When creating an application that follows The Clean Architecture you end up with a number of UseCases that hold your application logic. In this blog post I outline a testing pattern for effectively testing these UseCases and avoiding common pitfalls.
Testing UseCases
A UseCase contains the application logic for a single “action” that your system supports. For instance “cancel a membership”. This application logic interacts with the domain and various services. These services and the domain should have their own unit and integration tests. Each UseCase gets used in one or more applications, where it gets invoked from inside the presentation layer. Typically you want to have a few integration or edge-to-edge tests that cover this invocation. In this post I look at how to test the application logic of the UseCase itself.
UseCases tend to have “many” collaborators. I can’t recall any that had less than 3. For the typical UseCase the number is likely closer to 6 or 7, with more collaborators being possible even when the design is good. That means constructing a UseCase takes some work: you need to provide working instances of all the collaborators.
Integration Testing
One way to deal with this is to write integration tests for your UseCases. Simply get an instance of the UseCase from your Top Level Factory or Dependency Injection Container.
This approach often requires you to mutate the factory or DIC. Want to test that an exception from the persistence service gets handled properly? You’ll need to use some test double instead of the real service, or perhaps mutate the real service in some way. Want to verify a mail got send? Definitely want to use a Spy here instead of the real service. Mutability comes with a cost so is better avoided.
A second issue with using real collaborators is that your tests get slow due to real persistence usage. Even using an in-memory SQLite database (that needs initialization) instead of a simple in-memory fake repository makes for a speed difference of easily two orders of magnitude.
Unit Testing
While there might be some cases where integration tests make sense, normally it is better to write unit tests for UseCases. This means having test doubles for all collaborators. Which leads us to the question of how to best inject these test doubles into our UseCases.
As example I will use the CancelMembershipApplicationUseCase of the Wikimedia Deutschland fundrasing application.
1 2 3 4 5 |
function __construct(ApplicationAuthorizer $authorizer, ApplicationRepository $repository, TemplateMailerInterface $mailer) { $this->authorizer = $authorizer; $this->repository = $repository; $this->mailer = $mailer; } |
This UseCase uses 3 collaborators. An authorization service, a repository (persistence service) and a mailing service. First it checks if the operation is allowed with the authorizer, then it interacts with the persistence and finally, if all went well, it uses the mailing service to send a confirmation email. Our unit test should test all this behavior and needs to inject test doubles for the 3 collaborators.
The most obvious approach is to construct the UseCase in each test method.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
public function testGivenIdOfUnknownDonation_cancellationIsNotSuccessful(): void { $useCase = new CancelMembershipApplicationUseCase( new SucceedingAuthorizer(), $this->newRepositoryWithCancellableDonation(), new MailerSpy() ); $response = $useCase->cancelApplication( new CancellationRequest( self::ID_OF_NON_EXISTING_APPLICATION ) ); $this->assertFalse( $response->cancellationWasSuccessful() ); } public function testGivenIdOfCancellableApplication_cancellationIsSuccessful(): void { $useCase = new CancelMembershipApplicationUseCase( new SucceedingAuthorizer(), $this->newRepositoryWithCancellableDonation(), new MailerSpy() ); $response = $useCase->cancelApplication( new CancellationRequest( $this->cancelableApplication->getId() ) ); $this->assertTrue( $response->cancellationWasSuccessful() ); } |
Note how both these test methods use the same test doubles. This is not always the case, for instance when testing authorization failure, the test double for the authorizer service will differ, and when testing persistence failure, the test double for the persistence service will differ.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public function testWhenAuthorizationFails_cancellationFails(): void { $useCase = new CancelMembershipApplicationUseCase( new FailingAuthorizer(), $this->newRepositoryWithCancellableDonation(), new MailerSpy() ); $response = $useCase->cancelApplication( new CancellationRequest( $this->cancelableApplication->getId() ) ); $this->assertFalse( $response->cancellationWasSuccessful() ); } |
Normally a test function will only change a single test double.
UseCases tend to have, on average, two or more behaviors (and thus tests) per collaborator. That means for most UseCases you will be repeating the construction of the UseCase in a dozen or more test functions. That is a problem. Ask yourself why.
If the answer you came up with was DRY then think again and read my blog post on DRY 😉 The primary issue is that you couple each of those test methods to the list of collaborators. So when the constructor signature of your UseCase changes, you will need to do Shotgun Surgery and update all test functions. Even if those tests have nothing to do with the changed collaborator. A second issue is that you pollute the test methods with irrelevant details, making them harder to read.
Default Test Doubles Pattern
The pattern is demonstrated using PHP + PHPUnit and will need some adaptation when using a testing framework that does not work with a class based model like that of PHPUnit.
The coupling to the constructor signature and resulting Shotgun Surgery can be avoided by having a default instance of the UseCase filled with the right test doubles. This can be done by having a newUseCase method that constructs the UseCase and returns it. A way to change specific collaborators is needed (ie a FailingAuthorizer
to test handling of failing authorization).
1 2 3 4 5 6 7 |
private function newUseCase() { return new CancelMembershipApplicationUseCase( new SucceedingAuthorizer(), new InMemoryApplicationRepository(), new MailerSpy() ); } |
Making the UseCase itself mutable is a big no-no. Adding optional parameters to the newUseCase
method works in languages that have named parameters. Since PHP does not have named parameters, another solution is needed.
An alternative approach to getting modified collaborators into the newUseCase
method is using fields. This is less nice than named parameters, as it introduces mutable state on the level of the test class. Since in PHP this approach gives us named fields and is understandable by tools, it is better than either using a positional list of optional arguments or emulating named arguments with an associative array (key-value map).
The fields can be set in the setUp
method, which gets called by PHPUnit before the test methods. For each test method PHPUnit instantiates the test class, then calls setUp
, and then calls the test method.
1 2 3 4 5 6 7 8 |
public function setUp() { $this->authorizer = new SucceedingAuthorizer(); $this->repository = new InMemoryApplicationRepository(); $this->mailer = new MailerSpy(); $this->cancelableApplication = ValidMembershipApplication::newDomainEntity(); $this->repository->storeApplication( $this->cancelableApplication ); } |
1 2 3 4 5 6 7 |
private function newUseCase(): CancelMembershipApplicationUseCase { return new CancelMembershipApplicationUseCase( $this->authorizer, $this->repository, $this->mailer ); } |
With this field-based approach individual test methods can modify a specific collaborator by writing to the field before calling newUseCase
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public function testWhenAuthorizationFails_cancellationFails(): void { $this->authorizer = new FailingAuthorizer(); $response = $this->newUseCase()->cancelApplication( new CancellationRequest( $this->cancelableApplication->getId() ) ); $this->assertFalse( $response->cancellationWasSuccessful() ); } public function testWhenSaveFails_cancellationFails() { $this->repository->throwOnWrite(); $response = $this->newUseCase()->cancelApplication( new CancellationRequest( $this->cancelableApplication->getId() ) ); $this->assertFalse( $response->cancellationWasSuccessful() ); } |
The choice of default collaborators is important. To minimize binding in the test functions, the default collaborators should not cause any failures. This is the case both when using the field-based approach and when using optional named parameters.
If the authorization service failed by default, most test methods would need to modify it, even if they have nothing to do with authorization. And it is not always self-evident they need to modify the unrelated collaborator. Imagine the default authorization service indeed fails and that the testWhenSaveFails_cancellationFails
test method forgets to modify it. This test method would end up passing even if the behavior it tests is broken, since the UseCase will return the expected failure result even before getting to the point where it saves something.
This is why inside of the setUp
function the example creates a “cancellable application” and puts it inside an in-memory test double of the repository.
I chose the CancelMembershipApplication UseCase as an example because it is short and easy to understand. For most UseCases it is even more important to avoid the constructor signature coupling as this issue becomes more severe with size. And no matter how big or small the UseCase is, you benefit from not polluting your tests with unrelated setup details.
You can view the whole CancelMembershipApplicationUseCase and CancelMembershipApplicationUseCaseTest.
1 thought on “Clean Architecture: UseCase tests”