Testing at Boundaries with Test Doubles and Fixtures
originally published May 17th, 2022This article was originally shared in the form of a GitHub Gist and has been translated directly to this blog. In this gist, I attempt to communicate techniques for defining explicit boundaries over naturally occurring boundaries (in this case, the boundary between domain and service/data layers) to empower both testing and to improve comprehensibility of the system.
In my view, testing and application code are one-and-the-same and should evolve together. Testing code should not be an after-thought. I believe that haphazardly designing application code and test code leads to lead-time penalties.
If you’d like to reply, consider replying to this twitter thread. Additionally, I am available on Twitter @ShawnMcCool.
Note: I acknowledge that no techniques are right for everyone. But I have a long history with these techniques, and I found them quite adequate for large and small-scale systems.
The Application
This section contains two repository interfaces, each with a database concretion. The abstraction exists because the repository’s interface is a domain concern and the repository’s implementation is a service-layer concern. There is a natural boundary where the repository access the database, so we acknowledge that indeed the boundary does exist, and we make it explicit. This decision will inform the rest of the implementation.
This layer also contains a service class that generates QR codes for deliveries. I just made the example up out of my head, and I entered the code directly into the gist website, so it’s probably broken as all get-out.
namespace DeliveryPlatform\ParcelTracking;
interface DeliveryRepository
{
function withId(DeliveryId $deliveryId): Delivery;
}
class DatabaseDeliveryRepository implements DeliveryRepository
{
public function withId(DeliveryId $deliveryId): Delivery
{
// database stuff
$result = // database query
return new Delivery(
DeliveryId::fromString($result->delivery_id),
// etc...
);
}
}
namespace DeliveryPlatform\ParcelTracking;
interface GenerateParcelTrackingQrCode
{
function forDelivery(DeliveryId $deliveryId): ParcelTrackingQrCode;
}
class GenerateParcelTrackingQrCodeFromDatabase implements GenerateParcelTrackingQrCode
{
public function __construct(
private DeliveryRepository $deliveries,
private ParcelTrackingQrCodeRepository $qrCodes
) {}
public function forDelivery(DeliveryId $deliveryId): ParcelTrackingQrCode
{
// maybe do stuff to create a qr code, who knows
$this->qrCodes->store(
ParcelTrackingQrCode::generateForDelivery(
$this->deliveries->withId($deliveryId)
)
);
}
}
Test Doubles
Because testing is an important aspect of software design. We expect to design our tests and our application code in harmony and to give both the design attention that they deserve. This does not mean that we lose a lot of time imagining abstractions that are unnecessary. To the contrary, we save time because our test tooling enables simple / comprehensive testing patterns. It also runs quickly because it doesn’t test database access for every service that interacts with an object-graph connected to a repository.
The repositories will be tested fully with integration tests that have their own fixture class (will discuss later) and will directly interact with the database.
Our fixtures and our test doubles exist for a bounded context. They are not used for an entire application. Therefore they’re ‘tight’ and to-the-point.
The test doubles use easy-to-comprehend language that help to turn the tests from complex setup and implementation into descriptions of how the system under test works.
You can build the fixtures and test doubles to use patterns that you find useful. I enjoy the pattern of being able to override values for text fixtures. Notice that in the test I explicitly type out the DeliveryId::fromString('delivery id')
bit repeatedly. I like this because I feel like it makes the test more comprehensible. You may prefer to bind it to a variable, or because a default value is available within the fixture factory, you can just have your tests directly use ‘some default delivery id’ instead. Your call.
The test doubles can be much more than just in-memory versions of repositories. They can be a nice place to put convenient assertions and other behavior that assists in making tests easier to develop, run faster, and easier to read.
namespace Tests\DeliveryPlatform\ParcelTracking;
class DeliveryRepositoryForTesting implements DeliveryRepository
{
private function __construct(
private Collection $deliveries
) {}
public static function empty(): self
{
return new self(
Collection::empty()
);
}
public static function withDelivery(Delivery $delivery): self
{
return new self(
Collection::list(
$delivery
)
);
}
public function withId(DeliveryId $deliveryId): Delivery
{
$delivery = $this->deliveries->first(
fn(Delivery $delivery) => $delivery->id->equals($deliveryId)
);
if ( ! $delivery)
throw CanNotRetrieveDelivery::deliveryIdNotFound($deliveryId);
}
return $delivery;
}
}
class ParcelTrackingQrCodeRepositoryForTesting implements ParcelTrackingQrCodeRepository
{
private function __construct(
private Collection $qrCodes
) {}
public static function empty(): self
{
return new self(
Collection::empty()
);
}
public function store(ParcelTrackingQrCode $qrCode): void
{
$this->qrCodes = $this->qrCodes->add($qrCode);
}
public function assertQrCodeWasGeneratedForDelivery(DeliveryId $deliveryId): void
{
\PhpUnit\Assert::assertNotNull(
$this->qrCodes->first(
fn(PostalTrackingQrCode $qrCode) => $qrCode->deliveryId->equals($deliveryId)
),
"The expected QR Code was not stored for delivery '{$deliveryId->toString()}'."
);
}
}
namespace Tests\DeliveryPlatform\ParcelTracking;
class ParcelTrackingUnitTestFixtures
{
public static function make(): self
{
return new self();
}
public function shippedDelivery(
?DeliveryId $deliveryId = null
): Delivery {
return new Delivery(
$deliveryId ?? DeliveryId::fromString('some default id value'),
// full of nice,
// test values,
// representing,
// an interesting state
);
}
}
Unit Test The Service
This is where the rubber hits the road.
We define the context for our test, construct the dependencies, execute the behavior, and assert an outcome.
We want the test to be as readable as possible. Everything about the test should tell us everything about how it works. The setup should be minimal (no page-long setup() of framework mocks please!)
Note: I explicitly define
DeliveryId
because I like to see it. The test shouts to me how the service functions.
I use natural language to express ideas because it’s easier to read and understand.
class GenerateParcelTrackingCodeTest extends \PhpUnit\TestCase
{
function test_can_create_parcel_tracking_qr_code_for_delivery()
{
/*
* set up
*/
$qrCodes = ParcelTrackingQrCodeRepositoryForTesting::empty();
$generate = new GenerateParcelTrackingQrCode(
DeliveryRepositoryForTesting::withDelivery(
ParcelTrackingUnitTestFixtures::make()->shippedDelivery(
DeliveryId::fromString('delivery id')
)
),
$qrCodes
);
/*
* do it
*/
$generate->forDelivery(
DeliveryId::fromString('delivery id')
);
/*
* prove it
*/
$qrCodes->assertQrCodeWasGeneratedForDelivery(
DeliveryId::fromString('delivery id')
);
}
}