TEST-007 Name tests after the behavior under test
Name a test after the behavior it verifies: the scenario under test and the
expected result. The name is the first thing you see when a test fails, so it
should identify the broken behavior without opening the file. A name like
test_withdraw_1 or TestAccount forces you to read the body to learn what
broke; a name that states the behavior does not.
TEST_CASE("withdraw more than the balance is rejected") {
// Arrange
auto account = Account{/*balance=*/100};
// Act
const bool ok = account.withdraw(150);
// Assert
REQUIRE_FALSE(ok);
REQUIRE(account.balance() == 100);
}
func TestWithdraw_RejectsAmountAboveBalance(t *testing.T) {
t.Parallel()
// Arrange
account := NewAccount(100)
// Act
ok := account.Withdraw(150)
// Assert
if got, want := ok, false; got != want {
t.Errorf("Withdraw(150) = %v, want %v", got, want)
}
if got, want := account.Balance(), 100; got != want {
t.Errorf("Balance() = %d, want %d", got, want)
}
}
def test_withdraw_more_than_balance_is_rejected():
# Arrange
account = Account(balance=100)
# Act
ok = account.withdraw(150)
# Assert
assert ok is False
assert account.balance() == 100
#[test]
fn withdraw_more_than_balance_is_rejected() {
// Arrange
let mut account = Account::new(100);
// Act
let ok = account.withdraw(150);
// Assert
assert!(!ok);
assert_eq!(account.balance(), 100);
}
Motivation
Test names are read in two situations, and a body-free name serves both:
In a failing run: a CI log or test runner prints the name of the test that failed, and very little additional context. If the name is
test_case_3, you learn only that something failed, and you must open the file, read the setup, and infer the intent.If the name is
withdraw_more_than_balance_is_rejected, the log already tells you which behavior regressed.Reading the suite as a catalogue: test names, listed together, enumerate the behaviors a unit guarantees. Names built from the method plus a number describe the code’s structure, not its behavior, so the list documents nothing. Names built from scenario and result read as a specification.
Justification
The value of a test name is the information it carries at the moment of failure. A name derived from the behavior – the input condition and the expected outcome – carries that information for free, because it restates the contract the test checks. A name derived from a method or an index carries none of it, and pushes the work of understanding the failure onto whoever reads the log.
A consistent scheme makes the names predictable. One common shape is
subject, condition, expected result (withdraw, more than balance,
is rejected). The exact convention matters less than applying one uniformly, so
that every name answers the same question in the same order.
Examples
The unit under test is an Account whose withdraw refuses to overdraw. The bad
name says nothing about that behavior; the good name states the scenario and the
result.
❌ Bad Example
// The name describes neither the scenario nor the expected result.
TEST_CASE("withdraw test 2") {
Account account{100};
REQUIRE_FALSE(account.withdraw(150));
}
✅ Good Example
// The name states the scenario (overdraw) and the result (rejected).
TEST_CASE("withdraw more than the balance is rejected") {
// Arrange
auto account = Account{/*balance=*/100};
// Act
const bool ok = account.withdraw(150);
// Assert
REQUIRE_FALSE(ok);
REQUIRE(account.balance() == 100);
}
Go convention is Test<Subject>_<Condition>_<Expectation>; the leading Test is
required by the toolchain, and the suffix carries the behavior.
Go also supports subtests with t.Run, which help provide additional details.
❌ Bad Example
// A method name plus a number; the log tells you nothing on failure.
func TestWithdraw2(t *testing.T) {
account := NewAccount(100)
if account.Withdraw(150) {
t.Errorf("withdraw succeeded")
}
}
✅ Good Example
func TestWithdraw_AmountAboveBalance_RejectsWithdrawal(t *testing.T) {
t.Parallel()
// Arrange
account := NewAccount(100)
// Act
ok := account.Withdraw(150)
// Assert
if got, want := ok, false; got != want {
t.Errorf("Withdraw(150) = %v, want %v", got, want)
}
if got, want := account.Balance(), 100; got != want {
t.Errorf("Balance() = %d, want %d", got, want)
}
}
❌ Bad Example
# Numbered and opaque; the report names the function but not the behavior.
def test_withdraw_2():
account = Account(balance=100)
assert not account.withdraw(150)
✅ Good Example
def test_withdraw_more_than_balance_is_rejected():
# Arrange
account = Account(balance=100)
# Act
ok = account.withdraw(150)
# Assert
assert ok is False
assert account.balance() == 100
❌ Bad Example
// Says nothing about the condition being tested.
#[test]
fn withdraw_works() {
let mut account = Account::new(100);
assert!(!account.withdraw(150));
}
✅ Good Example
#[test]
fn withdraw_more_than_balance_is_rejected() {
// Arrange
let mut account = Account::new(100);
// Act
let ok = account.withdraw(150);
// Assert
assert!(!ok);
assert_eq!(account.balance(), 100);
}
Resources
Naming standards for unit tests by Roy Osherove - the classic method / state-under-test / expected-behavior naming scheme.
Software Engineering at Google: Unit Testing - on test names as behavior documentation and writing descriptive test names.