Skip to content

Testing Infrastructure Guide

Last Updated: October 27, 2025
Laravel: 12.x | Pest: v4 | Spatie Multitenancy: v4


Overview

This guide covers the modernized testing infrastructure for the Flowcode application. The system is designed for speed, reliability, and maintainability, leveraging Laravel 12's parallel testing capabilities, Pest v4's modern architecture, and proper multi-tenancy isolation.


Quick Start

Running Tests

# Run all tests
php artisan test

# Run with profiling
php artisan test --profile

# Run tests in parallel (faster)
php artisan test --parallel

# Run specific test file
php artisan test tests/Unit/Models/UserTest.php

# Run with filter
php artisan test --filter=UserTest

# Stop on first failure
php artisan test --stop-on-failure

Writing a New Test

<?php

// Uses StandardTestCase by default (configured in Pest.php)
// Provides automatic database transactions and tenant helpers

use App\Models\User;
use App\Models\Tenant;

it('creates a user successfully', function () {
    $user = User::factory()->create([
        'name' => 'Test User',
    ]);

    expect($user->name)->toBe('Test User');
});

// Test with tenant context
it('creates user in specific tenant', function () {
    $tenant = $this->createTestTenant(['name' => 'Test Org']);

    $this->inTenant($tenant, function () use ($tenant) {
        $user = User::factory()->create();

        expect($user->tenant_id)->toBe($tenant->id);
    });
});

Architecture

Test Class Hierarchy

PHPUnit\Framework\TestCase (Pest v4)
    ↓
Tests\BaseTestCase
    ├── Multi-tenancy setup
    ├── Application bootstrapping
    └── Landlord tenant configuration
        ↓
    Tests\StandardTestCase (Most tests - 95%)
        ├── Database transactions (auto rollback)
        └── TenantTestingTrait methods
        ↓
    Tests\DatabaseCommitTestCase (Special cases - <5%)
        └── No transactions (tests actual commits)

DuskTestCase: Separate hierarchy for browser tests, uses DatabaseTruncation.

When to Use Each Base Class

Base Class Use When Transaction Handling
StandardTestCase 95% of tests ✅ Auto rollback after each test
DatabaseCommitTestCase Testing actual DB commits, observers that commit ❌ No transactions (manual cleanup)
DuskTestCase Browser/E2E tests Uses DatabaseTruncation

Multi-Tenancy Testing

Available Helpers (TenantTestingTrait)

All standard tests have access to these helpers:

// Switch to a specific tenant
$this->switchToTenant($tenant);

// Switch back to landlord
$this->switchToLandlord();

// Create a test tenant
$tenant = $this->createTestTenant([
    'name' => 'Test Organisation',
    'code' => 'TEST001',
]);

// Create and immediately switch to tenant
$tenant = $this->createAndSwitchToTenant(['name' => 'Test Org']);

// Get the landlord tenant
$landlord = $this->getLandlord();

// Execute code in tenant context (auto-switches back after)
$this->inTenant($tenant, function () {
    $user = User::factory()->create();
    // User is created in $tenant context
});

Example: Multi-Tenant Test

it('isolates data between tenants', function () {
    $tenant1 = $this->createTestTenant(['name' => 'Org 1']);
    $tenant2 = $this->createTestTenant(['name' => 'Org 2']);

    // Create user in tenant 1
    $this->inTenant($tenant1, function () {
        User::factory()->create(['name' => 'User 1']);
    });

    // Create user in tenant 2
    $this->inTenant($tenant2, function () {
        User::factory()->create(['name' => 'User 2']);
    });

    // Verify isolation
    $this->inTenant($tenant1, function () {
        expect(User::count())->toBe(1);
    });

    $this->inTenant($tenant2, function () {
        expect(User::count())->toBe(1);
    });
});

Parallel Testing

How It Works

Laravel 12's parallel testing creates separate databases for each worker:

Main Database: envoy_test
Worker 1: envoy_test_test_1
Worker 2: envoy_test_test_2
Worker 3: envoy_test_test_3
...

Each worker: 1. Gets its own database (created automatically) 2. Runs migrations once at startup 3. Seeds data once at startup 4. Runs tests using transactions (rollback after each) 5. Cleans up database after all tests complete

Performance

Parallel Testing Flow:

Test Run Starts
    ↓
ParallelTesting::setUpProcess() (once per worker)
    ↓
Create database: envoy_test_test_N
    ↓
Run migrations
    ↓
Seed LandlordTenantSeeder
    ↓
Seed ProductionSeeder (on tenant)
    ↓
Worker Ready
    ↓
ParallelTesting::setUpTestCase() (per test)
    ↓
Configure database connection
    ↓
BaseTestCase::setUp()
    ├── Set up landlord tenant
    └── StandardTestCase::setUp()
        └── Begin transaction
    ↓
Run Test
    ↓
StandardTestCase::tearDown()
    └── Rollback transaction (automatic cleanup)

Configuration

Parallel testing is configured in phpunit.xml:

<phpunit>
    <!-- Parallel execution -->
    <extensions>
        <bootstrap class="Pest\Parallel\Support\ParallelPlugin"/>
    </extensions>
</phpunit>

Common Issues

Issue: "Table doesn't exist" in parallel mode

Cause: Database not properly migrated for worker
Solution: Already fixed in AppServiceProvider::setUpProcess() - always runs migrations

Issue: Slow parallel test startup

Cause: 177+ migration files run for each worker
Solution: Use schema dumps (see Optimization section below)


Best Practices

