Articles Snippets Projects

The Flaky Test Chronicles V: The Abstraction Avalanche

Building a Bulletproof TestCase Architecture

January 1st ʼ26 2 months ago 8 min 1527 words

Your TestCase is the constitution of your test suite. Write it carefully.

Your TestCase file starts small. One trait. Two helper methods. A year passes. Now it's 400 lines of conflicting setup logic, and every new test inherits all of it.

What belongs in TestCase:

  • Global configuration that NEVER changes per test

  • Traits that EVERY test needs

  • Cleanup that's always required

What doesn't belong:

  • Setup for specific test types (put in sub-classes or traits)

  • Mocks for specific services

  • Business logic setup


The setUp() Anatomy

A well-structured setUp() method reads like a checklist of “things I don’t want to think about in every test.”

Disable External Dependencies

protected function setUp(): void
{
    parent::setUp();

    // No asset bundling in tests
    $this->withoutVite();

    // No rate limiting headaches
    $this->withoutMiddleware(ThrottleRequests::class);
}

Configure Test Defaults

// In setUp()

// Set default calculation modes
config(['app.calculation_mode' => CalculationMode::COMPOUND]);

// Add domain-specific Faker providers
$this->faker->addProvider(new NationalIdProvider($this->faker));

// Set safe defaults for throttling
config(['app.throttle_limit' => 1000]);

Disable Observable Events

Model observers can send notifications, update related records, and call external services. None of that should happen unless you're explicitly testing it. The ignorable-observers package lets you suppress specific events while keeping others active:

// In setUp()
Application::ignoreObservableEvents(['saved', 'created', 'updated']);
Order::ignoreObservableEvents(['saved', 'updated']);
Customer::ignoreObservableEvents(['saved', 'created', 'updated']);
Product::ignoreObservableEvents(['saved', 'created', 'updated']);
Merchant::ignoreObservableEvents(['saved', 'updated']);
// ... 9+ models total

When you need to test observer behavior, temporarily re-enable with Model::unignoreObservableEvents(['created']).


Trait Organization

Traits are powerful. They're also easy to abuse. Here's how to use them well.

When to Use Traits

  1. Concern is optional - Not every test needs it

  2. Has its own lifecycle - Needs setUp/tearDown hooks

  3. Reusable across test classes - Multiple tests share the same concern

Good candidates:

  • RefreshesSushiModels - Only tests using Sushi models with Config mocks

  • AlternateRegionTrait - Only tests for alternate region

  • HasFormRequestAssertions - Only validation tests

Laravel's Automatic Hook Discovery

Laravel automatically discovers and calls methods named setUpXXX() and tearDownXXX() in traits:

The Sushi Problem

Sushi models cache their SQLite connections statically. When tests mock facades (especially the Application container), this cached connection can become corrupted:

Mockery\Exception\BadMethodCallException:
Received Mockery_0::connection(), but no expectations were specified

You mocked Config. Sushi's static connection is pointing to a Mockery ghost. The solution is an opt-in trait that resets the connection:

trait RefreshesSushiModels
{
    protected function setUpRefreshesSushiModels(): void
    {
        $this->resetSushiConnection();
    }

    protected function tearDownRefreshesSushiModels(): void
    {
        $this->resetSushiConnection();
    }

    private function resetSushiConnection(): void
    {
        CachedOrderSummary::flushEventListeners();

        $reflection = new ReflectionClass(CachedOrderSummary::class);
        $property = $reflection->getProperty('sushiConnection');
        $property->setAccessible(true);
        $property->setValue(null, null);

        CachedOrderSummary::clearBootedModels();
    }
}

When to Stay in TestCase

Some concerns are truly global:

  • DatabaseTransactions - Every test needs isolation

  • MockeryPHPUnitIntegration - Mockery cleanup is universal

  • WithFaker - Almost every test uses fake data

  • CreatesApplication - Laravel bootstrap


Authentication Patterns

Authentication setup is where many tests go wrong. Either they do too much (creating clients in every test) or too little (forgetting scopes).

Passport Client Lazy Initialization

Create clients only when needed:

public function createPassportClients(): void
{
    if (Passport::client()->count() >= 5) {
        return;  // Already initialized
    }

    $this->artisan('create:personal-access-clients');
}

Old pattern (slow):

// In TestCase setUp() - runs for EVERY test
$this->createPassportClients();

New pattern (fast):

// Only in tests that need authentication
$this->createPassportClients();
$this->actingAsMerchantUser();

Convenience Authentication Helpers

protected function actingAsMerchantUser(): self
{
    $merchant = Merchant::factory()->create();
    $user = MerchantUser::factory()
        ->for($merchant)
        ->owner()
        ->create();

    // Laravel's actingAs for guard selection
    $this->actingAs($user, 'merchant');

    // Passport for token scopes (if needed)
    Passport::actingAs($user, ['orders:read']);

    return $this;
}

protected function actingAsCustomer(): self
{
    $customer = Customer::factory()->create();

    // For API guard with Passport
    $this->actingAs($customer, 'api');
    Passport::actingAs($customer, []);

    return $this;
}

Multi-Guard Setup

// Admin API - guard + scopes
$this->actingAs($adminUser, 'api');
Passport::actingAs($adminUser, ['admin:full']);

// Mobile app - simple API access
$this->actingAs($customer, 'api');
Passport::actingAs($customer, []);

// Merchant portal - guard + specific scopes
$this->actingAs($merchantUser, 'merchant');
Passport::actingAs($merchantUser, ['orders:read', 'orders:write']);

Service Faking Strategy

The Golden Rule

Fake BEFORE factory creation.

// CORRECT
Notification::fake();
Mail::fake();
$order = Order::factory()->create();  // afterCreating hooks won't send

// WRONG
$order = Order::factory()->create();  // afterCreating hooks already sent!
Notification::fake();  // Too late

Common Fakes

Fake

Use For

Http::fake()

External API calls

Notification::fake()

SMS, email, push notifications

Queue::fake()

Job dispatch assertions

Bus::fake()

Command bus assertions

Event::fake()

Event dispatch assertions

Storage::fake('local')

File operations

Partial Fakes

Sometimes you want SOME jobs to run, but not others:

// Only fake these jobs - let others run normally
Bus::fake([
    SendNotificationJob::class,
    UpdateSearchIndexJob::class,
]);

This is better than faking everything and then wondering why your test doesn't work.


Environment & Locale Testing

Country-Specific Tests

For multi-country applications, tests must set the country explicitly:

trait AlternateRegionTrait
{
    protected function setUp(): void
    {
        parent::setUp();
        Config::set('app.country', Region::EU->value);
    }

    protected function tearDown(): void
    {
        Config::set('app.country', Region::DEFAULT->value);  // Reset to default
        parent::tearDown();
    }
}

Alternative: Set in Test

public function it_calculates_regional_tax(): void
{
    Region::EU->setCurrent();

    // Test region-specific behavior...
}

What's Next

Missed the previous part? Part 4: The Teardown Tango covers tearDown ordering, file isolation, observable events, and assertion patterns.

Part 6: The Reference is the finale - everything from Parts 1-5, organized into checklists:

  • Time & Randomness checklist

  • Mocking checklist

  • Cleanup & Assertions checklist

  • The complete reference for code reviews and onboarding

See you there.


The Flaky Test Chronicles is a series documenting what we learned from 300+ commits of test suite cleanup. Your TestCase is the constitution of your test suite. Amend it wisely.