Hard-to-Test Code.
Production code whose design (tight coupling, hidden dependencies, global state, non-deterministic IO, or async-only interfaces) forces tests into awkward contortions, or makes a unit impossible to exercise in isolation at all.
##Signs and Symptoms
This smell lives in the production code, but you discover it while writing tests. The tell-tale sign is that a unit test cannot be written cleanly — the test has to fight the design to get the code under test into a known state and observe the result.
Watch for these symptoms:
- Giant arrange blocks. You must construct a web of collaborators just to instantiate the class under test ("I can't test
OrderServicewithout also wiring up a DB, a gateway, and a config loader"). - No seam to inject a double. The code calls
new ConcreteThing(), reaches a static singleton (Database.getInstance()), or hits the network/filesystem/clock directly, so there is nowhere to substitute a stub or fake. - Non-determinism baked in. Logic depends on
Date.now(),Math.random(),process.env, or wall-clock timing that the test cannot control, producing flaky or unrepeatable results. - No observation point / no control point. The result you want to assert is buried in private state or a side effect, so tests reach in via reflection, casts, or test-only getters.
- Async-only interface. The only way to drive the code is to start a timer/thread/queue and then
sleep()or poll, because completion is never directly observable. - You change production code just to test it. Loosening
privatetopublic, addingif (testMode) { ... }test hooks, or subclassing only to reach internals.
// Hard to test: hidden + hard-wired dependencies, global state, non-determinism
class OrderService {
placeOrder(cart) {
const db = Database.getInstance(); // global singleton (no seam)
const gateway = new StripeGateway(API_KEY); // hard-wired concrete dep -> real network
const id = Math.random().toString(36).slice(2); // non-deterministic
const charge = gateway.charge(cart.total); // real HTTP call inside the unit
db.save({ id, at: Date.now(), charge }); // non-injected clock
return id;
}
}
// To "unit test" this you must hit Stripe, monkey-patch Date/Math globally,
// and inspect a shared singleton — i.e. Indirect Testing through awkward interfaces.
##Reasons for the Problem
Why it happens
- Test-Last on legacy code. Meszaros names the root cause lack of Design for Testability. Testability emerges naturally from TDD, but when tests are written "last," nothing pressured the design to expose control and observation points, so it has to be retrofitted.
- Tight coupling and hard-wired dependencies. Instantiating concrete collaborators with
new(or pulling them from static singletons) means you cannot replace them with test doubles — the test exercises the unit and everything it touches. - Global/shared state and hidden inputs. Singletons, statics, ambient time, randomness, and environment reads are inputs the test never declared and cannot set, so behavior is implicit and uncontrollable.
- Asynchrony and side effects at the core. When business logic is entangled with threads, queues, IO, or the UI, there is no pure value to assert.
Why it hurts
- Reliability. Tests forced to use real IO, sleeps, shared singletons, or global clocks become slow, flaky, and order-dependent — the classic path to an Erratic/Fragile Test.
- Maintainability. Enormous setup and tests that reach into internals couple the test suite to implementation details, so harmless refactors break tests (Fragile Test / Fragile Fixture).
- False confidence. The hardest, most important code paths get tested only through coarse, indirect interfaces — or skipped entirely. Coverage numbers look fine while the risky logic is barely exercised.
- Readability. A test dominated by scaffolding obscures the one behavior it is supposed to specify, so it documents nothing.
In the Open Catalog of Test Smells, Hard-To-Test Code is the production-side counterpart of test smells like Indirect Testing and Fragile Test: the bad design is the cause, the awkward tests are the symptom.
##Treatment
Fix the design, not the test. The goal is to give every unit a control point (a way to put it in a known state) and an observation point (a way to read the outcome).
- Introduce seams via Dependency Injection. Pass collaborators in (constructor injection) instead of constructing or looking them up. Inject ambient inputs too — clock, id/uuid generator, random source — so they become controllable parameters.
- Depend on abstractions. Program to an interface and substitute a stub/fake/mock in tests. This directly removes Designite's Hard-wired Dependency and trims Excessive Dependency.
- Apply the Humble Object pattern. Push the genuinely hard-to-test parts (UI, async glue, raw IO) into a thin adapter with no logic, and move the decision logic into a plain, synchronous object you can test directly.
- Extract pure functions. Separate computation from side effects; assert on returned values, perform IO only at the boundary.
- Make async observable. Return a promise/future or expose a completion signal, and inject the scheduler so tests use fake timers instead of
sleep. - For legacy you can't redesign yet, use a Test-Specific Subclass or a narrowly scoped seam (Feathers' techniques) to get the code under test, then refactor toward injection — rather than leaving permanent
if (testMode)hooks in production. - Avoid global/static state. Replace singletons with explicitly passed dependencies so tests don't share or reset hidden state.
// AFTER: dependencies, clock, and id are injected -> testable in isolation
class OrderService {
constructor(db, gateway, clock = () => Date.now(), newId = uuid) {
this.db = db; this.gateway = gateway; this.clock = clock; this.newId = newId;
}
placeOrder(cart) {
const id = this.newId();
const charge = this.gateway.charge(cart.total); // a PaymentGateway abstraction
this.db.save({ id, at: this.clock(), charge });
return id;
}
}
// Test: deterministic, no network, no global patching, fast.
const svc = new OrderService(fakeDb, fakeGateway, () => 1000, () => 'id-1');
expect(svc.placeOrder({ total: 50 })).toBe('id-1');
expect(fakeGateway.charge).toHaveBeenCalledWith(50);
expect(fakeDb.saved[0]).toEqual({ id: 'id-1', at: 1000, charge: fakeGateway.result });
##Detected by
- designite Hard-wired Dependency (testability smell) — Hard-wired Dependency
- designite Excessive Dependency (testability smell) — Excessive Dependency
- designite Global State (testability smell) — Global State
- designite Law of Demeter Violation (testability smell) — Law of Demeter Violation