Testing and debugging · Lesson 66 of 77 · concept
What a test actually is
About 55 minutes.
By the end of this lesson you can
- Say in one sentence what a test is, and what makes one worth keeping.
- Write an assertion by hand that throws when the answer is not the expected one.
- Explain why === says two identical-looking arrays are different, and compare plain data safely instead.
- Watch a new test fail on purpose before trusting it.
You already test your code - you just throw the test away
An analogy
Every time you have written a function and then printed the result to see whether it looked right, you ran a test. You built an input, you called the function, you looked at the answer, and you decided. The only problem is what happened next: you deleted the print, you moved on, and the check you just made now exists nowhere except in your memory of the last thirty seconds.
A test is that same check, written down as code, so a machine can redo it for you - tomorrow, after you rename something, after someone else edits the file, a thousand times, in under a second, without ever getting bored or skipping one. Nothing about it is clever. The whole idea is that a judgement you made once by eye becomes a judgement the computer makes for you every time.
That is also the honest answer to 'why bother'. You do not write tests to prove you are a good programmer. You write them so that the next change you make cannot quietly break something you fixed last week without you being the one who finds out.
An assertion is an if statement that throws
Explanation
The smallest useful piece of a test is an assertion: a claim about a value that the program checks for itself. You already have everything you need to write one. Take the value your code produced, compare it with the value you expected, and if they differ, throw an error - the same throw you used when deciding what your own code should do about bad input.
That is genuinely all an assertion is: 'if actual is not expected, throw'. There is no test-specific machinery hiding in the language. Frameworks add reporting, file discovery and nicer messages, and you will meet one at the end of this module, but underneath every single one of them there is an if and a throw.
Throwing rather than printing is the deliberate part. A printed complaint is a line of text that scrolls past and that nothing acts on. A thrown error stops the test it is inside, and something above it can catch that error, count it, and report a failure. 'Failed' has to be a thing that happens, not a thing you are expected to notice.
The message is the part you will actually read
Why it matters
When a test fails - three months later, on a machine that is not yours, in a file you have never opened - the only thing you get is the message. 'Assertion failed' costs you the next twenty minutes. 'basket total: expected 6 but got 0' hands you the bug.
So a good assertion takes three things, not two: what happened, what you expected, and a label saying which claim this was. Two of those are values; the third is a sentence in English that tells you which of your fifty tests just spoke. Write that message once, properly, and every test you build on top of it gets a readable failure for free.
Why === says two identical arrays are different
A common mistake
Comparing numbers and text with === does what you expect. Comparing arrays and objects does not, and this trips up everyone writing their first test. Two arrays with the same contents are still two different arrays: === on objects and arrays asks 'are these the same one', not 'do these look the same'. It compares identity, not contents. So a comparison of two separately built arrays holding 1, 2 and 3 is false, and always will be.
The cheap fix, and the one this module uses, is to compare the JSON text of both values instead. JSON.stringify turns a value into a string, and two structurally identical values produce the same string. It is a real technique rather than a hack, and it covers plain data: numbers, text, true and false, null, and arrays and objects built out of those.
It also has edges you must know before you lean on it. Key order counts, because the text for a name-then-id object is not the text for an id-then-name object even though both describe the same thing. A property whose value is undefined disappears entirely. NaN comes out as null. A value that contains itself throws. A grown-up test framework walks the two values property by property instead, which is one of the things you are buying when you adopt one - so use the JSON trick knowing exactly what it costs.
A test you have never seen fail is not yet a test
A mental model
A test that cannot fail is worse than no test at all, because it is a claim of safety you have not earned. It is easy to write one by accident: a check with no else branch prints nothing and passes in silence, a comparison written the wrong way round, a test that calls a function you renamed and never notices.
The habit that catches all of those costs five seconds. After you write a test, break the code on purpose - return the wrong number, delete a line - and check that the test goes red. Then put the code back and check that it goes green again. Now you know the wire is connected at both ends.
The same trick turns a bug report into a test. Write the test that reproduces the bug FIRST, watch it fail, and only then fix the code. If it passes before you have changed anything, the test is not testing what you think it is, and the bug is still out there waiting for you.
What to hold on to
Recap
A test is a check you used to do by eye, written down so a machine repeats it. An assertion is an if plus a throw. The message needs a label, the expectation and the actual value, because the message is all you get later. Use === for numbers and text; compare JSON text for arrays and plain objects, and remember that key order and undefined properties are the price of doing so. And never trust a test you have not watched fail.
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.
A whole test, in eleven lines
This is a complete testing setup: one function that makes a claim, and one function under test. Read the second call carefully - it is deliberately wrong, so you can see what a failure actually looks like from the outside.
1function assertEquals(actual, expected, label) {2 if (actual === expected) {3 console.log("PASS", label);4 return true;5 }6 throw new Error(label + ": expected " + expected + " but got " + actual);7}8 9function add(a, b) {10 return a + b;11}12 13assertEquals(add(2, 2), 4, "add adds its two arguments");14 15try {16 assertEquals(add(2, 2), 5, "a claim that is simply wrong");17} catch (error) {18 console.log("FAIL", error.message);19}- line 2
The entire idea of testing is on this line: compare what happened with what you expected.
- line 6
The failure path throws rather than returning false, so a caller cannot ignore it by accident. Notice all three pieces in the message: the label, the expectation, the actual value.
- line 13
Arrange the inputs, act by calling add, assert on the answer - all on one line here, and split into three in the next lesson.
- line 16
The expectation is wrong on purpose. In a real suite nothing would wrap this; the runner you build in lesson 2 is what catches the error and turns it into a report.
What it prints
PASS add adds its two argumentsFAIL a claim that is simply wrong: expected 5 but got 4
Two arrays that look the same and are not
The single most common surprise in a first test. Watch what === answers, then what comparing the JSON text answers, then watch the JSON trick refuse two objects that differ only in the order their properties were written.
1const left = [1, 2, 3];2const right = [1, 2, 3];3 4console.log(left === right);5console.log(left === left);6console.log(JSON.stringify(left) === JSON.stringify(right));7 8const ada = { name: "ada", id: 1 };9const alsoAda = { id: 1, name: "ada" };10 11console.log(JSON.stringify(ada));12console.log(JSON.stringify(alsoAda));13console.log(JSON.stringify(ada) === JSON.stringify(alsoAda));- line 4
false. Same contents, different arrays. === on an array asks whether both names refer to one and the same array.
- line 5
true, and this is the proof of what === is really asking: the only array equal to left is left itself.
- line 6
true. Both sides produce the text [1,2,3], and comparing two strings with === does compare contents.
- line 13
false - and this is the cost of the trick. The two objects hold identical information, but the text was written in a different order, so the strings differ.
What it prints
falsetruetrue{"name":"ada","id":1}{"id":1,"name":"ada"}false
The test that passes forever
Both claims below are checked. One of them is false. Count the lines of output and notice that nothing anywhere says so - this is what a broken safety net looks like, and it looks exactly like a working one.
1function isEven(value) {2 return value % 2 === 0;3}4 5function checkThatCannotFail(claim) {6 if (claim) {7 console.log("PASS");8 }9}10 11checkThatCannotFail(isEven(4));12checkThatCannotFail(isEven(3));13console.log("The run finished. One claim was false and nothing said so.");- line 6
There is no else. When the claim is false this function simply does nothing, and doing nothing is indistinguishable from success.
- line 12
isEven(3) is false, so this check produces no output at all. A missing PASS is not something a human reads reliably at line 400 of a log.
- line 13
The program ends normally, so an automated runner would call this a green build. Failure has to be something that HAPPENS - a thrown error - not something absent.
What it prints
PASSThe run finished. One claim was false and nothing said so.
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
You expected [1, 2] and your function returned a freshly built [1, 2]. Your test compares them with ===. What happens?
This assertEquals compares JSON text and reports both values in its message. The two arrays hold the same items in a different order. Work out the exact line printed before you run it.
function assertEquals(actual, expected, label) { if (JSON.stringify(actual) === JSON.stringify(expected)) { return true; } throw new Error(label + ": expected " + JSON.stringify(expected) + " but got " + JSON.stringify(actual));}try { assertEquals(["a", "b"], ["b", "a"], "letters");} catch (error) { console.log(error.message);}
You have just written a test and it passed first time. What is the useful next move?
Write it yourself
Exercise · core · 8 tests
Write the assertion the rest of this module runs on
Write assertEquals(actual, expected, label). When the two values match, return true. When they do not, throw an Error whose message is exactly the label, then ': expected ', then the expected value, then ' but got ', then the actual value - with both values written as JSON text so that arrays and objects are readable. Matching means 'the same JSON text', not ===, so that two separately built arrays holding the same items count as equal. The starter returns true for everything, which makes it an assertion that can never fail: your first job is to give it a way to say no.
assertEquals returns true when JSON.stringify(actual) and JSON.stringify(expected) are the same string, and otherwise throws an Error with the message label + ': expected ' + JSON.stringify(expected) + ' but got ' + JSON.stringify(actual). Array order and object key order both count as part of the value. It never prints anything.
Define assertEquals at the top level of your code — the tests call it by name.
Exercise · stretch · 6 tests
Assert that something fails
Half of what you built in the errors module is about failing deliberately, and none of it can be tested with assertEquals - you cannot compare a value that was never returned. Write assertThrows(body, label). Call body with no arguments. If it throws, return the error's message. If it does not throw, throw an Error whose message is the label, then ': expected the function to throw, but it returned ', then the returned value as JSON text. The starter calls the body but lets the error escape, which reports a crash exactly where you wanted a pass.
assertThrows(body, label) calls body() exactly once. If body throws, it returns error.message as a string. If body returns instead, it throws an Error with the message label + ': expected the function to throw, but it returned ' + JSON.stringify(returned). It works for errors thrown by JavaScript itself as well as ones thrown by the body.
Define assertThrows at the top level of your code — the tests call it by name.
Worth remembering
- What is an assertion, expressed as code you already know?
An if and a throw: if the actual value is not the expected one, throw an Error whose message carries the label, the expectation and the actual value.
- Why does === report two identically filled arrays as different?
For arrays and objects, === compares identity - whether both names refer to the same one - not contents. Two separately built arrays are never strictly equal.
- What does comparing JSON.stringify output get wrong?
Key order counts, undefined properties vanish, NaN becomes null, and a self-referencing value throws. Fine for plain data, wrong for anything else.
- What do you do immediately after writing a test that passes?
Break the code on purpose and confirm the test goes red, then restore it. Until you have seen it fail, you do not know it is checking anything.
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: Strict equality compares objects (including arrays) by identity: two distinct objects are never strictly equal, even when their contents match. Numbers and strings are compared by value.
Check there: Throwing stops execution of the current function and passes control to the first catch block in the call stack.
Check there: Properties whose value is undefined are omitted from the output, NaN is serialised as null, and a circular structure throws a TypeError.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown