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
--parallelfor 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)
Schema Dumps (Recommended)
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 programmesEnrolmentListServiceMock- Returns 3 test enrolmentsExitListServiceMock- 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.