Skip to content
academia.sh

Lesson 03 / 13

Test Naming

Building a test name from its unit, condition, and expected-result parts, how bundling three rules into one test makes the name misleading, and how the names of passing tests form a readable list of rules.

Contents

The first piece of information a failing run gives is not the assertion, it is the name. In the line the runner reports, the result comes first, then the test’s name; the detailed error message sits below that. If the name states the rule, the reader usually knows what broke without going to the source. If the name says something like “record is correct”, reading the error message and going from there to the source becomes mandatory.

This lesson takes the name out of being a matter of style and ties it to something measurable: when the same three rules are written in two different forms, how much does the information a failing run gives change?

A Name’s Three Parts

A good test name is a sentence and carries three parts: which unit, under which condition, gives which result. When all three are present, the name alone is a readable rule.

In the sentence a student loan lasts twenty-eight days, the unit is the loan-duration calculation, the condition is the member type being a student, and the result is twenty-eight days. By contrast, the name dueDate test has neither a condition nor a result; the name works correctly does not even have a unit.

Two application rules keep this shape. A name refers not to the steps inside the function under test, but to the behavior observed from outside: Math.min is called is an implementation detail, does not exceed the cap is a behavior. A name states a positive rule instead of a negation: accepts a loan within the limit instead of does not throw.

Three Rules in One Test

In the library version below, three rules have regressed at once: the student loan duration has dropped to fourteen days, the grace period has been zeroed out, and the late fee’s cap has been removed.

// rules.mjs — regressed version: three rules broken at once
export const RULES = {
  student: { loanDays: 14, maxLoans: 10 },
  member: { loanDays: 14, maxLoans: 5 },
};

export const LATE_FEE = { graceDays: 0, dailyRate: 2, cap: 40 };

export function dueDate(checkoutDay, memberType) {
  return checkoutDay + RULES[memberType].loanDays;
}

export function lateFee(dueDay, returnDay) {
  const daysLate = returnDay - dueDay;
  if (daysLate <= LATE_FEE.graceDays) return 0;
  return (daysLate - LATE_FEE.graceDays) * LATE_FEE.dailyRate;
}

The form that bundles three rules into one test can only say something generic in its name.

// combined.test.mjs — the same three rules verified in a single test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dueDate, lateFee } from './rules.mjs';

test('loan rules work correctly', () => {
  assert.equal(dueDate(1000, 'student'), 1028);
  assert.equal(lateFee(1000, 1003), 0);
  assert.equal(lateFee(1000, 1100), 40);
});
node --test --test-reporter=tap combined.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
not ok 1 - loan rules work correctly
# tests 1
# pass 0
# fail 1

Three rules are broken, the report is one line. The reason is not naming alone: when one assertion fails, the rest of the test never runs. The second and third assertions were never evaluated, so the state of those two rules is unknown. As fixes are made, the same test fails again each time, and each round surfaces the next rule.

The same three rules, split into separate tests, produce a different report.

// separate.test.mjs — the same three rules, each in its own test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dueDate, lateFee } from './rules.mjs';

test('a student loan lasts twenty-eight days', () => {
  assert.equal(dueDate(1000, 'student'), 1028);
});

test('a return within the grace period is free', () => {
  assert.equal(lateFee(1000, 1003), 0);
});

test('late fee does not exceed the cap', () => {
  assert.equal(lateFee(1000, 1100), 40);
});
node --test --test-reporter=tap separate.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
not ok 1 - a student loan lasts twenty-eight days
not ok 2 - a return within the grace period is free
not ok 3 - late fee does not exceed the cap
# tests 3
# pass 0
# fail 3

Same code, same three rules, same three assertions. The difference is measurable: the first form reported that one rule was broken; the second form reported that three rules were broken, and which ones. The information obtained without looking at the source tripled.

This is the companion half of the second lesson’s signal-strength measurement. There, how many corruptions an assertion catches was measured; here, how much of a caught corruption reaches the report is measured. The rule that a test verifies one behavior is the joint result of these two measurements, not an aesthetic preference.

The Sum of the Names Is a Specification

