---
title: "Test Logic in Production"
type: "test-smell"
slug: "test-logic-in-production"
url: "http://localhost:3000/en/test-smells/test-logic-in-production.md"
category: "Coupling Smells"
description: "Production code contains logic, branches, or members that exist only to support testing, blurring the line between what ships and what is merely tested."
---
# 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'`, or `mock` flag 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` / `@TestOnly` and 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.

```ts
// 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.

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.

```ts
// 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 (https://codeql.github.com/codeql-query-help/java/java-visible-for-testing-abuse/)
- **deepsource** `JAVA-A1067` — @VisibleForTesting/@TestOnly methods should not be used in non-test code (https://deepsource.com/directory/java/issues/JAVA-A1067)
- **android-lint** `VisibleForTests` — Visible Only For Tests (https://googlesamples.github.io/android-custom-lint-rules/checks/VisibleForTests.md.html)
