0

Stub — a test double that returns hardcoded values, no expectations

Beginner5 min read·eng-17-006
interview

Concept

Stub — a test double that returns predefined (hardcoded) values when called. Unlike a mock, a stub does NOT verify that it was called. It just provides controlled return values.

The difference from a mock:

  • Stub: "When called, return this value." No verification.
  • Mock: "Must be called exactly once with these args. Also return this value."

When to use stubs:

  • You need a dependency to return a specific value so you can test your code's reaction to it.
  • You don't care if the dependency was called — only what happens in your code after.
  • You want to test error paths (stub throws an exception).

Stub for different scenarios:

  • willReturn($value): Always returns this value.
  • willReturnMap([]): Returns different values for different arguments.
  • willThrowException(new \Exception('error')): Throws on call.
  • willReturnCallback(fn() => ...): Custom logic.

Common stubs in Laravel:

  • Http::fake(): Stubs HTTP responses so no real network request happens.
  • Cache::fake(): Stubs the cache driver.
  • Stubbing Eloquent models in unit tests.

Stubbing in PHPUnit: You create a mock object (same API) but use method()->willReturn() without expects(). Without expects(), it's a stub — no verification.

Code Example

php
<?php
// STUB — returns controlled values, no verification
class OrderServiceTest extends \PHPUnit\Framework\TestCase
{
    public function test_creates_order_when_payment_succeeds(): void
    {
        // STUB: PaymentGateway always returns success
        $stubGateway = $this->createStub(PaymentGateway::class); // createStub = mock without expectations
        $stubGateway->method('charge')
                    ->willReturn(new PaymentResult(success: true, transactionId: 'tx_abc123'));
        // Don't care if charge() is called once, twice, or never
        // Just need it to return success when called

        $service = new OrderService($stubGateway, new InMemoryOrderRepository());
        $order   = $service->place(['total' => 100], 'tok_valid');

        $this->assertEquals('paid', $order->status);
        $this->assertEquals('tx_abc123', $order->transactionId);
    }

    public function test_marks_order_failed_when_payment_declines(): void
    {
        // STUB: PaymentGateway returns failure
        $stubGateway = $this->createStub(PaymentGateway::class);
        $stubGateway->method('charge')
                    ->willReturn(new PaymentResult(success: false, transactionId: null, error: 'Card declined'));

        $service = new OrderService($stubGateway, new InMemoryOrderRepository());
        $order   = $service->place(['total' => 100], 'tok_decline');

        $this->assertEquals('payment_failed', $order->status);
    }

    public function test_handles_gateway_exception(): void
    {
        // STUB: throws exception
        $stubGateway = $this->createStub(PaymentGateway::class);
        $stubGateway->method('charge')
                    ->willThrowException(new \RuntimeException('Connection timeout'));

        $service = new OrderService($stubGateway, new InMemoryOrderRepository());

        $this->expectException(\App\Exceptions\PaymentException::class);
        $service->place(['total' => 100], 'tok_timeout');
    }

    public function test_returns_different_values_for_different_args(): void
    {
        // STUB with argument-based responses
        $stubRepo = $this->createStub(OrderRepositoryInterface::class);
        $stubRepo->method('find')
                 ->willReturnMap([
                     [1, new Order(['id' => 1, 'status' => 'paid'])],
                     [2, new Order(['id' => 2, 'status' => 'pending'])],
                     [99, null], // not found
                 ]);

        $this->assertEquals('paid',    $stubRepo->find(1)->status);
        $this->assertEquals('pending', $stubRepo->find(2)->status);
        $this->assertNull($stubRepo->find(99));
    }
}

// Laravel Http::fake() — stub for HTTP calls
\Http::fake([
    'https://api.stripe.com/*'  => \Http::response(['id' => 'ch_123', 'status' => 'succeeded'], 200),
    'https://api.example.com/*' => \Http::response(['error' => 'Not found'], 404),
    '*'                          => \Http::response('', 500), // all others fail
]);

// Now any Http::post('https://api.stripe.com/charges') returns the stubbed response
$result = Http::post('https://api.stripe.com/v1/charges', [...]);
$result->status(); // 200
$result->json();   // ['id' => 'ch_123', 'status' => 'succeeded']