Testing Smarter: 3 – Unit Scope too large
November 24, 2023 — Zinderlabs
We continue our series on Testing Smarter by looking at common pitfalls around unit scope being either too small or too large. A unit that is too small in size can result in brittle tests, as we saw in the previous example. But the opposite can also be true!
For our following example, we have an application that provides administrative features for a book catalog. We need to build an endpoint that creates a book in the database when called, and sends some info to other integrated systems. We’ll put most of our business logic in a command handler.
Example
public class CreateBookHandler : IRequestHandler<CreateBookCommand, BookId>
{
<...>
public async Task<BookId> Handle(CreateBookCommand command)
{
var book = new Book(command.Name, command.DateOfPublication, command.Genre);
Accolades accolades = IsBelgianAuthor(command.Author.Origin)
? PrioritizeBelgianAccolades(command.Accolades)
: command.Accolades;
if (IsSoldByAmazon(request.Sellers) && request.Audiobook.AudioBookType == AudioBookType.Audible)
{
book.HasAudibleAudioBook(new AudioBookBuilder(request.Sellers.First(s => s.Id == SellerConstansts.AMAZON_ID)).Build())
}
<...other business requirements and checks>
var bookId = await _bookRepository.Save(book);
IRequest eventToSend = book.IsSoldByAmazonAndHasBelgianAuthor()
? new AmazonBookWithBelgianAuthorCreatedEvent() { ... }
: new BookCreatedEvent() { ... };
await _mediator.Publish(eventToSend);
return bookId;
}
}
Our test file would look something like this…
public class CreateBookHandlerTests
{
[Fact]
public async Task Book_should_be_created_with_minimal_functional_properties() { ... }
[Fact]
public async Task Book_with_belgian_author_should_be_created_with_prioritized_belgian_accolades() { ... }
[Fact]
public async Task Book_with_multiple_authors_one_of_which_is_belgian_should_be_created_with_prioritized_belgian_accolades() { ... }
[Fact]
public async Task Book_with_foreign_author_should_be_created_with_normal_accolades() { ... }
[Fact]
public async Task Book_sold_by_amazon_and_with_audible_adio_book_type_should_be_created_with_audible_audiobook_template() { ... }
[Fact]
public async Task Book_sold_by_amazon_but_without_belgian_author_should_not_get_specific_event() { ... }
[Fact]
public async Task Book_with_belgian_authors_but_not_sold_by_amazon_should_not_get_specific_event() { ... }
[Fact]
public async Task Book_with_belgian_authors_and_sold_by_amazon_should_get_specific_event() { ... }
}
Issues
I omitted the actual test code for brevity, but one can observe from the test names alone that we have some very specific test cases for many different scenarios, all in the same test file and all for the same system under test. Due to the complex flows, we will also need very specific setups for our test. The input will have to be tweaked just right, as will our mocks. Thus, as with tests that are too small a scope, we have created some very brittle tests in this example. The moment the input changes or an extra condition is added to the handler, many of these tests will start to fail and will have to change along with that handler, even though the real-world scenarios they test might not have changed.
Solution

There are multiple paths we could take here. We could split the main Handle method into multiple methods and write tests for them separately. We could also move some of the logic to the Book domain object to let it manage its own internal state and unit test it there (although this might just move the problem to another location). In this situation, it is best to reduce our logical unit in size to have tests with less specific setups. To reiterate, more setup in tests equals to more factors that force change.
Additional Pitfall: Canary in the coal mine
The tests for the CreateBookHandler we created in the example are not actually at fault for the brittleness. It is the production code that needs changing. Usually, when tests are hard to write it is a sign that the code to test does too much or is too complex. We should have split the CreateBookCommand flow into more specific commands like CreateAmazonBook, CreateBelgianBook, and others. As mentioned before, we could also have off-loaded some of the logic to the Book aggregate if we were in a domain-driven context. Another solution would be to write some helper(s) with a pure input/output signature and test those separately.
Other posts in this series: