Skip to content
End To End Tester

Writing Testable Code

The properties that make code easy to test — pure cores, injected edges, no hidden state — and the specific smells that make it hard.

3 min read · updated 19 September 2026

If a test is hard to write, the test is rarely the problem. Four properties account for most of it.

#1. Dependencies arrive from outside

Code that constructs its own collaborators cannot be isolated. This is dependency injection and it is the single highest-return change most codebases can make.

The tell: new SomethingWithIO() inside a method, a static service locator, or a module-level singleton.

#2. Decisions are separated from effects

This is the highest-leverage idea on this page.

typescript
// Hard: the decision and the effect are welded together.
async function expireStaleCarts() {
  const carts = await db.query('SELECT * FROM carts');        // effect
  for (const cart of carts) {
    if (Date.now() - cart.updatedAt > 30 * 86_400_000) {      // decision
      await db.delete('carts', cart.id);                      // effect
      await mailer.send(cart.email, 'Your cart expired');     // effect
    }
  }
}

To test the thirty-day rule you need a database and a mail server, and you will end up asserting on mock calls.

typescript
// Easy: the decision is a pure function of its inputs.
export interface Expiry { cartId: string; email: string }

export function cartsToExpire(carts: Cart[], now: number, ttlMs: number): Expiry[] {
  return carts
    .filter((cart) => now - cart.updatedAt > ttlMs)
    .map((cart) => ({ cartId: cart.id, email: cart.email }));
}

// The shell is so thin it needs no unit test at all.
async function expireStaleCarts() {
  const carts = await db.loadCarts();
  for (const { cartId, email } of cartsToExpire(carts, Date.now(), THIRTY_DAYS)) {
    await db.deleteCart(cartId);
    await mailer.send(email, 'Your cart expired');
  }
}

cartsToExpire needs no doubles, no clock injection, no database. Boundary cases — exactly thirty days, an empty list, a cart updated in the future — are one line each.

This is functional core, imperative shell, and the related humble object pattern: make the untestable part so thin it is obviously correct, and put everything interesting where it can be tested cheaply.

csharp
// The same move in a UI controller. The presenter is pure; the view
// is humble and needs no test.
public static class OrderSummaryPresenter
{
    public static OrderSummaryViewModel Present(Order order, TimeZoneInfo tz) => new(
        Title: $"Order {order.Reference}",
        PlacedAt: TimeZoneInfo.ConvertTimeFromUtc(order.PlacedAtUtc, tz).ToString("d MMM yyyy"),
        Total: order.TotalCents.ToMoney("GBP"),
        CanCancel: order.Status is OrderStatus.Reserved or OrderStatus.Paid);
}

#3. No hidden inputs

A function whose result depends on something not in its arguments is a function you cannot test deterministically.

  • DateTime.Now, new Date() — inject a clock
  • Random, uuid() — inject the source
  • process.env, ConfigurationManager — read at construction, pass in
  • static mutable state, caches, singletons — the most dangerous, because they also couple tests to each other and break parallel execution

#4. One reason to change

A class that parses, validates, persists and notifies needs four kinds of setup to test any one of them. The test length is a direct measurement of how many responsibilities the unit has, which is why "the test needs eleven lines of arrange" is a design review finding rather than a testing inconvenience.

#The specific smells

Static coupling. Logger.Instance.Log(...), Database.Query(...). Nothing can be substituted. Some languages let you intercept statics with tooling; doing so treats the symptom.

Constructors that do work. A constructor that opens a connection means you cannot create the object in a test. Constructors should assign fields.

private methods you want to test directly. The urge means either the method is more important than its visibility suggests and belongs in its own class, or you should test it through the public surface. Making it public "for tests" couples the test to the implementation permanently.

Deep inheritance. A test must set up the entire chain. Composition substitutes one piece.

Sealed / final third-party types in signatures. If your method takes a concrete HttpClient, DbContext or S3Client, substitution requires a wrapper. Take an interface you own and adapt at the edge.

Boolean parameters. process(order, true, false) encodes two behaviours in one method, so every test covers a combination rather than a behaviour.

#The counter-argument, taken seriously

Designing for testability can be overdone. An interface per class, a factory per interface and a container registration per factory is a real cost paid in navigability, and "it's more testable" has been used to justify a great deal of unnecessary indirection.

The defensible line: the moves that also improve the design — pure cores, injected edges, small units, explicit inputs — are worth making whether or not you write tests. The moves that exist only to satisfy a mocking framework deserve scrutiny.

If you want the fastest possible feedback on whether your design has this property, write the test first: see TDD. The difficulty of the test arrives before the code does, which is when it is cheapest to act on.

Common questions

Why is my code hard to test?
Nearly always one of four things — it constructs its own dependencies, it reads hidden state (clock, environment, statics), it mixes decision-making with I/O, or it does too many things at once. All four are design problems that show up first as testing problems.
Does designing for testability make the design worse?
It can, if taken to extremes — interfaces with a single implementation invented purely to allow mocking are a real cost. But the core moves (inject the edges, separate decisions from effects) are good design independently, which is why the pain of testing is a useful signal rather than a tax.
What is the humble object pattern?
Move all the logic out of a hard-to-test component into a plain object that is trivial to test, leaving the untestable part so thin that it is obviously correct by inspection. It is the standard technique for UI controllers, message handlers and anything bound to a framework.

Runnable samples for this page

last test results ↗
  • TypeScripttypescript/src/practices/writing-testable-code

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?