TEST-007 Name tests after the behavior under test

Best Practice C++GoPythonRust Testing

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