Testing at the boundaries of your application

Antonello D'Ippolito
5 min readApr 7

--

From Unsplash

When writing automated unit tests at the boundaries of our application — that is, communicating with an external service — we often make use of test doubles: due to the external service’s possible unreliability, slow performance, or complicated setup, the idea is to replace it with a service that provides the same level of functionality within the test’s context.

Think for example to one of the most common external service an application makes use of, the database, and the repository pattern: you often have an interface that is implemented by a concrete class — that reaches out to the actual database and performs queries — and an in-memory implementation that is faster, more reliable, and perfectly suitable for a unit test:

final class MySQLUsersRepository implements UsersRepository
{
public function __construct(private \PDO $pdo) {}

public function store(User $user): void
{
$sth = $this->pdo->prepare('INSERT INTO users (id, name) VALUES (?,?)');
$sth->execute([$user->id(), $user->name()]);
}

public function findById(int $id): User
{
$sth = $this->pdo->prepare('SELECT id, name FROM users WHERE id = ?');
$sth->execute([$id]);
$result = $sth->fetch();

if ($sth->rowCount() === 0) {
throw new UserNotFound("The user with id {$id} was not found");
}

return new User($result['id'], $result['name']);
}

public function findAll(): array
{
$sth = $this->pdo->prepare('SELECT id, name FROM users');
$sth->execute();
$results = $sth->fetchAll();

$users = [];
foreach ($results as $result) {
$users[] = new User($result['id'], $result['name']);
}

return $users;
}
}
final class InMemoryUsersRepository implements UsersRepository
{
/** @var array<User> */
private array $users = [];

public function store(User $user): void
{
$this->users[$user->id()] = $user;
}

public function findById(int $id): User
{
if (! array_key_exists($id, $this->users)) {
throw new UserNotFound("The user with id {$id} was not found");
}

return $this->users[$id];
}

public function findAll(): array
{
return $this->users;
}
}

But then a question in the brain of every observant software developer should arise: how do I make sure that the test double is really reliable, in terms of behaviour, when running my tests against it? How can I have a mechanism that, when I evolve the behaviour of my concrete implementation, warns me that I need to change also the test double accordingly?

The answer is: a contract test. The basic idea is to use the same set of tests for both (or the many) the implementations you have, while having a different setup for each one of them. This test will be the first client for your implementations, and it will verify that all of them work at the same way, from its point of view. That enables you to replace one implementation with the other, and get the same result in your unit tests.

In an object oriented language like PHP (using PHPUnit), there are a few ways to do this, let’s go through them and their pros and cons.

You can see this (working) example in this repository:

Abstract class

A simple way to approach this is to create an abstract class that contains all the necessary test cases and then inherit from it for each implementation you wish to test. However, this approach requires you to maintain the same chain of inheritance for both implementations, potentially resulting in the execution of unnecessary and slow code during in-memory implementation tests (in this case, you alway inherit from DbTestCase, that prepares the databases):

abstract class UsersRepositoryTestCase extends DbTestCase
{
abstract protected function getUsersRepository(): UsersRepository;

public function testItRetrievesAUser(): void
{
// Arrange
$userRepository = $this->getUsersRepository();
$userRepository->store(new User(1, 'John'));

// Act
$retrievedUser = $userRepository->findById(1);

// Assert
self::assertEquals(new User(1, 'John'), $retrievedUser);
}

public function testItRetrievesAllUsers(): void
{
// Arrange
$userRepository = $this->getUsersRepository();
$userRepository->store(new User(1, 'John'));
$userRepository->store(new User(2, 'Paul'));

// Act
$retrievedUsers = $userRepository->findAll();

// Assert
self::assertCount(2, $retrievedUsers);
self::assertEquals(new User(2, 'Paul'), array_pop($retrievedUsers));
self::assertEquals(new User(1, 'John'), array_pop($retrievedUsers));
}

public function testItThrowsAnExceptionWhenUserIsNotFound(): void
{
// Arrange
$userRepository = $this->getUsersRepository();

// Assert
$this->expectException(UserNotFound::class);

// Act
$userRepository->findById(1);
}
}
final class MySQLUsersRepositoryTest extends UsersRepositoryTestCase
{
protected function getUsersRepository(): UsersRepository
{
return $this->services[MySQLUsersRepository::class];
}
}
final class InMemoryUsersRepositoryTest extends UsersRepositoryTestCase
{
protected function getUsersRepository(): UsersRepository
{
return new InMemoryUsersRepository();
}
}

Data providers

You can also have a single test class, and use data providers to inject the different implementations in each test case. Also here though, you have to inherit from the same class and execute some useless code in the in-memory test cases:

class UsersRepositoryTest extends DbTestCase
{
/** @dataProvider getUsersRepositoryImplementations */
public function testItRetrievesAUser(string $userRepositoryClassName): void
{
// Arrange
$userRepository = $this->services[$userRepositoryClassName];
$userRepository->store(new User(1, 'John'));

// Act
$retrievedUser = $userRepository->findById(1);

// Assert
self::assertEquals(new User(1, 'John'), $retrievedUser);
}

/** @dataProvider getUsersRepositoryImplementations */
public function testItRetrievesAllUsers(string $userRepositoryClassName): void
{
// Arrange
$userRepository = $this->services[$userRepositoryClassName];
$userRepository->store(new User(1, 'John'));
$userRepository->store(new User(2, 'Paul'));

// Act
$retrievedUsers = $userRepository->findAll();

// Assert
self::assertCount(2, $retrievedUsers);
self::assertEquals(new User(2, 'Paul'), array_pop($retrievedUsers));
self::assertEquals(new User(1, 'John'), array_pop($retrievedUsers));
}

/** @dataProvider getUsersRepositoryImplementations */
public function testItThrowsAnExceptionWhenUserIsNotFound(string $userRepositoryClassName): void
{
// Arrange
$userRepository = $this->services[$userRepositoryClassName];

// Assert
$this->expectException(UserNotFound::class);

// Act
$userRepository->findById(1);
}

/** @return array<array<UsersRepository>> */
public function getUsersRepositoryImplementations(): array
{
return[
InMemoryUsersRepository::class => [InMemoryUsersRepository::class],
MySQLUsersRepository::class => [MySQLUsersRepository::class],
];
}
}

Traits

With traits you can solve the problem of unnecessarily inheriting from the same class for the two implementations. In this case you have a trait with all the test cases, and two different classes that use that trait with different setups:

trait UsersRepositoryTestCase
{
private UsersRepository $usersRepository;

public function testItRetrievesAUser(): void
{
// Arrange
$this->usersRepository->store(new User(1, 'John'));

// Act
$retrievedUser = $this->usersRepository->findById(1);

// Assert
self::assertEquals(new User(1, 'John'), $retrievedUser);
}

public function testItRetrievesAllUsers(): void
{
// Arrange
$this->usersRepository->store(new User(1, 'John'));
$this->usersRepository->store(new User(2, 'Paul'));

// Act
$retrievedUsers = $this->usersRepository->findAll();

// Assert
self::assertCount(2, $retrievedUsers);
self::assertEquals(new User(2, 'Paul'), array_pop($retrievedUsers));
self::assertEquals(new User(1, 'John'), array_pop($retrievedUsers));
}

public function testItThrowsAnExceptionWhenUserIsNotFound(): void
{
// Assert
$this->expectException(UserNotFound::class);

// Act
$this->usersRepository->findById(1);
}
}
class MySQLUsersRepositoryTest extends DbTestCase
{
use UsersRepositoryTestCase;

protected function setUp(): void
{
parent::setUp();
$this->usersRepository = $this->services[MySQLUsersRepository::class];
}
}
class InMemoryUsersRepositoryTest extends TestCase
{
use UsersRepositoryTestCase;

protected function setUp(): void
{
$this->usersRepository = new InMemoryUsersRepository();
}
}

The code examples and their features are related to PHP, but the principles apply to every other programming language. The technique used to implement the contract tests is up to you and depends on the context of your application, and how the automated tests are set up.

This kind of contract tests give you the ability of using your test doubles in unit (however big your unit is) tests, while maintaining the confidence that they will behave the same way as the original production implementation from the point of view of your test.

--

--