Testing and debugging · Lesson 67 of 77 · practice
Arrange, act, assert
About 50 minutes.
By the end of this lesson you can
- Split any test into three visible steps: arrange the inputs, act once, assert on the result.
- Write a runner that runs every test, catches the failures, and reports a summary.
- Explain why one test must never depend on another test having run first.
- Name a test after the behaviour it claims rather than the function it calls.
Every test you will ever write has the same three steps
Explanation
Arrange: build the inputs this test needs, and nothing else. Act: call the thing you are testing, exactly once. Assert: compare what came back with what you claimed would come back. That is the whole shape, and it is the same whether the test is two lines or twenty.
The order matters less than the fact that the three steps are visible. When you can point at the three parts, a stranger can read the test as a sentence - 'given a basket with one line of two items at three each, when we total it, we get six' - and can tell at a glance whether a failure means the code is wrong or the claim is wrong.
The one rule worth being strict about is: act once. A test that calls the function three times and asserts after each call is three tests wearing a trenchcoat. When it fails you are told the trenchcoat failed, and you still have to work out which of the three claims broke. Split it, and the failure names itself.
Arrange inside the test, every time, even when it repeats
A common mistake
The most common way a first test suite rots is shared setup. One array is built at the top of the file, and each test pushes something into it or edits it. Everything passes on your machine. Then one test is renamed, the order changes, and three tests fail that you did not touch - because they were quietly relying on the leftovers of a test that used to run before them.
The cure is boring and it works: each test builds its own inputs, from scratch, inside its own body. Yes, you will type the same four lines in six tests. That duplication buys you a property worth far more than the keystrokes - any test can run alone, in any order, and mean the same thing. When the repetition genuinely hurts, write a small function that RETURNS a fresh input each time it is called, rather than a variable that everyone shares.
There is a second reason, and it is the one that bites hardest later: a test that depends on earlier tests cannot tell you anything when it fails. Its failure might be its own, or it might be inherited. You end up debugging the suite instead of the code.
A runner is a loop with a try/catch in it
Explanation
An assertion that throws is useful for one test and useless for fifty, because the first failure ends the program and the other forty-nine never run. You do not learn that four things are broken; you learn that one is, over and over, once per fix.
A runner fixes exactly that, and it is smaller than you expect. Put each test in a function, keep those functions in an array with a name beside each one, and loop over them. Call each one inside a try. If it returns normally, count a pass. If it throws, catch the error, record the name and the message, and carry on to the next one. At the end, report.
That is all a test framework is doing when it prints '2 passed, 1 failed' - the try/catch is the whole mechanism, and everything else is presentation. You will build this runner yourself in the exercise, and in the last lesson of the module you will see the same three parts wearing the names describe, it and expect.
The test name is the line a stranger reads first
Why it matters
When your runner prints a failure, the first thing anyone sees is the name you gave the test. 'test3' and 'basketTotal works' cost the reader a trip into the source. 'an empty basket costs nothing' tells them the rule that just broke without them reading a single line of code.
So name the behaviour, not the function. A good name finishes the sentence 'it ...': it ignores lines with a quantity of zero, it rejects a negative price, it keeps two decimal places. Do that consistently and the list of test names becomes a readable specification of what your code promises - which is the closest thing to documentation that cannot go out of date, because it fails when it stops being true.
What to hold on to
Recap
Arrange, act, assert - and act once. Build your inputs inside the test so it can run alone and in any order. A runner is a loop, a try/catch and a counter; it exists so that one failure does not hide the other four. Name each test after the behaviour it claims, because that name is the failure report.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
Three tests and the loop that runs them
A real, if tiny, test suite. Two tests pass and one fails - and the interesting part of the failing one is that the CODE is right and the CLAIM is wrong. Read the three lines in each body: arrange, act, assert, in that order, every time.
1function assertEquals(actual, expected, label) {2 if (JSON.stringify(actual) === JSON.stringify(expected)) {3 return true;4 }5 throw new Error(label + ": expected " + JSON.stringify(expected) + " but got " + JSON.stringify(actual));6}7 8function basketTotal(lines) {9 let total = 0;10 for (const line of lines) {11 total = total + line.price * line.quantity;12 }13 return total;14}15 16const tests = [17 {18 name: "adds one line",19 body: function () {20 const lines = [{ price: 3, quantity: 2 }];21 const total = basketTotal(lines);22 assertEquals(total, 6, "one line at quantity two");23 },24 },25 {26 name: "an empty basket costs nothing",27 body: function () {28 const lines = [];29 const total = basketTotal(lines);30 assertEquals(total, 0, "empty basket");31 },32 },33 {34 name: "a quantity of zero is free",35 body: function () {36 const lines = [{ price: 5, quantity: 0 }];37 const total = basketTotal(lines);38 assertEquals(total, 5, "zero quantity");39 },40 },41];42 43for (const test of tests) {44 try {45 test.body();46 console.log("PASS", test.name);47 } catch (error) {48 console.log("FAIL", test.name, "-", error.message);49 }50}- line 18
The name is a sentence about behaviour, so the report reads as English. Nothing here mentions the function's name.
- line 20
Arrange. This array is built inside this test body, so no other test can have touched it.
- line 21
Act - one call, one result. If you find yourself wanting a second call here, you have found a second test.
- line 22
Assert. The label is for the assertion; the name above is for the test. A failure prints both, and between them you rarely need to open the file.
- line 38
This claim is wrong on purpose: five at a quantity of zero costs nothing, not five. The code is correct and the test is not - which is exactly why a red test means 'go and look', never 'the code is broken'.
- line 44
The try/catch IS the runner. Without it, the third test would end the program and you would never learn that the first two passed.
What it prints
PASS adds one linePASS an empty basket costs nothingFAIL a quantity of zero is free - zero quantity: expected 5 but got 0
The bug that only appears when the tests share a basket
Both checks below claim the same thing: after adding one item, the basket holds one item. The second is false, and nothing about the second test is wrong. Its inputs were arranged by the test above it.
1const basket = [];2 3function addItem(name) {4 basket.push(name);5 return basket.length;6}7 8function testFirstItem() {9 const size = addItem("apple");10 return size === 1;11}12 13function testSecondTestInIsolation() {14 const size = addItem("pear");15 return size === 1;16}17 18console.log(testFirstItem());19console.log(testSecondTestInIsolation());20console.log(basket);- line 1
One array, declared outside every test. This single line is the whole bug. const stops the NAME being reassigned; it does nothing to stop the array being filled.
- line 14
This test would pass on its own. Run it second and the basket already has an apple in it, so the size is 2.
- line 19
false - and the failure is inherited, not caused. Reorder the two calls and the failure moves to the other test, which is the signature of shared state.
- line 20
Both items are still there at the end. In a real suite this array keeps growing all the way through the file.
What it prints
truefalse["apple","pear"]
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
This runner runs three tests; the middle one throws. Predict both printed lines - the number, then the array of failed names.
function runTests(tests) { let passed = 0; const failures = []; for (const test of tests) { try { test.body(); passed = passed + 1; } catch (error) { failures.push(test.name); } } return { passed: passed, failures: failures };}const report = runTests([ { name: "one", body: function () {} }, { name: "two", body: function () { throw new Error("nope"); } }, { name: "three", body: function () {} },]);console.log(report.passed);console.log(report.failures);
A test calls formatPrice three times with different inputs and asserts after each call. What is the practical cost?
Your suite passes when you run the whole file and fails when you run one test on its own. What is the most likely cause?
Write it yourself
Exercise · core · 9 tests
Build the runner that survives a failure
Write runTests(tests). Each item in tests is an object with a name and a body function. Run every body, in order. Return an object with three properties: passed (how many bodies returned without throwing), failed (how many threw), and failures (an array with one entry per failure, each an object with the failing test's name and the thrown error's message, in the order they failed). runTests itself must never throw, however badly a body misbehaves. The starter counts passes correctly but has no try/catch, so the first failure takes the whole run down with it.
runTests returns { passed, failed, failures } for any array of { name, body }. A body that returns normally counts as passed; one that throws counts as failed and adds { name, message } to failures, in failure order. An empty array gives 0, 0 and an empty failures array. runTests never throws and never prints.
Define runTests at the top level of your code — the tests call it by name.
Worth remembering
- What are the three steps of a test, and which one is limited to once?
Arrange the inputs, act by calling the code, assert on the result. Act exactly once - a second call is a second test.
- What makes a test runner able to report five failures instead of one?
It calls each test body inside a try/catch, so a thrown assertion is caught, recorded and counted rather than ending the program.
- A test passes with the whole file and fails alone. What do you look for?
Shared mutable state - a fixture built outside the test bodies that an earlier test changed. Arrange inside each body instead.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
Check there: When a statement inside try throws, control moves to the catch block with the thrown value bound to its parameter, and execution then continues after the try statement rather than ending the program.
Check there: An Error instance carries the human-readable description passed to its constructor on the message property.
Check there: push changes the array it is called on and returns the new length; it does not produce a new array.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown