Unit Tests That Are Hard to Write Are Telling You Something About Your Code

by Arif Ikhsanudin, Backend Developer

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

How to End a Contract Professionally So They Come Back Next Time

The ending of a contract is as important as the beginning. How you close an engagement determines whether you are remembered as someone worth working with again.

Read more

The Danger of Sending Code Straight to Production Without Oversight

Pushing code directly to production might seem fast, but it’s a ticking time bomb. Without proper oversight, even small changes can cause massive headaches.

Read more

Setting Boundaries as a Remote Contractor Is Not Unprofessional. It Is Required.

Without boundaries, remote contracting does not become flexible — it becomes borderless. And borderless work has a well-known consequence: burnout.

Read more

How to Price Your Contract Work Without Underselling Yourself

Pricing is not just math. It is a statement about how you see your own value — and clients read it that way too.

Read more