Fragile Unit Tests in Clean Architecture
So tightly coupled to the domain that they break on every refactor - even when behavior never changes
📅 Join me: Clean Architecture for Backend Developers on Wed 26th Aug, 5:00 - 6:30 PM (CEST)
How Uncle Bob writes unit tests in Clean Architecture
Years ago, I wrote unit tests in Clean Architecture the way Uncle Bob does. Unit tests target use cases and verify the stateful outcomes on repositories and gateways through test doubles.
In the Clean Coders comparative design series, Uncle Bob shows that he writes unit tests targeting use cases, not targeting the domain. That’s why for each use case class he has a corresponding unit test class - a 1:1 mapping between use cases and unit tests.
You’ll notice that even though he targets use cases and not the domain, the tests are still coupled to the domain - they assert directly on domain entities. That turns out to be a challenge, as we’ll see later in this article.
E.g. for the use case PostDocument here’s the unit test PostDocumentTest:
@Test
public void canPostAnyDocument() throws Exception {
LocalDateTime now = LocalDateTime.now();
Document createdDocument = postDocument.post(“username”, “text”);
Document fetchedDocument = UseCaseContext.repository.getDocument(createdDocument.id);
assertThat(fetchedDocument.username).isEqualTo(“username”);
assertThat(fetchedDocument.text).isEqualTo(“text”);
assertThat(fetchedDocument.id).isEqualTo(createdDocument.id);
assertThat(fetchedDocument.dateTime).isEqualTo(createdDocument.dateTime);
}Real-life example: eShop - placing orders
Now let’s illustrate Uncle Bob’s approach to unit tests on a more realistic example - the eShop.
A customer places an order. The PlaceOrder use case takes a SKU and a quantity, looks up the product, checks stock, calculates the total order price, saves the order, notifies the customer, and returns the order number. It reaches the outside world through repository interfaces and gateway interfaces.
The domain holds two entities - Order and Product - plus value objects that own invariants a primitive can’t: Quantity (must be positive), Money (currency and arithmetic), OrderNumber (the format we generate). Those repository and gateway interfaces are part of the domain too - abstractions the use case owns and depends on, implemented out at the infrastructure edge.
Unit testing the PlaceOrder use case
So when we adopt Clean Architecture, we write unit tests against the use case. The test class comes out like this:
class PlaceOrderUnitTest {
...
@BeforeEach
void setUp() {
orderRepository = new FakeOrderRepository();
productGateway = new StubProductGateway();
orderNumberGateway = new StubOrderNumberGateway();
notificationGateway = new SpyNotificationGateway();
placeOrder = new PlaceOrder(
orderRepository, productGateway, orderNumberGateway, notificationGateway);
}
@Test
void calculatesTheTotal() {
productGateway.willReturn(new Product(“ABC”, Money.of(20), Quantity.of(10)));
orderNumberGateway.willReturn(OrderNumber.of(“ORD-1001”));
var request = new PlaceOrderRequest();
request.setSku(“ABC”);
request.setQuantity(4);
var result = placeOrder.execute(request);
assertThat(result.isSuccess()).isTrue();
var addedOrder = orderRepository.getOrder(OrderNumber.of(“ORD-1001”));
assertThat(addedOrder.getSku()).isEqualTo(“ABC”);
assertThat(addedOrder.getTotalPrice()).isEqualTo(Money.of(80));
}
@Test
void rejectsAnOrderThatExceedsStock() {
productGateway.willReturn(new Product(“ABC”, Money.of(20), Quantity.of(10)));
var request = new PlaceOrderRequest();
request.setSku(“ABC”);
request.setQuantity(15);
var result = placeOrder.execute(request);
assertThat(result.isSuccess()).isFalse();
assertThat(orderRepository.isEmpty()).isTrue();
}
...
}🚫Maintenance nightmare - refactoring the domain breaks unit tests
The tests are green. 100% code coverage, 100% mutation coverage.
But then, there comes a day when we need to refactor the domain. That’s when the nightmare starts...


