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.
// 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.
// 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.
// 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 clockRandom,uuid()— inject the sourceprocess.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 ↗- TypeScript
typescript/src/practices/writing-testable-code
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Dependency Injection for TestabilityWhy injected dependencies are the one technique that makes isolated testing possible, the three forms of injection, and how to do it without a container.
- Test-Driven DevelopmentRed-green-refactor, what TDD actually changes about a codebase, where it fits badly, and the honest evidence for and against it.
- Unit TestingWhat a unit actually is, what belongs in a unit test and what does not, and the properties that separate a unit suite people run from one they skip.
- Test DoublesDummy, stub, spy, mock and fake — Meszaros's five kinds of test double, what each is for, and why using the words precisely makes code reviews shorter.
- Flaky TestsWhy tests fail intermittently, the six root causes and how to fix each one, how to detect flakiness deliberately, and what to do with a test you cannot fix today.
- Testing Avalonia ApplicationsAvalonia's headless test platform runs real UI tests in CI in milliseconds with no display server — the most testable desktop stack in .NET.
- Arrange-Act-AssertThe three-part shape every readable test has, why the act step should be one line, and the smells that show up when a test will not fit the pattern.
- SDET vs Automation Tester vs Manual QAThree genuinely different jobs that are routinely advertised as seniority levels of one — what each actually does, and what goes wrong when the distinction is lost.