Contract Tests

Fake and real, tested the same way

When you fake external APIs, how do you know the fake still matches the real thing?

You write a fake payment gateway for fast tests. Months later, the real API changes. Your tests still pass, but production breaks.

Contract Test Pattern Diagram
The Contract
// app/Contracts/PaymentGateway.php
interface PaymentGateway
{
    public function charge(int $amount, string $token): array;
}
The Fake (fast, for unit tests)
// app/Services/Payment/FakePaymentGateway.php
class FakePaymentGateway implements PaymentGateway
{
    public function charge(int $amount, string $token): array
    {
        return [
            'status' => 'succeeded',
            'id' => 'ch_' . uniqid(),
            'amount' => $amount,
        ];
    }
}
The Real Implementation (slow, hits API)
// app/Services/Payment/StripePaymentGateway.php
class StripePaymentGateway implements PaymentGateway
{
    public function charge(int $amount, string $token): array
    {
        $response = Http::withToken(config('services.stripe.secret'))
            ->post('https://api.stripe.com/v1/charges', [
                'amount' => $amount,
                'currency' => 'usd',
                'source' => $token,
            ]);

        return $response->json();
    }
}
The Contract Test — data provider approach
// tests/Contracts/PaymentGatewayContractTest.php
class PaymentGatewayContractTest extends TestCase
{
    #[Test]
    #[DataProvider('gatewaysProviders')]
    public function charges_with_a_valid_payment_token_are_successful($paymentGateway)
    {
        $charge = $paymentGateway->charge(2500, $paymentGateway->getValidToken());

        $this->assertSame(2500, $charge);
    }

    #[Test]
    #[DataProvider('gatewaysProviders')]
    public function charges_with_an_invalid_payment_token_fail($paymentGateway)
    {
        try {
            $paymentGateway->charge(2500, 'invalid-payment-token');
        } catch (Exception $e) {
            $this->assertTrue(true);
            return;
        }

        $this->fail('Charging with an invalid payment token did not throw a PaymentFailedException.');
    }

    public function gatewaysProviders()
    {
        return [
            'Fake payment gateway' => [new FakePaymentGateway()],
            'Stripe payment gateway' => [new StripePaymentGateway('secret-stripe-key')],
        ];
    }
}
Run only fast tests
# Run only the fake variant
php artisan test --filter='gatewaysProviders.*Fake'

# Run only the stripe variant
php artisan test --filter='gatewaysProviders.*Stripe'

Read more by @jcergolj: Contract Tests