tech, developers, and the code underneath

issue 036· essay·

The test suite is a design document

If your tests are hard to write, your design is wrong. That's the signal, and most teams ignore it.

The most useful thing tests do is not catch regressions. It is tell you, before you have committed to anything, that your design is bad.

That signal is available for free, it is extremely reliable, and most teams respond to it by making the tests worse instead of the design better.

the tells#

You need six mocks to test one function. The function has six dependencies. That is the actual problem. The mocks are the messenger.

You are testing private methods. The class is doing more than one thing and you know it, which is why the interesting behavior is not reachable from the public interface. Extract the thing you want to test into its own unit with its own public interface.

Setup is longer than the assertion. The unit under test requires a large amount of world to exist before it does anything. That is coupling, expressed in lines of setup code.

You cannot test it without a database. Sometimes true and unavoidable. Usually it means business logic and persistence are interleaved, and separating them would make both testable and both clearer.

The test breaks whenever you refactor. You are asserting on implementation rather than behavior. Either the test is bad, or the unit has no meaningful behavioral contract, which is worse.

You cannot describe what the test verifies in one sentence. Then neither can the code.

the mechanism#

Tests are the first consumer of your interface. They are the only place, before production, where you have to actually use the thing you built rather than look at it.

Everything that is awkward about consuming your interface shows up in the test as friction: too many parameters, unclear ordering, hidden global state, temporal coupling between calls, error conditions that cannot be triggered deliberately.

This is why "test-first" works for people it works for, and it is not because of any ritual. It is because writing the usage before the implementation forces you to design the interface from the caller's perspective, which is the only perspective that matters.

You do not have to write tests first to get this. You have to notice when the test is hard and treat that as data.

the common wrong response#

Team hits testing friction. Team adds a mocking framework with more power. Now you can mock static methods, patch constructors, intercept module loading, and rewrite the world.

The friction goes away. The design problem does not. It is now permanently invisible, encased in tooling, and the test suite has become a second implementation of the system that must be maintained in parallel with the first.

Powerful mocking frameworks are a tool for testing legacy code you cannot change. Reaching for one in new code is choosing not to hear the signal.

what good looks like#

python
def test_order_total_applies_bulk_discount():
    order = Order(items=[Item(price=10_00, qty=100)])
    assert order.total() == 900_00

No mocks. No setup. No database. One sentence describes it. It breaks if and only if the discount rule changes.

That test is easy to write because Order.total is a pure function of its data. Making it a pure function of its data was the design decision. The test just confirmed it was a good one.

the one exception worth stating#

Sometimes the system is genuinely complex and the tests are genuinely complicated, because the domain is. Distributed consensus, a compiler backend, a scheduler. Difficulty is not always a design smell.

The distinguishing question: is the test complicated because the problem is hard, or because getting to the problem is hard? Complexity in the assertions is fine. Complexity in the setup is the smell.

get README in your inbox

One dispatch, no noise. Tech and developer news, plus the occasional long piece on the craft.

subscribe →