ConstructiCat Logo
CodeBust.
Browse section ▾

Test Logic in Production.

Production code contains test-mode flags, hooks, or branches that make the shipped code behave differently under test, so the real path ships untested.

##Signs and Symptoms

You find code in the system under test (SUT) that only matters when a test is running. Tell-tale signs:

  • Test hooks / mode flags — branches keyed off a testing, isTest, NODE_ENV === 'test', or mock flag that short-circuit the real behavior.
  • For Tests Only members that production code relies on — Meszaros lists them as one cause of this smell: setters, reset(), or constructors added so a test can reach internal state, often tagged @VisibleForTesting / @TestOnly, that production code then actually calls. The test-only API surface itself is covered in For Testers Only.
  • Equality pollution — equals() / comparison logic added to a production class purely so an assertion can compare two objects.
  • Test dependency in production — production modules importing a test framework, fixture, or mock factory.
// SMELL: the shipped code behaves differently when "testing" is on
class PaymentService {
  charge(order: Order) {
    if (process.env.NODE_ENV === 'test' || this.isTesting) {
      return { status: 'ok', id: 'FAKE-TEST-ID' }; // real gateway never exercised
    }
    return this.gateway.charge(order); // <-- the path that actually ships
  }
}

The "real" branch is the one your customers hit, and it is exactly the branch your tests skip.

Scope: this article is about behavioral test logic — flags, hooks, and branches that make the shipped code act differently when it is under test. Meszaros traces the smell to four causes: Test Hook, For Tests Only, Test Dependency in Production, and Equality Pollution. Members that exist only so tests can reach in, without changing what production does, are covered by For Testers Only.

##Reasons for the Problem

Why it happens

  • The SUT is hard to test (it talks to a network, clock, payment gateway, or filesystem) and adding an if (testing) shortcut is faster than introducing a proper seam.
  • A test needs to observe or set internal state, so a developer exposes it "just for the test." See For Testers Only.
  • Stubbing/mocking is awkward, so canned data is hard-coded behind a flag.

Why it hurts

  • False confidence. Tests exercise the test-only branch, so the production path ships untested. Green tests prove the fake works, not the real thing.
  • Reliability / safety. Test-only code that survives into production can run in anger. Meszaros' canonical cautionary tale is Ariane 5: ground-only code left active in flight triggered the failure. An if (isTesting) left enabled is the software version of that.
  • Security. Test bypasses are backdoors — a flag that skips auth, payment, or validation is one config mistake away from being exploitable.
  • Readability & API bloat. Test-only setters/getters/reset() enlarge the public surface and mislead real clients about what the class is for.
  • Maintainability. Two behaviors live in one class; every change must reason about both the production and the test path, and the divergence quietly rots.

##Treatment

Get the test logic out of production code by introducing a proper seam instead of a flag.

  1. Inject the variation (Dependency Injection + Test Double). Replace the hard-coded branch with a collaborator that the test substitutes. Production wires the real implementation; the test wires a fake/stub/mock.
  2. Use a Test-Specific Subclass when you only need to override one method — override it in a subclass that lives in test code, not via an if in the base class.
  3. Apply the Humble Object pattern to pull hard-to-test logic (clock, I/O) behind a thin adapter, so the core logic becomes directly testable without hooks.
  4. Move comparison logic to the test side. Instead of polluting production with equals() for assertions, use a custom matcher/comparator or assert on the fields you care about.
  5. Keep test code out of the build. Use source-set / build-config separation so test helpers can't be compiled into the shipped artifact; mark genuinely test-visible members with @VisibleForTesting/@TestOnly and let a linter enforce that production never calls them.
// BEFORE: test hook inside production code
class PaymentService {
  charge(order: Order) {
    if (this.isTesting) return { status: 'ok', id: 'FAKE-TEST-ID' };
    return this.gateway.charge(order);
  }
}

// AFTER: one code path; the gateway is injected and faked in the test
class PaymentService {
  constructor(private gateway: PaymentGateway) {}
  charge(order: Order) {
    return this.gateway.charge(order); // same path in prod and test
  }
}

// test
const fakeGateway = { charge: () => ({ status: 'ok', id: 'FAKE-TEST-ID' }) };
const service = new PaymentService(fakeGateway);

Now the production path is the only path, and the test controls behavior from the outside.

##Detected by