Contract Test

Janez Cergolj • February 16, 2021

Prologue

When testing 3rd party API call (dealing with external resources), one of the problems is that those tests are slow. One of the solutions is to use fake classes. On the outside, the fake class behaves the same as real implementation. However, it is much simpler and faster under the hood, and it doesn't request a call to a 3rd party API. Let's take a look at a simple example.

// interface or contract
interface PaymentGateway
{
    public function charge();
}

// fake class
class FakePaymentGateway implements PaymentGateway
{
    public function charge()
    {
        return [
            'charge_id' => 'ch_123',
            'buyer_email' => '[email protected]'
        ]
    }
}

// real implementation class
class RealPaymentGateway implements PaymentGateway
{
    public function charge()
    {
        // 3rd party API response with an array including charge_id and buyer_email
        return \Illuminate\Support\Facades\Http::post('http://example.com/charge');
    }
}

One of the risks of using fake classes in your tests is that it is hard to guarantee that a fake implementation and a real implementation of your class behaves the same. Granted, both of those classes implement the same interface/contract, but this doesn't mean that charge method in both classes will behave the same in the future. For example, a 3rd party API could change the response array, and we might not notice it. Now we know why fake classes are useful. However, we need to be sure that both implementations are the same.

What are the contract test?

One solution is to run the same test twice each time with a different implementation, e.g. the first time with fake class the second time with the real one. Because both of those classes implement the same interface, such tests are called contract tests. We can achieve this in two ways. One is Adam Wathan's approach using a trait. Mine is using data providers. Let's compare them.

Use of trait

// PaymentGatewayContractTrait.php
<?php
namespace Tests\WithTrait;

use Exception;

trait PaymentGatewayContractTrait
{
    abstract protected function getPaymentGateway();

    /** @test */
    function charges_with_a_valid_payment_token_are_successful()
    {
        $paymentGateway = $this->getPaymentGateway();
        $this->assertEquals(2500, $paymentGateway->charge(2500, $paymentGateway->getValidTestToken()));
    }

    /** @test */
    function charges_with_an_invalid_payment_token_fail()
    {
        $paymentGateway = $this->getPaymentGateway();

        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 an Exception.");
    }
}
// FakePaymentGatewayTest.php
<?php
namespace Tests\WithTrait;

use App\FakePaymentGateway;
use PHPUnit\Framework\TestCase;
use Tests\WithTrait\PaymentGatewayContractTrait;

class FakePaymentGatewayTest extends TestCase
{
    use PaymentGatewayContractTrait;

    protected function getPaymentGateway()
    {
        return new FakePaymentGateway;
    }
}
// StripePaymentGatewayTest.php
<?php
namespace Tests\WithTrait;

use App\StripePaymentGateway;
use PHPUnit\Framework\TestCase;
use Tests\WithTrait\PaymentGatewayContractTrait;

/** @group integration */
class StripePaymentGatewayTest extends TestCase
{
    use PaymentGatewayContractTrait;

    protected function getPaymentGateway()
    {
        return new StripePaymentGateway('secret-stripe-key');
    }
}

As you can see, we have two test classes, one for fake and one for real implementation, but actual tests are stored in a trait. With @group integration it is straightforward to exclude slow tests for real implementation when running the whole test suit. What I love about this implementation also is one to one match between test and class files FakePaymentGatewayTest.php => FakePaymentGateway.php.

Use of data providers

// PaymentGatewayContractTest.php
<?php
namespace Tests\WithDataProviders;

use App\FakePaymentGateway;
use App\StripePaymentGateway;
use Exception;
use PHPUnit\Framework\TestCase;

class PaymentGatewayContractTest extends TestCase
{
    /**
     * @test
     * @dataProvider gatewaysProviders
     */
    function charges_with_a_valid_payment_token_are_successful($paymentGateway)
    {
        $charge = $paymentGateway->charge(2500, $paymentGateway->getValidTestToken());

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

    /**
     * @test
     * @dataProvider gatewaysProviders
     */
    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')]
        ];
    }
}

I love this solution that everything is neatly packed in a single class, compared to trait example where bits of code are scattered in 3 different files. The downside is that you can't exclude slow tests.

Summary

Rewriting tests from trait to data providers approach was a fantastic learning experience. I haven't used data providers approach in a production environment just yet, so I can't really evaluate a data provider's approach. You might give it a try and let me know about your findings. If you are in doubt, go with Adam Wathan's approach. He is a TDD guru, and everything I've learned about testing, I learned from him.

More about contract tests

If you are eager to know more about the contract test, here are some additional resources:
1. My contract test github repo - you can fork it and tinker with it
2. Test Driven Laravel by Adam Wathan
3. Contract Tests by Kai Sassnowski
4. Contract Tests by Martin Fowler y