Once the rules are fixed, the list of passing tests serves another purpose: it describes the library’s behavior in readable form.

// rules.mjs — fixed version
export const RULES = {
  student: { loanDays: 28, maxLoans: 10 },
  member: { loanDays: 14, maxLoans: 5 },
};

export const LATE_FEE = { graceDays: 3, dailyRate: 2, cap: 40 };

export function dueDate(checkoutDay, memberType) {
  return checkoutDay + RULES[memberType].loanDays;
}

export function lateFee(dueDay, returnDay) {
  const daysLate = returnDay - dueDay;
  if (daysLate <= LATE_FEE.graceDays) return 0;
  const raw = (daysLate - LATE_FEE.graceDays) * LATE_FEE.dailyRate;
  return Math.min(raw, LATE_FEE.cap);
}

Grouping tests under topic headings splits the load the name would otherwise carry: the unit and the context go to the group, the condition and the result stay with the test.

// specification.test.mjs — a test suite whose names read as a list of rules
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { dueDate, lateFee } from './rules.mjs';

describe('loan duration', () => {
  test('twenty-eight days for a student member', () => {
    assert.equal(dueDate(1000, 'student'), 1028);
  });

  test('fourteen days for a standard member', () => {
    assert.equal(dueDate(1000, 'member'), 1014);
  });
});

describe('late fee', () => {
  test('zero within the grace period', () => {
    assert.equal(lateFee(1000, 1003), 0);
  });

  test('charged per day outside the grace period', () => {
    assert.equal(lateFee(1000, 1008), 10);
  });

  test('does not exceed the cap', () => {
    assert.equal(lateFee(1000, 1100), 40);
  });
});
node --test --test-reporter=tap specification.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
    ok 1 - twenty-eight days for a student member
    ok 2 - fourteen days for a standard member
ok 1 - loan duration
    ok 1 - zero within the grace period
    ok 2 - charged per day outside the grace period
    ok 3 - does not exceed the cap
ok 2 - late fee
# tests 5
# pass 5
# fail 0

The indented lines give the individual rules, the unindented lines give the groups. Read from top to bottom, the output turns into a list of rules: the loan duration is twenty-eight days for a student member, fourteen for a standard member; the late fee is zero within the grace period, charged per day outside it, and does not exceed the cap.

This list is not the written specification itself, but it can be compared against it. A rule present in the specification but missing from the list is the traceability gap named in the What Is Quality Assurance lesson in the Quality and Testing Fundamentals course. A line present in the list but missing from the specification is a warning in the opposite direction: either the specification was written incompletely, or the test has locked in a behavior nobody asked for.

Alignment Between Name and Assertion

There is a single consistency rule between name and assertion: every field named in the assertion must be part of the rule the name states. If a test named twenty-eight days for a student member also checks the record’s status field, that test is testing two rules, and its name is hiding the second one.

The reverse direction is equally binding: if the rule the name states does not appear in the assertion, the test is not living up to its name. The second lesson’s unmatched error expectation was an example of this — its name spoke of the loan limit, but what it actually tested was a missing field.

When an assertion grows without the name changing, this alignment breaks silently. The question to ask when adding a new assertion to a test is this: is this assertion within the existing name’s scope, or is a new test needed? The second answer is usually the right one, and it costs three lines.

Summary

  • A test name carries its unit, condition, and expected-result parts; when all three are present, the name alone is a readable rule.
  • A name refers to the behavior observed from outside, not to implementation steps, and it states a positive rule instead of a negation.
  • When one assertion fails, the test’s remaining assertions never run; the form that bundled three rules into one test reported one failure, and the split form reported three.
  • Read from top to bottom, the names of passing tests give a list of rules; this list can be compared against the written specification.
  • Every field named in the assertion must be within the name’s scope; when an assertion grows without the name changing, the test starts testing a rule its name does not state.

Next Step

Up to this point, every test looked only at its own input, and every run gave the same result. As tests multiply, this does not hold on its own: two tests sharing a common fixture see the state the other left behind, and run order starts to determine the result. The next lesson produces this bond with a real run — the same tests give different results in a different order — and builds the setup that keeps tests fast and independent of each other.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close