TEST-006 Do not write test-aware code
Do not write code that detects whether it is running under test and changes its behavior – test-aware code. A unit that branches on a test flag, the presence of a test runner, the presence of an environment variable, or a test-only build runs a different path under test than it does in production, so the test verifies behavior you do not ship. Instead, make code testable through design – typically dependency injection – so that tests substitute collaborators without the unit ever knowing.
Tip
If a test only passes because the code skipped its real work, the test is exercising the test-mode path, not the production behavior. It has little value as a test.Motivation
The purpose of a test is to give confidence that the shipped code works. Test-aware code defeats that purpose: the branch the test takes is, by construction, not the branch production takes. The production branch – the database write, the network call, the real side effect – is usually the riskiest part of the unit, and it is exactly the part a test flag skips. This enables the test to pass while the untested path is the one that runs in real life, giving a false sense of security.
Justification
A test is only meaningful if it exercises the same code that runs in production. Removing test-awareness restores that property: with the test flag gone, there is a single code path, and the test drives it the same way a real caller would. The difference between test and production then lives entirely in the collaborators that are supplied from the outside, not in the unit’s own logic.
Dependency injection is what makes this possible without test-awareness; by accepting its collaborators – a datastore, a clock, a network client – through a constructor, parameter, or interface, a unit lets a test pass a substitute and production pass the real thing, while the unit’s behavior stays identical in both. The seam is at the boundary, where it belongs, instead of inside the logic. The need for a test flag is a sign that such a seam is missing.
Examples
Each example is an Account whose deposit updates an in-memory balance and then
persists it. The naive, test-aware version suppresses the persistence under test;
the injected version supplies the datastore from the outside so the same code
runs everywhere.
The mechanism that tempts test-awareness differs by language – a flag, a preprocessor guard, runner detection, or a build configuration. Each dropdown notes the relevant one.
C++ example
The common test-aware mechanism in C++ is a preprocessor guard (#ifdef UNIT_TEST) or a runtime flag that strips real work from test builds. Both make
the test build compile different code than production. Prefer injecting an
abstract interface that a test can implement.
❌ Bad Example
class Account {
public:
// The persistence call is compiled out of test builds entirely.
auto deposit(int amount) -> void {
balance_ += amount;
#ifndef UNIT_TEST
save_to_database(balance_);
#endif
}
auto balance() const -> int { return balance_; }
private:
int balance_ = 0;
};
// Built with -DUNIT_TEST, so deposit() never persists here.
TEST_CASE("deposit increases the balance") {
auto account = Account{};
account.deposit(100);
REQUIRE(account.balance() == 100);
}
✅ Good Example
// An injected interface lets the test substitute the collaborator.
class Store {
public:
virtual ~Store() = default;
virtual auto save(int balance) -> void = 0;
};
class Account {
public:
explicit Account(Store& store) : m_store{&store} {}
auto deposit(int amount) -> void {
m_balance += amount;
m_store->save(m_balance);
}
auto balance() const -> int { return m_balance; }
private:
Store* m_store;
int m_balance = 0;
};
struct RecorderStore : Store {
int saved = 0;
auto save(int balance) -> void override { saved = balance; }
};
TEST_CASE("deposit increases the balance and persists it") {
// Arrange
auto store = RecorderStore{};
auto account = Account{store};
// Act
account.deposit(100);
// Assert
REQUIRE(account.balance() == 100);
}
Go example
Go encourages dependency injection through interfaces, and collaborators can be
supplied as struct fields, constructor arguments, or functional options. The
test-aware temptation is a boolean field like TestMode; the fix is to inject the
datastore behind an interface so production and tests run the same method body.
❌ Bad Example
package bank
type Account struct {
balance int
TestMode bool
}
// Deposit skips persistence when TestMode is set, so the test takes a different
// path than production.
func (a *Account) Deposit(amount int) {
a.balance += amount
if a.TestMode {
return
}
saveToDatabase(a.balance)
}
func (a *Account) Balance() int { return a.balance }
package bank_test
func TestDeposit(t *testing.T) {
account := &bank.Account{TestMode: true}
account.Deposit(100)
if got, want := account.Balance(), 100; got != want {
t.Errorf("Balance() = %d, want %d", got, want)
}
}
✅ Good Example
package bank
// Store persists account balances.
type Store interface {
Save(balance int) error
}
type Account struct {
balance int
store Store
}
func NewAccount(store Store) *Account {
return &Account{store: store}
}
func (a *Account) Deposit(amount int) error {
a.balance += amount
return a.store.Save(a.balance)
}
func (a *Account) Balance() int { return a.balance }
package bank_test
type recorderStore struct{ saved int }
func (s *recorderStore) Save(balance int) error {
s.saved = balance
return nil
}
func TestDeposit(t *testing.T) {
// Arrange
store := &recorderStore{}
account := bank.NewAccount(store)
// Act
err := account.Deposit(100)
// Assert
if got, want := err, (error)(nil); !errors.Is(got, want) {
t.Fatalf("Deposit(100) returned error: %v", err)
}
if got, want := account.Balance(), 100; got != want {
t.Errorf("Balance() = %d, want %d", got, want)
}
}
Python example
The Python temptation is to detect the runner at runtime –
"pytest" in sys.modules, an environment variable, or an is_test flag. Prefer
passing the collaborator into the constructor so the same deposit body runs
under test and in production.
❌ Bad Example
# bank.py
import sys
class Account:
def __init__(self, balance: int = 0):
self.balance = balance
def deposit(self, amount: int) -> None:
self.balance += amount
if "pytest" in sys.modules: # behaves differently under test
return
self._save_to_database()
def test_deposit():
account = Account()
account.deposit(100)
assert account.balance == 100
✅ Good Example
# bank.py
class Account:
def __init__(self, store: Store, balance: int = 0):
self._store = store
self.balance = balance
def deposit(self, amount: int) -> None:
self.balance += amount
self._store.save(self.balance)
class RecorderStore:
def __init__(self):
self.saved = None
def save(self, balance: int) -> None:
self.saved = balance
def test_deposit_increases_balance_and_persists_it():
# Arrange
store = RecorderStore()
account = Account(store)
# Act
account.deposit(100)
# Assert
assert account.balance == 100
assert store.saved == 100
Rust example
#[cfg(test)] is the idiomatic way to compile test-only modules, but using
#[cfg(not(test))] to branch production logic makes the test build behave
differently than the release build – the test-aware antipattern. Prefer injecting
the collaborator as a generic type parameter (or trait object) on the struct.
❌ Bad Example
pub struct Account {
balance: i64,
}
impl Account {
// The persistence call is compiled out of test builds.
pub fn deposit(&mut self, amount: i64) {
self.balance += amount;
#[cfg(not(test))]
self.save_to_database();
}
pub fn balance(&self) -> i64 {
self.balance
}
}
✅ Good Example
pub trait Store {
fn save(&mut self, balance: i64);
}
pub struct Account<S: Store> {
balance: i64,
store: S,
}
impl<S: Store> Account<S> {
pub fn new(store: S) -> Self {
Self { balance: 0, store }
}
pub fn deposit(&mut self, amount: i64) {
self.balance += amount;
self.store.save(self.balance);
}
pub fn balance(&self) -> i64 {
self.balance
}
pub fn store(&self) -> &S {
&self.store
}
}
struct RecorderStore {
saved: Option<i64>,
}
impl Store for RecorderStore {
fn save(&mut self, balance: i64) {
self.saved = Some(balance);
}
}
#[test]
fn deposit_increases_balance_and_persists_it() {
// Arrange
let mut account = Account::new(RecorderStore { saved: None });
// Act
account.deposit(100);
// Assert
assert_eq!(account.balance(), 100);
assert_eq!(account.store().saved, Some(100));
}
Resources
Working Effectively with Legacy Code by Michael Feathers - covers seams and dependency injection for making code testable without changing its behavior.
Dependency injection (Wikipedia) - the pattern used to supply collaborators from the outside instead of branching on a test flag.
Functional options for friendly APIs by Dave Cheney - a Go pattern for injecting behavior without exposing it as test-aware configuration.