Test Logic in Production.
Production code contains logic, branches, or members that exist only to support testing, blurring the line between what ships and what is merely tested.
##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', ormockflag that short-circuit the real behavior. - "For tests only" members — public setters, getters,
reset(), or constructors added solely so a test can reach internal state, often tagged@VisibleForTesting/@TestOnlyand then actually called from production. - 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.
##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."
- 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.
- 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.
- 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
ifin the base class. - 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.
- 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. - 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/@TestOnlyand 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
- codeql java/visible-for-testing-abuse — Use of VisibleForTesting in production code
- deepsource JAVA-A1067 — @VisibleForTesting/@TestOnly methods should not be used in non-test code
- android-lint VisibleForTests — Visible Only For Tests