---
title: "Hard-to-Test Code"
type: "test-smell"
slug: "hard-to-test-code"
url: "http://localhost:3000/en/test-smells/hard-to-test-code.md"
category: "Coupling Smells"
description: "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."
---
# 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 `OrderService` without 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 `private` to `public`, adding `if (testMode) { ... }` test hooks, or subclassing only to reach internals.

```js
// 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).

1. **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.
2. **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_.
3. **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.
4. **Extract pure functions.** Separate computation from side effects; assert on returned values, perform IO only at the boundary.
5. **Make async observable.** Return a promise/future or expose a completion signal, and inject the scheduler so tests use fake timers instead of `sleep`.
6. **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.
7. **Avoid global/static state.** Replace singletons with explicitly passed dependencies so tests don't share or reset hidden state.

```js
// 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 (https://www.designite-tools.com/blog/understanding-testability-test-smells)
- **designite** `Excessive Dependency (testability smell)` — Excessive Dependency (https://www.designite-tools.com/blog/understanding-testability-test-smells)
- **designite** `Global State (testability smell)` — Global State (https://www.designite-tools.com/blog/understanding-testability-test-smells)
- **designite** `Law of Demeter Violation (testability smell)` — Law of Demeter Violation (https://www.designite-tools.com/blog/understanding-testability-test-smells)
