Skip to content

Instantly share code, notes, and snippets.

@ShawnMcCool
Last active October 7, 2022 07:37
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ShawnMcCool/f7b613d9f43c04063eef53c2717ac596 to your computer and use it in GitHub Desktop.
Save ShawnMcCool/f7b613d9f43c04063eef53c2717ac596 to your computer and use it in GitHub Desktop.
Testing at Boundaries with Test Doubles and Fixtures: A Vertical Slice

Overview

In this gist, I attempt to communicate techniques for defining explicit boundaries over naturally occuring 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 penalities.

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.

  • lylas, Shawn!

Section 1 - 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.

<?php
namespace DeliveryPlatform\ParcelTracking;
class DatabaseDeliveryRepository implements DeliveryRepository
{
public function withId(DeliveryId $deliveryId): Delivery
{
// database stuff
$result = // database query
return new Delivery(
DeliveryId::fromString($result->delivery_id),
// etc...
);
}
}
<?php
namespace DeliveryPlatform\ParcelTracking;
interface DeliveryRepository
{
function withId(DeliveryId $deliveryId): Delivery;
}
<?php
namespace DeliveryPlatform\ParcelTracking;
interface GenerateParcelTrackingQrCode
{
function forDelivery(DeliveryId $deliveryId): ParcelTrackingQrCode;
}
<?php
namespace DeliveryPlatform\ParcelTracking;
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)
)
);
}
}

Section 2 - 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.

<?php
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;
}
}
<?php
namespace Tests\DeliveryPlatform\ParcelTracking;
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()}'."
);
}
}
<?php
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
);
}
}

Section 3 - The Service Test

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.

<?php
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')
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment