Articles Snippets Projects

The Flaky Test Chronicles II: Mock Madness

Making Mockery Parallel-Safe

December 25th ʼ25 2 months ago 8 min 1501 words

The Crime Scene

53 alias and overload mocks walked into a CI pipeline. None of them came out parallel-safe.

One morning we saw this error in CI:

Mockery\Exception\RuntimeException: Could not load mock
App\Services\PaymentGateway\CreditCard\PaymentProcessor, class already exists

Test passes locally. Fails in CI. The classic. But this time, I knew exactly what it was. I just didn't want to admit it.

The problem: Mockery alias and overload mocks.

Across multiple test files, we were using Mockery::mock('alias:...') and Mockery::mock('overload:...') in 53 places. And these mocks weren't compatible with parallel test runners.


Why Alias and Overload Mocks Are the Enemy

Mockery's alias: and overload: prefixes let you mock static methods and class instantiation. Sounds great. But they have serious problems.

Problem 1: Not Parallel-Safe

Both alias and overload mocks globally modify PHP's class autoloader. When two tests run in parallel and try to mock the same class:

class already exists

PHP looks at you like you just asked it to divide by zero.

Tests run in parallel by default in CI environments. If you run sequentially locally, you won't notice. It blows up in CI.

But Wait, Doesn't Mockery::close() Fix This?

No. Here's the thing: PHP cannot unload a class once it's been defined. This is a fundamental limitation of the PHP runtime, not a Mockery bug.

When you call Mockery::close(), it does these things:

  • Verifies that all mock expectations were met

  • Releases mock object references from memory

  • Prevents memory leaks between tests

But it cannot remove the fake class definition from PHP's class registry. Once PaymentProcessor is defined (even as a mock), it stays defined until the PHP process terminates.

Test A: Mockery::mock('alias:PaymentProcessor')
         Fake PaymentProcessor registered in PHP's class registry

Test A ends: Mockery::close()
        → Mock expectations cleared
        → BUT: PaymentProcessor class definition still in memory!

Test B: Mockery::mock('alias:PaymentProcessor')
        → FATAL: "class already exists"

This is why alias and overload mocks and parallel testing are fundamentally incompatible—not because Mockery is broken, but because PHP's class system doesn't support class unloading.

Problem 2: The Process Isolation Tax

The only safe way to use alias or overload mocks is with @runInSeparateProcess annotation. But this:

  • Slows down by 10-100x - Each test spawns a new PHP process

  • Makes debugging impossible - Breakpoints and var_dump don't work

  • Memory overhead - Each process reloads the framework

  • Multiplies CI time - Parallel runner can't optimize

Problem 3: Design Smell

If you need alias or overload mocks, your code is tightly coupled to static calls. This is a design problem.


The Pattern to Avoid

Here's the pattern we saw 43 times in our project:

// BAD: Alias mock - not parallel-safe
Mockery::mock('alias:'.PaymentProcessor::class)
    ->shouldReceive('create')
    ->with($paymentDTO, $cardNumber)
    ->once()
    ->andReturnSelf()
    ->shouldReceive('withCard')
    ->andReturnSelf()
    ->shouldReceive('pay3d')
    ->andReturn(redirect($url));

This code works. Locally. In sequential mode. But when CI's parallel runner kicks in, it explodes.

The core issue? PaymentProcessor::create() is a static factory method. Static calls don't go through Laravel's container. You can't intercept them with $this->mock(). The only way to mock them is with alias or overload mocks, which brings us back to the parallel-unsafe territory.


Why Container Mocking is Parallel-Safe

Before we look at solutions, let's understand why container-based mocking doesn't suffer from the same parallel test issues.

Alias Mock

Container Mock

What it modifies

PHP Autoloader (global)

Laravel Container (test-scoped)

Scope

Entire PHP process

Single test instance

Parallel-safe?

