EduFlow
Testing JavaScript Apps
0%

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.

  1. 1Arrange, Act, Assert
  2. 2Names are the first line of the bug report
  3. 3toBe or toEqual?
  4. 4Reading a failure
  5. 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:

src/tip.test.js
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.

Three boxes in a row: Arrange builds inputs, Act calls withTip once, Assert compares the result with toEqual. Arrange 1980, 12.5 Act withTip(...) Assert toEqual({...})
Arrange, Act, Assert: one setup, one action, the claims

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:

  • toBe uses Object.is: same primitive value, or the same object reference. Use it for numbers, strings and booleans.
  • toEqual compares structure, recursively: same keys, same values. Use it for objects and arrays. (toStrictEqual also checks that undefined properties 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:

JavaScript
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:

Text
 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:28

Read it top to bottom, in three passes:

  1. The path says which behaviour broke: converting 0.29 dollars.
  2. Expected versus received says how: one cent short, so something truncated instead of rounding.
  3. 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:

JavaScript
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.
  • toBe compares with Object.is (same value or same reference); toEqual compares 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.

Further reading

Check your understanding

4 questions

Question 1 of 4

What does this test report?

JavaScript
test('builds the tip summary', () => {
  expect(withTip(2000, 15)).toBe({ tipCents: 300, totalCents: 2300 });
});
Question 2 of 4

A failing test prints expected 28 to be 29 for toCents(0.29). Where do you look first?

Question 3 of 4

Which test name is most useful when it fails in CI at 2 a.m.?

Question 4 of 4

Why do Arrange, Act and Assert usually appear in that order, separated by a blank line?

Practice

Code playground

AAA tests for withTip

withTip(subtotalCents, percent) returns { tipCents, totalCents }. The tip is rounded to the nearest cent (a half cent rounds up), a 0% tip is valid, and a negative percent throws a RangeError.

The starter test checks a 15% tip on $20.00. Add tests in Arrange, Act, Assert shape until your suite passes on the real code and catches three hidden bugs: one floors the tip instead of rounding it, one forgets to reject negative percents, and one wrongly rejects a zero tip.

// Adds a tip to a subtotal. Money is in integer cents; percent is a
// number such as 15 or 12.5. The tip is rounded to the nearest cent
// (a half cent rounds up). A zero tip is allowed; a negative one is not.
function withTip(subtotalCents, percent) {
  if (percent < 0) throw new RangeError('Tip percent cannot be negative');
  const tipCents = Math.round((subtotalCents * percent) / 100);
  return { tipCents, totalCents: subtotalCents + tipCents };
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Results

(no output)

Checks

  • All your tests pass against the real withTip
  • Catches the version that floors the tip
  • Catches the version that accepts a negative percent
  • Catches the version that rejects a zero tip