15. Testing your code
Running your code once and eyeballing the answer is not knowing that it works. A test writes that check down so it runs every time, and the cleverest kind needs no right answers at all: just two programs that ought to agree.
- Write a test with an assertion, and explain what makes it pass or fail.
- Choose edge cases that are likely to expose bugs.
- Explain the difference between toBe, toEqual and toBeCloseTo.
- Use differential testing to check two implementations against each other.
“It worked when I tried it”
Back in chapter 2 you wrote max. You probably ran it on one array, saw
the right number, and moved on. Now suppose that next month you change
max to make it faster. Is it still right? To find out, you would have
to remember which arrays you tried, and what the answers should be.
Nobody remembers. So write it down, as code.
Pilots run through a printed checklist before every take-off, even after ten thousand flights. Not because they forgot how to fly, but because a written check catches the one thing memory skips. A test is a checklist for your code, and the computer reads it out for you every time.
A test is code that checks code
The smallest possible check is an if:
if (max([3, 7, 2]) !== 7) {
console.log('FAIL: max([3, 7, 2])');
}
That gets repetitive fast, so give the two jobs names. An assertion checks one fact and throws if it’s false. A test runs some assertions and reports whether anything threw:
function expectBe(got, want) {
if (got !== want) {
throw new Error(
`expected ${want}, got ${got}`);
}
}
function test(name, fn) {
try {
fn();
console.log('pass: ' + name);
} catch (e) {
console.log('FAIL: ' + name);
console.log(' ' + e.message);
}
}
test('picks the largest', () => {
expectBe(max([3, 7, 2]), 7);
});
That is nearly the whole idea, and it’s how the tests behind every
exercise in this book work: test runs your function, expect(x).toBe(7)
throws if x isn’t 7, and the runner counts what threw. Node’s
built-in test runner uses the same rule: a test fails if it throws, and
passes otherwise[1].
A test is code that runs other code on chosen inputs and checks the
results. An assertion is one check that must be true, like
expect(max([3, 7, 2])).toBe(7). If an assertion is false, it throws,
and the test fails with a message saying what was expected and what
actually happened.
function expectBe(got, want) {
if (got !== want) {
throw new Error(
`expected ${want}, got ${got}`);
}
}
function test(name, fn) {
try {
fn();
console.log('pass: ' + name);
} catch (e) {
console.log('FAIL: ' + name);
console.log(' ' + e.message);
}
}
function double(n) {
return n + n;
}
test('double 3', () => {
expectBe(double(3), 6);
});
test('double 0', () => {
expectBe(double(0), 1);
});
// The second test is wrong on purpose.
// Fix the test, not the function.Which kind of equal?
Chapter 4 showed that === on objects asks “same object?”. So
expect([1, 2]).toBe([1, 2]) fails: two separate arrays are never
===. When you want “same contents”, use toEqual, which compares the
insides piece by piece.
Decimals need a third kind. 0.1 + 0.2 is not exactly 0.3 in
JavaScript (try it: you get 0.30000000000000004; the next chapter shows
why). So for computed decimals, use toBeCloseTo(0.3), which allows a
tiny difference.
Edge cases
Here is a max that passes the obvious tests. Step through a test run
and watch where it breaks:
function max(xs) {let best = 0;for (const x of xs) {if (x > best) best = x;}return best;}
The bug survived three tests because they were all ordinary inputs. Bugs live at the edges: the empty list, the single item, the negative number.
An edge case is an unusual input at the boundary of what a function
handles. Good ones to try: empty ([], '', 0), one item,
zero, negative numbers, huge numbers, duplicates, and the
answer sitting in the first or last position.
Two programs that should agree
Every test so far needed you to know the right answer. What if you don’t? What is the max of a random 500-number list? Working it out by hand is the very thing you can’t do.
Two people add up the same long receipt separately. If they get different totals, at least one of them made a mistake, and neither had to know the right total in advance. Agreement doesn’t prove both are right, but every disagreement is a bug caught.
So write a second version that is slow but obviously correct. A value is the max if nothing in the list beats it:
function maxSlow(xs) {
for (const x of xs) {
let beaten = false;
for (const y of xs) {
if (y > x) beaten = true;
}
if (!beaten) return x;
}
return -Infinity;
}
Then feed both versions thousands of random lists, and stop at the first
disagreement. Run this against the buggy max from above:
function max(xs) {
let best = 0;
for (const x of xs) {
if (x > best) best = x;
}
return best;
}
function maxSlow(xs) {
for (const x of xs) {
let beaten = false;
for (const y of xs) {
if (y > x) beaten = true;
}
if (!beaten) return x;
}
return -Infinity;
}
// rand(k): a random whole number
// from 0 to k - 1
function rand(k) {
return Math.floor(Math.random() * k);
}
function randomList() {
const xs = [];
const n = rand(5);
for (let i = 0; i < n; i++) {
xs.push(rand(19) - 9);
}
return xs;
}
let t = 0;
while (t < 1000) {
const xs = randomList();
const a = max(xs);
const b = maxSlow(xs);
if (a !== b) {
console.log('disagree on', xs);
console.log('max', a, 'slow', b);
break;
}
t++;
}
console.log(`${t} lists agreed`);
// Fix max, then run it again.Differential testing runs two implementations of the same thing on the same inputs and checks that they agree. You don’t need expected answers, so the inputs can be random and endless. Every disagreement is a bug in one of them.
Running tests outside the book
In your own projects, you don’t write test yourself. Node comes with one,
and you run every test file with node --test[1]:
import test from 'node:test';
import assert from 'node:assert';
test('max picks the largest', () => {
assert.strictEqual(max([3, 7, 2]), 7);
});
assert.strictEqual is Node’s toBe[2]. The engine code in
this book’s repository uses a popular library called Vitest instead, whose
expect(x).toBe(y) reads just like the one you’ve been using.
Differential testing is the main way this book tests itself. In this repository, the benchmark’s test file runs every particle layout, from plain objects to struct of arrays, and checks they all compute the same result. Later you’ll have a tree-walking interpreter, a bytecode VM and Viscosity’s compiled output, and they must all print the same thing for the same program. Real compiler testers do this at scale: Csmith generates random C programs to find compiler bugs, using differential testing to decide what counts as wrong[3]. The other approach, a huge suite of known right answers, also exists: Test262 is the official conformance suite for JavaScript itself[4].
Your turn
Write findMismatch(f, g, inputs). f and g are two functions that should do the same thing. Call both on each input in order, and return the first input where their results differ (by !==). If they agree on every input, return undefined.
function findMismatch(f, g, inputs) {
// your code here
}
const abs1 = (n) => Math.abs(n);
const abs2 = (n) => n; // forgot < 0
console.log(
findMismatch(abs1, abs2, [3, 0, -2]));Write check(got, want). If got === want, return true. Otherwise throw an Error whose message is exactly expected WANT, got GOT, for example expected 2, got 1.
function check(got, want) {
// your code here
}
console.log(check(1 + 1, 2));
check(1, 2); // should throwWhat you learned
- A test runs code on chosen inputs and checks the results. Tests run every time, so they catch what memory forgets.
- An assertion checks one fact and throws if it’s false.
toBeis===,toEqualcompares contents,toBeCloseToallows tiny decimal differences. - Edge cases (empty, one, zero, negative, huge, duplicates) are where bugs hide. Keep every old test.
- Differential testing checks that two implementations agree on many inputs, with no expected answers needed. It is how you’ll test the interpreters and compilers in this book.
Check your understanding
0/6- 1.
A test calls
expect(add(2, 2)).toBe(5)andaddcorrectly returns4. What happens? - 2.
Which assertion passes?
- 3.
You are testing
average(xs). Which input is the best edge case to add to[2, 4, 6]? - 4.
What is the big advantage of differential testing?
- 5.
A fast
sumTo(n)uses the formulan * (n + 1) / 2. What is a good reference to test it against? - 6.
How will this book check that Viscosity’s optimized output is correct?
References
- [1] Node.js contributors. Test runner (node:test). Node.js documentation. nodejs.org/api/test.html (checked 2026-10-31)
- [2] Node.js contributors. Assert (node:assert). Node.js documentation. nodejs.org/api/assert.html (checked 2026-10-31)
- [3] Xuejun Yang, Yang Chen, Eric Eide, John Regehr. Csmith: a random generator of C programs. github.com/csmith-project/csmith (README). github.com/csmith-project/csmith (checked 2026-10-31)
- [4] Ecma TC39 and Test262 contributors. Test262: ECMAScript Test Suite. github.com/tc39/test262. github.com/tc39/test262 (checked 2026-10-31)