When PHPUnit runs tests in parallel, each process gets its own PHP runtime and its own Laravel Application instance. Container bindings are isolated to that instance. Alias mocks, however, modify PHP's class autoloading mechanism—which is shared across the entire process.

The key insight: any solution that routes through the container is parallel-safe. Factory injection, container binding, Facades—they all work because they use the container, not the autoloader.


The Escape Routes

Good news: there are multiple ways out of alias and overload mock hell. Bad news: none of them are free.

Each approach trades off different things:

Approach

Effort

Elegance

Keeps Static Syntax

Parallel-Safe

Factory Pattern

Low

Medium

Facade Pattern

High

High

Container Binding

Lowest

Low

Let's explore each one.


Option 1: Factory Pattern

The Factory pattern wraps your static calls in an injectable class. Quick to implement, gets the job done.

Pros: Minimal changes, fast to implement, works immediately

Cons: Changes call sites from Class::method() to $this->factory->method()

Step 1: Create a Factory Class

<?php

declare(strict_types=1);

namespace App\Services\PaymentGateway\CreditCard;

use App\Enums\ChannelType;
use App\Services\PaymentGateway\CreditCard\DTOs\PaymentDTO;

class PaymentProcessorFactory
{
    /**
     * Create a PaymentProcessor instance.
     *
     * Note: Return type intentionally omitted for Mockery compatibility
     * with readonly classes.
     *
     * @return PaymentProcessor
     */
    public function create(PaymentDTO $payment, ?string $cardNumber = null, ?ChannelType $channelType = null)
    {
        return PaymentProcessor::create($payment, $cardNumber, $channelType);
    }

    /**
     * Get default payment calculator.
     *
     * @return AbstractPaymentCalculator
     */
    public function default()
    {
        return PaymentProcessor::default();
    }
}

Step 2: Inject the Factory in Controllers

class OrderPayWithCreditCardController extends Controller
{
    public function __construct(
        private readonly PaymentProcessorFactory $paymentProcessorFactory
    ) {}

    public function pay3D(Request $request, Order $order): RedirectResponse
    {
        $paymentDTO = PaymentDTO::fromOrder($order);

        // Use factory instead of static call
        $payment = $this->creditCardPaymentFactory->create(
            $paymentDTO,
            $request->card_number
        );

        return $payment->withCard($cardDTO)->pay3d();
    }
}

Step 3: Mock the Factory in Tests

#[Test]
public function it_pays_using_credit_card_with_3d(): void
{
    // Arrange
    $order = Order::factory()->create();
    $redirectUrl = 'https://payment.example.com/3d';

    // Create mock for PaymentProcessor instance
    $mockProcessor = Mockery::mock();
    $mockProcessor->shouldReceive('withCard')
        ->with(Mockery::on(static fn ($args) => $args->is($cardDTO)))
        ->once()
        ->andReturnSelf();
    $mockProcessor->shouldReceive('withTransactionType')
        ->with(TransactionType::RETAILER_CASH)
        ->once()
        ->andReturnSelf();
    $mockProcessor->shouldReceive('pay3d')
        ->once()
        ->andReturn(redirect($redirectUrl));

    // Mock factory to return our mock instance
    $this->mock(PaymentProcessorFactory::class, function (MockInterface $mock) use ($mockProcessor, $order): void {
        $mock->shouldReceive('create')
            ->with(
                Mockery::on(function ($payable) use ($order): bool {
                    $this->assertEquals($order->cash_price, $payable->getPaymentAmount());
                    return true;
                }),
                '5555555555555555'
            )
            ->once()
            ->andReturn($mockProcessor);
    });

    // Act
    $response = $this->postJson(route('orders.pay-3d', $order), $paymentData);

    // Assert
    $response->assertRedirect($redirectUrl);
}

Option 2: Container Binding

If you want to avoid creating factory classes, you can use Laravel's container binding directly. This requires the least amount of new code.

