Section 1 · Why and What to Test
Anatomy of a Test and Reading Failures
18 min3 min readCode playground
Lesson at a glance
Structure every test as Arrange, Act, Assert, choose between toBe and toEqual, and read a Vitest failure to find the broken line in seconds.
- 1Arrange, Act, Assert
- 2Names are the first line of the bug report
- 3toBe or toEqual?
- 4Reading a failure
- 5Testing that something throws
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
A test is read far more often than it is written. It is read when it fails at a bad moment, by someone who didn’t write it, who wants one answer: what broke? This lesson is about writing tests that answer that in seconds, and about reading the answer Vitest gives you.
Arrange, Act, Assert
Nearly every good test has the same three parts:
import { test, expect } from 'vitest';
import { withTip } from './tip.js';
test('rounds a half-cent tip up to the next cent', () => {
// Arrange: the inputs that matter for this behaviour
const subtotalCents = 1980;
const percent = 12.5;
// Act: one call to the code under test
const result = withTip(subtotalCents, percent);
// Assert: the claims about the outcome
expect(result).toEqual({ tipCents: 248, totalCents: 2228 });
});Arrange builds the world the test needs. Act does exactly one thing: the behaviour you are testing. Assert states what should now be true. You don’t need the comments once the shape is a habit; the blank lines are enough.
The shape protects you from the most common smell: a test that acts, asserts, acts again, asserts again. When that fails, you can’t tell which step broke. Split it into two tests, each with a name that says what it proves.
How many assertions per test?
“One assertion per test” is a rule you will hear, and it is too strict. The real rule is one behaviour per test. Checking both tipCents and totalCents after a single call is one behaviour with two observable facts, so asserting both is fine, and toEqual on the whole object does it in one line. What you want to avoid is a test that checks rounding, then calls the function again to check the error case, then a third time for zero. When the first claim fails, Vitest stops that test, so you never learn whether the other two behaviours still work. Three small tests report three separate answers.
Names are the first line of the bug report
When a test fails, the report prints its full path: withTip > rounds a half-cent tip up to the next cent. Write that name as a sentence about behaviour. “withTip works” tells you nothing; “rejects a negative tip percent” tells you what regressed before you open a file. Group with describe by unit, name with test by rule.
toBe or toEqual?
Two matchers do most of the work, and they compare differently:
toBeusesObject.is: same primitive value, or the same object reference. Use it for numbers, strings and booleans.toEqualcompares structure, recursively: same keys, same values. Use it for objects and arrays. (toStrictEqualalso checks thatundefinedproperties and class types match.)
expect(withTip(2000, 15)).toBe({ tipCents: 300, totalCents: 2300 }) fails even though the values match, because the function returns a new object. This trips up everyone once.
Reading a failure
Here is a real kind of bug. A first version of toCents, which turns a dollar amount into cents, used Math.floor:
const toCents = (dollars) => Math.floor(dollars * 100);
console.log(0.29 * 100); // what the computer actually computes
console.log(toCents(0.29)); // one cent short
console.log(Math.round(0.29 * 100));The test expect(toCents(0.29)).toBe(29) fails, and Vitest reports something like this:
FAIL src/money.test.js > toCents > converts 0.29 dollars to 29 cents
AssertionError: expected 28 to be 29 // Object.is equality
- Expected
+ Received
- 29
+ 28
❯ src/money.test.js:7:28Read it top to bottom, in three passes:
- The path says which behaviour broke: converting 0.29 dollars.
- Expected versus received says how: one cent short, so something truncated instead of rounding.
- The location (
money.test.js:7:28) points at the assertion line in the test. The bug is in the code that line calls.
For objects, the diff marks only the lines that differ, so a wrong totalCents inside a ten-key object stands out immediately.
Testing that something throws
Sometimes the behaviour is “refuse bad input”. Wrap the call in a function so expect can call it and catch the error:
test('rejects a negative tip percent', () => {
expect(() => withTip(1000, -5)).toThrow(RangeError);
});Without the arrow function, withTip throws while the arguments are being evaluated, before expect runs, and the test crashes instead of passing. toThrow also accepts a string or regex to match the message.
In the exercise you write AAA tests for withTip, including the boundary that is easy to forget: a tip of zero. The next section moves from “how a test looks” to “which tests are worth writing”.
Key takeaways
- Arrange, Act, Assert gives every test the same three-part shape, so readers find the setup, the action and the claim without hunting.
- One test checks one behaviour, and its name is a sentence that says which behaviour broke when it fails.
toBecompares withObject.is(same value or same reference);toEqualcompares structure, which is what you want for objects and arrays.- A failure report gives you the test path, the expected and received values, and the file and line; read all three before touching code.