Unit Tests That Are Hard to Write Are Telling You Something About Your Code
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Test That Takes Two Hours to Write
You sit down to write a unit test for a function that calculates shipping costs. Twenty minutes later, you have the test mostly working but it requires mocking four collaborators, constructing a database entity with 23 fields, spinning up an in-memory context container, and suppressing three log warnings that fire during test initialization.
The function itself is about 30 lines. It should take ten minutes to test.
The gap between how hard this should be and how hard it actually is is the signal. The code is telling you something. Most developers ignore the signal and push through the test, adding to the fixture library and moving on. The design problem compounds quietly until the codebase is so tangled that every new test is an excavation project.
What the Pain Is Actually Indicating
Different kinds of testability friction map to different design problems.
Too many dependencies. If you need to mock five objects to test one function, that function is depending on too many things. The Single Responsibility Principle is likely being violated. The function is probably doing more than one thing. A useful heuristic: if a unit test requires more than two or three mocks, the function needs to be decomposed.
Hidden dependencies. When a function constructs its own collaborators — new DatabaseConnection(), System.getenv("API_KEY"), DateTime.now() — those dependencies cannot be controlled in tests. The result is either tests that hit real infrastructure or tests that require elaborate patching. The fix is dependency injection: pass collaborators in, do not let the function fetch them.
# Hidden dependency on current time — hard to test
def is_subscription_expired(user_id: int) -> bool:
db = Database.get_connection() # Hidden, hard to mock
user = db.find_user(user_id)
return user.expires_at < datetime.now() # datetime.now() is non-deterministic
# Injected dependencies — trivially testable
def is_subscription_expired(user: User, now: datetime) -> bool:
return user.expires_at < now
# Test is now trivial
def test_subscription_is_expired_when_expiry_date_is_in_past():
user = User(expires_at=datetime(2023, 1, 1))
assert is_subscription_expired(user, now=datetime(2024, 1, 1)) is True
def test_subscription_is_not_expired_when_expiry_date_is_in_future():
user = User(expires_at=datetime(2025, 1, 1))
assert is_subscription_expired(user, now=datetime(2024, 1, 1)) is False
Testing private behavior. When you find yourself wanting to test a private method directly — using reflection, making it package-private, or re-architecting visibility just for test access — the private method is doing something important enough to test, which means it is probably doing too much. Extract it to a separate class with a public interface.
Coupled concerns. When a "business logic" function also handles logging, metrics, error formatting, and HTTP response construction, you cannot test the logic without dealing with all the surrounding concerns. Each concern should be separable.
The Refactoring Signal
Testability friction gives you a prioritized refactoring queue. The hardest-to-test code is almost always the code with the most design problems — high coupling, low cohesion, too many responsibilities, too many assumptions about the environment.
Before fighting your way through a painful test, pause and ask what the test is trying to tell you. Consider these patterns as responses:
- If you need more than two mocks: split the function
- If you need to patch a constructor or
datetime.now(): inject the dependency - If you need to test a private method: extract a class
- If you need 30 lines of setup: introduce a factory or builder that captures the essential structure, but do not just hide the complexity — simplify it
// Instead of this setup in every test:
Order order = new Order();
order.setId(UUID.randomUUID());
order.setCustomer(new Customer());
order.getCustomer().setId(1L);
order.getCustomer().setEmail("test@example.com");
order.getCustomer().setTier(CustomerTier.GOLD);
order.setItems(new ArrayList<>());
order.addItem(new OrderItem(productA, 2, 49.99));
order.setStatus(OrderStatus.PENDING);
order.setCreatedAt(LocalDateTime.now());
// Consider whether the code itself needs simplification first.
// If a test object requires 10+ fields to construct, your domain model
// may be carrying too much incidental state.
When the Signal Is Ignored
Teams that treat test pain as just "the cost of testing" accumulate a codebase where the feedback loop between design quality and daily experience is severed. The pain of writing tests should translate directly into design improvements — that is one of its primary values.
If tests are consistently hard to write and the response is always to add more test infrastructure rather than to improve the design, the test infrastructure grows without bound while the design problems compound. The feedback mechanism is broken.
Use the pain. When a test is harder to write than the code it covers, that is a design review waiting to happen.