Pros: No new classes, minimal code changes, quick to implement

Cons: Changes call sites from Class::method() to app(Class::class, [...])->method()

Step 1: Add Container Binding in ServiceProvider

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->bind(PaymentProcessor::class, static function ($app, array $params): PaymentProcessor {
        return PaymentProcessor::create(
            payable: $params['payable'],
            cardNumber: $params['cardNumber'] ?? null,
        );
    });
}

Step 2: Replace Static Calls

// Before:
PaymentProcessor::create($payable, $cardNumber)->pay3d();

// After:
app(PaymentProcessor::class, ['payable' => $payable, 'cardNumber' => $cardNumber])->pay3d();

Step 3: Mock in Tests

// Create a generic mock (works with readonly classes too)
$mockPayment = Mockery::mock();
$mockPayment->shouldReceive('pay3d')
    ->once()
    ->andReturn(redirect($url));

// Bind mock to container
$this->app->bind(PaymentProcessor::class, static fn () => $mockPayment);

Option 3: Facade Pattern

What if you really love that static syntax? What if PaymentProcessor::create() feels like home and you don't want to leave?

Laravel Facades let you keep the static-looking syntax while routing everything through the container. The catch? You'll need to rename your existing class.

// Step 1: Rename the original class
class PaymentProcessorService  // was: PaymentProcessor
{
    public function create(PaymentDTO $payable): self
    {
        return new self(
            gateway: (new PaymentGatewayFactory())->create($payable)
        );
    }
}

// Step 2: Create the Facade
class PaymentProcessor extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return PaymentProcessorService::class;
    }
}

Usage stays the same:

// Production code - unchanged!
PaymentProcessor::create($payable)->withCard($card)->pay3d();

// Test - now parallel-safe
PaymentProcessor::shouldReceive('create')
    ->once()
    ->andReturn($mockPayment);

Notice the ::shouldReceive() syntax in tests. This only works because PaymentProcessor is now a Facade. Regular PHP classes don't have this method—it's provided by Laravel's Facade base class. If you try SomeRegularClass::shouldReceive(), you'll get a fatal error.

The Facade approach gives you the best of both worlds: familiar static syntax and container-based testability. But it requires renaming your original class and setting up the Facade infrastructure.


What We Actually Did

So which path did we take? The fastest one. The least invasive one. The one that ships.

We were already knee-deep in a major architectural refactoring. Creating factory classes or setting up Facade infrastructure would have been scope creep.

So we went with Option 2: Container Binding. It required the least amount of new code. We just needed to add a binding in the ServiceProvider and change static calls to container resolution.

Step 1: Add Container Binding

// app/Providers/AppServiceProvider.php
$this->app->bind(PaymentProcessor::class, static function ($app, array $params): PaymentProcessor {
    return PaymentProcessor::create(
        payable: $params['payable'],
        cardNumber: $params['cardNumber'] ?? null,
        platformType: $params['platformType'] ?? null
    );
});

Step 2: Update Call Sites

// Before: Static call, not mockable
PaymentProcessor::create($payable, $cardNumber)->withCard($card)->pay3d();

// After: Container resolution, mockable
app(PaymentProcessor::class, ['payable' => $payable, 'cardNumber' => $cardNumber])
    ->withCard($card)
    ->pay3d();

Step 3: Update Tests

// Before: Alias mock (not parallel-safe)
Mockery::mock('alias:'.PaymentProcessor::class)
    ->shouldReceive('create')
    ->andReturnSelf()
    ->shouldReceive('withCard')
    ->andReturnSelf()
    ->shouldReceive('pay3d')
    ->andReturn(redirect($url));

// After: Container binding (parallel-safe)
$mockPayment = Mockery::mock();
$mockPayment->shouldReceive('withCard')->once()->andReturnSelf();
$mockPayment->shouldReceive('pay3d')->once()->andReturn(redirect($url));

