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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Spring Boot API Rate Limiting — rack-attack Equivalent in Java

Rate limiting protects APIs from abuse, enforces fair usage, and prevents accidental runaway clients from taking down infrastructure. Here is how to implement per-user, per-IP, and per-endpoint rate limiting in Spring Boot with Bucket4j and Redis.

Read more

Why Your API Feels Inconsistent and How to Fix It

Inconsistent APIs aren’t just annoying—they slow teams down and introduce subtle bugs. Most inconsistency comes from a lack of enforced patterns, not lack of skill, and fixing it requires deliberate constraints.

Read more

Amsterdam Backend Salaries Hit €100K. Here Is How Startups Avoid That Overhead

Your next backend hire in Amsterdam will probably cost you six figures before you even factor in the 30% ruling changes and mandatory benefits. That number used to be reserved for staff engineers. Now it's table stakes for anyone decent.

Read more

Specifications Too Low for Developers: The Typewriter Mentality

“Why does a developer need a $5000 laptop?” Because writing code isn’t typing—it’s running a small universe on your machine.

Read more