✅ DO

  • Use StandardTestCase for 95% of tests
  • Use TenantTestingTrait helpers for tenant operations
  • Use factories for test data
  • Use transactions for isolation
  • Mock external services (Mail, Queue, HTTP)
  • Test one thing per test
  • Use descriptive test names
  • Run with --parallel for speed
  • Profile tests regularly

❌ DON'T

  • Don't use sleep/delays - fix underlying race conditions instead
  • Don't create custom locks - Laravel handles synchronization
  • Don't manually manage transactions - base classes handle it
  • Don't seed data in individual tests - use factories
  • Don't test framework functionality - trust Laravel/Pest
  • Don't create separate test databases - use transactions
  • Don't use RefreshDatabase - use StandardTestCase
  • Don't commit database changes - unless using DatabaseCommitTestCase

Performance Optimization

Current Setup

With the modernized infrastructure: - ~600 lines of complexity removed (81% reduction) - ProductionSeeder runs once per worker (not per test) - No artificial delays (sleep/usleep removed) - No custom synchronization (file locks, MySQL locks removed) - Proper transaction isolation (fast rollback)

Problem: 177+ migration files make parallel testing startup slow (~20-30s per worker)

Solution: Use Laravel schema dumps to consolidate migrations

# Generate schema dump (one-time)
php artisan migrate
php artisan schema:dump --prune

# This creates database/schema/mysql-schema.sql
# and removes old migration files

Performance Improvement: 5-10x faster startup (from ~20-30s to ~3-5s per worker)

Maintenance:

# After adding new migrations, regenerate
php artisan migrate
php artisan schema:dump --prune
git add database/schema/
git commit -m "Update schema dump"

Profiling

# Find slow tests
php artisan test --profile

# The slowest tests will be listed at the end

REMS Testing

REMS (external system) data is mocked using static services:

Mock Services

  • RemsProgrammeServiceMock - Returns 4 test programmes
  • EnrolmentListServiceMock - Returns 3 test enrolments
  • ExitListServiceMock - Returns 3 test exits

Sushi Models (In-Memory)

use App\Models\Sushi\RemsEnrolment;
use App\Models\Sushi\RemsExit;

// These work with static data, no database needed
$enrolments = RemsEnrolment::all();
$exits = RemsExit::all();

No separate REMS database needed - all data is in-memory for fast tests.


Filament Testing

Testing Filament resources uses Livewire test helpers:

use Filament\Facades\Filament;

it('can list users', function () {
    $users = User::factory()->count(3)->create();

    livewire(ListUsers::class)
        ->assertCanSeeTableRecords($users);
});

it('can create user', function () {
    livewire(CreateUser::class)
        ->fillForm([
            'name' => 'New User',
            'email' => 'user@example.com',
        ])
        ->call('create')
        ->assertNotified()
        ->assertRedirect();

    assertDatabaseHas(User::class, [
        'email' => 'user@example.com',
    ]);
});

it('can call action', function () {
    $invoice = Invoice::factory()->create();

    livewire(EditInvoice::class, ['invoice' => $invoice])
        ->callAction('send');

    expect($invoice->refresh()->isSent())->toBeTrue();
});

Testing Multiple Panels

use Filament\Facades\Filament;

beforeEach(function () {
    Filament::setCurrentPanel('app');
});

Common Test Patterns

Testing Validation

it('requires name', function () {
    $response = $this->postJson('/api/users', [
        'email' => 'test@example.com',
        // name missing
    ]);

    $response->assertUnprocessable()
        ->assertJsonValidationErrors(['name']);
});

Testing Authorization

it('prevents unauthorized access', function () {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->get('/admin/users')
        ->assertForbidden();
});

Testing Jobs

use Illuminate\Support\Facades\Queue;

it('dispatches job', function () {
    Queue::fake();

    $user = User::factory()->create();

    // Trigger action that dispatches job
    $user->sendWelcomeEmail();

    Queue::assertPushed(SendWelcomeEmail::class);
});

Testing Mail

use Illuminate\Support\Facades\Mail;

it('sends welcome email', function () {
    Mail::fake();

    $user = User::factory()->create();
    $user->sendWelcomeEmail();

    Mail::assertSent(WelcomeEmail::class, function ($mail) use ($user) {
        return $mail->hasTo($user->email);
    });
});

Troubleshooting

Tests Failing in Parallel But Pass Individually

Cause: Shared state or improper isolation
Fix: - Ensure tests use transactions (StandardTestCase) - Don't use static variables - Don't rely on test execution order - Use $this->createTestTenant() for tenant-specific tests

Slow Tests

Cause: Too much setup, N+1 queries, missing eager loading
Fix:

# Profile to find slow tests
php artisan test --profile

# Look for:
- Excessive database queries
- Missing eager loading
- Complex setup in beforeEach
- Unnecessary seeding

Database Changes Not Rolling Back

Cause: Using DatabaseCommitTestCase or manual transaction control
Fix: Use StandardTestCase unless you specifically need to test commits

"Class not found" Errors

Cause: Autoloading issue
Fix:

composer dump-autoload
php artisan clear-compiled


Key Metrics

Metric Value
Test Files ~206 (63 Feature, 143 Unit)
Code Reduction 679 lines removed (81%)
Base Classes 3 (Base, Standard, DatabaseCommit)
Tenant Helpers 6 methods in TenantTestingTrait
Parallel Workers 8 (configurable)
Expected Speed Improvement 40-60%

References


History

For the complete history of this testing infrastructure refactoring, see: - docs/releases/testing-infrastructure-refactoring-2025-10.md


This testing infrastructure was modernized in October 2025 to leverage Laravel 12, Pest v4, and Spatie Multitenancy v4 capabilities.