$this->app->bind(PaymentProcessor::class, static fn () => $mockPayment);

And here's the thing about technical debt: we acknowledged it. We created a backlog item to migrate to the Facade pattern later. The elegant solution is waiting for a quieter sprint.

The takeaway: Sometimes the right solution isn't the perfect one. It's the one that unblocks your team today while leaving the door open for improvements tomorrow.


Mocking Variations

Different mock setups for different scenarios with container binding:

Payment Table (Single Method After Create)

$mockPayment = Mockery::mock();
$mockPayment->shouldReceive('getPaymentTable')
    ->once()
    ->andReturn(new PaymentTableDTO([...]));

$this->app->bind(PaymentProcessor::class, static fn () => $mockPayment);

Chained Method Calls

$mockPayment = Mockery::mock();
$mockPayment->shouldReceive('withCard')
    ->with(Mockery::on(fn ($card) => $card->number === '5555555555555555'))
    ->once()
    ->andReturnSelf();
$mockPayment->shouldReceive('withTransactionType')
    ->with(TransactionType::RETAILER_CASH)
    ->once()
    ->andReturnSelf();
$mockPayment->shouldReceive('pay3d')
    ->once()
    ->andReturn(redirect($redirectUrl));

$this->app->bind(PaymentProcessor::class, static fn () => $mockPayment);

Exception Testing

$mockPayment = Mockery::mock();
$mockPayment->shouldReceive('withCard')->once()->andReturnSelf();
$mockPayment->shouldReceive('pay3d')
    ->once()
    ->andThrow(new MokaPaymentAmountException('Payment failed'));

$this->app->bind(PaymentProcessor::class, static fn () => $mockPayment);

$response = $this->postJson(route('orders.pay-3d', $order), $paymentData);
$response->assertUnprocessableEntity();

Never-Called Assertions

// Verify payment is never attempted when validation fails
$mockPayment = Mockery::mock();
$mockPayment->shouldReceive('withCard')->never();
$mockPayment->shouldReceive('pay3d')->never();

$this->app->bind(PaymentProcessor::class, static fn () => $mockPayment);

$response = $this->postJson(route('orders.pay-3d', $ineligibleOrder), $paymentData);
$response->assertUnprocessableEntity();

Handling Readonly Classes

PHP 8.2+ readonly classes pose a special challenge for Mockery.

// This fails if PaymentProcessor is readonly
$mock = Mockery::mock(PaymentProcessor::class); // Error!

Solution 1: Untyped Mocks

// Don't specify class - create generic mock
$mockProcessor = Mockery::mock(); // Works!
$mockProcessor->shouldReceive('pay3d')->andReturn(...);

Solution 2: Remove Return Types from Factory

class PaymentProcessorFactory
{
    /**
     * Return type omitted for Mockery readonly class compatibility.
     *
     * @return PaymentProcessor
     */
    public function create(PaymentDTO $payable) // No return type!
    {
        return PaymentProcessor::create($payable);
    }
}

The Migration Checklist

When migrating from alias or overload mocks to container binding:

  1. Add container binding in ServiceProvider for the class

  2. Replace static calls (Class::create()) with container resolution (app(Class::class, [...]))

  3. Update tests - use $this->app->bind() instead of alias or overload mock

  4. If readonly class, use Mockery::mock() without class parameter

  5. Check if static method returns a different class (like ::default()) and add separate binding

  6. Run tests to verify refactoring

  7. Search for Mockery::mock('alias: and Mockery::mock('overload: and repeat

Real-World Impact

Results of our refactoring:

Test File

Mocks Removed

Feature/OrderPaymentControllerTest

8

Feature/PriceCalculatorControllerTest

9

Feature/ApplicationControllerTest

11

Mobile/OrderPayWithCreditCardControllerTest

8

General/OrderPayWithCreditCardControllerTest

7

ZiraatPaymentProviderTest

5

ZiraatPaymentTableCalculatorTest

1

IPSPriceCalculationJobTest

1

PaymentGatewayExceptionHandlerTest

3

Total

53

Result: All affected tests now run in parallel, no conflicts.


Mockery Best Practices

Beyond alias and overload mocks, here are the fundamental rules of Mockery usage:

1. MockeryPHPUnitIntegration Trait

Add this trait to your base TestCase:

<?php

namespace Tests;

use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    use MockeryPHPUnitIntegration;

    // ...
}

Why it matters:

  • Mockery expectations are verified at end of each test

  • Mock assertion counts integrate with PHPUnit

  • Mockery closes properly after each test

2. Always Add Call Count Expectations

No call count on mock = no assertion = risky test.

Wrong (Risky Test):

BankCollection::shouldReceive('updatePaymentStatusInfoWithPaymentExpCode')
    ->with($paymentId, $expCode, PaymentStatusType::MATCHED);
// No call count = no assertion = PHPUnit warning

Correct:

BankCollection::shouldReceive('updatePaymentStatusInfoWithPaymentExpCode')
    ->once()  // This is an assertion
    ->with($paymentId, $expCode, PaymentStatusType::MATCHED);

Call Count Methods:

Method

Usage

->once()

Must be called exactly 1 time

->twice()

Must be called exactly 2 times

->times(n)

Must be called exactly n times

->atLeast()->times(n)

Must be called at least n times

->atMost()->times(n)

Must be called at most n times

->never()

Must never be called

3. partialMock() vs shouldReceive()

Using shouldReceive() directly on facades is dangerous.

Wrong (Can corrupt application container):

LaravelConfig::shouldReceive('get')
    ->with('some.config.key')
    ->once()
    ->andReturn(true);

Correct:

LaravelConfig::partialMock()
    ->shouldReceive('get')
    ->with('some.config.key')
    ->once()
    ->andReturn(true);

Why partialMock() is better:

  • Only mocks specified methods

  • Rest of facade remains functional

  • Doesn't interfere with application container

  • Prevents Sushi model caching issues

The Facade vs Class Naming Trap

There's another subtle mock failure mode: when a facade and its underlying class share the same name.

We had a package with this structure:

TarfinLabs\LaravelConfig\LaravelConfig          // The class
TarfinLabs\LaravelConfig\Facades\LaravelConfig  // The facade

See the problem? When you type LaravelConfig in your IDE, autocomplete shows both. It's easy to pick the wrong one.

Wrong (mocking the class directly):

// BAD: Importing the class, not the facade
use TarfinLabs\LaravelConfig\LaravelConfig;

$this->mock(LaravelConfig::class)
    ->shouldReceive('get')
    ->once()
    ->andReturn('value');

// Mock is set up... but facade calls bypass it entirely!

The mock doesn't intercept anything because production code uses the facade, not the class directly.

Correct:

// GOOD: Using the facade
use TarfinLabs\LaravelConfig\Facades\LaravelConfig;

LaravelConfig::partialMock()
    ->shouldReceive('get')
    ->once()
    ->andReturn('value');

The fix: We renamed the class to ConfigManager in v6:

TarfinLabs\LaravelConfig\ConfigManager          // The class (renamed)
TarfinLabs\LaravelConfig\Facades\LaravelConfig  // The facade

Now when you type LaravelConfig, only the facade appears. Problem solved at the source.

4. Conditional Mocking for DataProviders

When using DataProviders, not every test case runs the same code path.

Wrong (Some datasets fail):

#[DataProvider('applicationStatuses')]
#[Test]
public function suspend_previous_applications(ApplicationStatus $status): void
{
    // Mock setup happens every time
    BankMock::cancelApplication(true);

    // But some statuses don't call Fiba::cancelApplication()!
    // InvalidCountException: Expected 1 call, received 0
}

Correct:

#[DataProvider('applicationStatuses')]
#[Test]
public function test_suspend_previous_applications(ApplicationStatus $status): void
{
    $statusesThatTriggerCancellation = [
        ApplicationStatus::BANK_DECISION_WAITING,
        ApplicationStatus::BANK_PIN_WAITING,
    ];

    // Only mock if code path will be taken
    if (in_array($status, $statusesThatTriggerCancellation, true)) {
        BankMock::cancelApplication(true);
    }

    // Test logic...
}

5. Don't Mock Unreachable Code

When using Bus::fake() or Queue::fake(), job internals don't execute.

Wrong:

Bus::fake();  // Jobs won't execute

BankMock::storeApplicant(true, [  // This will fail!
    'resultState' => StoreApplicantResultType::TH->name,
]);

StoreApplicantJob::dispatch($application);

Bus::assertDispatched(StoreApplicantJob::class);

Correct:

Bus::fake();

// BankMock not needed - job doesn't execute

StoreApplicantJob::dispatch($application);

Bus::assertDispatched(StoreApplicantJob::class);

Scenarios where mock is unnecessary:

  • Bus::fake() - queued jobs don't execute

  • Queue::fake() - queued jobs don't execute

  • Early return - subsequent code doesn't run

  • Exception throw - code after exception doesn't run


PHPUnit Mock vs Stub

PHPUnit 10+ separates mock and stub. In PHPUnit 12, a mock without expectations generates a warning.

Type

Purpose

Method

Mock

Verify calls/arguments

createMock() + expects()

Stub

Placeholder/return value

createStub()

Rules:

  1. If you only need a placeholder, use createStub()

  2. If you need to verify calls, use createMock() + expects($this->once())

  3. With getMockBuilder()->onlyMethods(...), stub every listed method

Examples:

// Stub - no verification
$property = $this->createStub(DataProperty::class);
$context = $this->createStub(CreationContext::class);
$result = $cast->cast($property, 'value', [], $context);
$this->assertEquals('expected', $result);

// Mock - with verification
$logger = $this->createMock(Logger::class);
$logger->expects($this->once())->method('log')->with('error');
$service->doSomething();

Concrete Test Stubs

If you only need to provide return values, creating a concrete test stub class avoids PHPUnit warnings:

final class StubQueryBuilder extends BaseQueryBuilder
{
    public function __construct(private readonly array $filters = [])
    {
        $this->model = StubModel::class;
    }

    public function allowedFilters(): array { return $this->filters; }
    public function allowedIncludes(): array { return []; }
    public function allowedFields(): array { return []; }
    public function allowedAppends(): array { return []; }
    public function allowedSorts(): array { return []; }
}

Laravel/Mockery vs PHPUnit Mocking

Tool

Stub

Mock

PHPUnit 10+ notice?

PHPUnit

createStub()

createMock() + expects()

Yes (if no expectations)

Mockery

spy()

shouldReceive()->once()

No

Laravel facade

Facade::fake()

shouldReceive()->once()

No

Laravel helper

$this->spy()

$this->mock()

No

Quick Picks:

  • Notifications/Mail/Event tests: Facade::fake() + assertSent/assertDispatched

  • External services: $this->mock(Service::class, fn ($m) => $m->shouldReceive('call')->once())

  • Partial behavior: $this->partialMock(Class::class, fn ($m) => $m->shouldReceive('send')->once())

  • Cast/DTO placeholders (no container): createStub()


What's Next

In Part 1: The Reckoning, we explored the six deadly sins of flaky tests and why your CI fails at random.

Part 3: The Determinism Principle tackles predictability:

  • Flaky timestamp comparisons

  • The five methods of time control

  • The random value trap

  • DataProviders running before Laravel boot

  • Factory patterns and state management

See you there.


The Flaky Test Chronicles is a series documenting what we learned from 300+ commits of test suite cleanup. Remember: alias mocks are forever. Or until the process dies.