Skip to content
wannacode

Tests with node:test

Problem

The discount that broke quietly

The shop's rule is: the discount is in percent, and on a purchase of €100 or more you add another 5%. You write the function and try it by hand:

main.js
// Discount in percent. From €100 up, add another 5%.
function applyDiscount(price, percent) {
if (price > 100) {
percent = percent + 5;
}
return price * (100 - percent) / 100;
}
console.log(applyDiscount(50, 10));
console.log(applyDiscount(200, 10));
Output
45
170

€50 with 10% off is €45, and €200 with 15% off is €170. It looks right, so you ship it. A week later a customer with a purchase of exactly €100 pays €90 instead of €85. The condition is price > 100, and it should be price >= 100.

Nobody tried exactly €100. And on every later change to the function, you have to try all the cases by hand again.

The idea

Code that checks code

Instead of reading the output with your own eyes, you write small functions that call your code and compare the result with the expected one. That is a test. You run all the tests with one click, after every change, and they tell you what broke.

Kind What it checks Example
Unit test one small piece, usually one function, apart from the rest applyDiscount(100, 10) returns 85
Integration how the pieces work together: with a database, a file, another service the order is saved to the database and read back
End-to-end the whole application, the way a person uses it the customer adds to the cart, pays and gets an email
In this lesson we write unit tests. Node.js has the built-in node:test module for them, and the checks come from node:assert: there is nothing to install.

Why it is worth it:

  • you see the bug right away, while you still remember what you changed, not when the customer complains;
  • the test shows how the code is used: which case returns what;
  • you can clean up the code boldly. If the tests are still green after the change, the behavior has not changed.
More precisely, if you are curious

There are other kinds of tests too: functional (does the program meet the requirements), performance (how many requests it can handle), security (are there any holes) and user interface (the buttons, the forms). All of them build on unit tests: there are the most of them, and they are the fastest and the cheapest to maintain.

Besides node:test, you will see Jest and Vitest: separate libraries, used especially for code in the browser. The ideas are the same: test, expect, beforeEach.

See it

The first test

applyDiscount is in the file discount.js, with export in front of function, so another file can import it. Its tests are in a separate file, discount.test.js:
discount.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyDiscount } from "./discount.js";
test("10% off €50", () => {
assert.equal(applyDiscount(50, 10), 45);
});
test("extra 5% from €100 up", () => {
assert.equal(applyDiscount(100, 10), 85);
});
  • test(name, function) registers a test. The name is text that says what is being checked.
  • assert.equal(actual, expected) compares the two values with ===. If they are different, the test fails. We import it from node:assert/strict: there every check is strict.

We run the two tests against the code from the first card:

Result
FAIL extra 5% from €100 up: 90 !== 85
PASS 10% off €50
Tests: 2, passed: 1, failed: 1

The first test passes. The second one fails and says exactly what happened: it expected 85, and the function returned 90. You fix the condition to price >= 100, run them again, and both are green:

Result
PASS 10% off €50
PASS extra 5% from €100 up
Tests: 2, passed: 2, failed: 0

Every test does three things: it prepares the values, calls the code and checks the result.

test("10% off €50", () => {
assert.equal(applyDiscount(50, 10), 45);
});
test("extra 5% from €100 up", () => {
assert.equal(applyDiscount(100, 10), 85);
});
Step by step, in words
  1. The file has two tests. node:test runs each one on its own and lights a lamp next to it.
  2. Test 1, prepare: a price of €50 and 10% off.
  3. Act: applyDiscount(50, 10) returns 45.
  4. Check: assert.equal compares the expected 45 with the returned 45. They are the same, so the test passes.
  5. Test 2: exactly €100 and 10%. We expect 85, because from €100 up there is another 5%.
  6. The condition in the function is price > 100, and 100 is not greater than 100. The function returns 90.
  7. Expected 85, returned 90: the test fails and says exactly that.
  8. You fix the condition to price >= 100 and run the tests again.
  9. Now 100 passes the condition and the function returns 85.
  10. Expected and returned match. Both tests are green.
More precisely, if you are curious
  • In the project the code and the tests are files side by side. Test files end in .test.js, so node --test can find them:
Layout
discount.js the code (with export)
discount.test.js the tests for it
  • You run them from the terminal. In the terminal, node --test prints each test with ✔ or ✖, how long it took, and the details of the failed ones:
Commands
node --test finds *.test.js and runs them
node --test discount.test.js this file only
node --test --watch runs them again on every change
  • Here, on the site, the files are written to a temporary folder and run with run() from node:test. The function below listens for the test:pass and test:fail events and prints the results sorted by name, so the output is the same on every run:
The runner
import { run } from "node:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
async function runTests(files) {
const dir = mkdtempSync(join(tmpdir(), "tests-"));
for (const [name, code] of Object.entries(files)) {
writeFileSync(join(dir, name), code);
}
const tests = Object.keys(files).filter((name) => name.endsWith(".test.js")).map((name) => join(dir, name));
const lines = [];
let failed = 0;
for await (const { type, data } of run({ files: tests })) {
if (data.details?.type !== "test") {
continue;
}
if (type === "test:pass") {
lines.push(`PASS ${data.name}`);
} else if (type === "test:fail") {
failed++;
const error = data.details.error.cause ?? data.details.error;
const message = error.operator === "strictEqual" ? `${error.actual} !== ${error.expected}` : error.message;
lines.push(`FAIL ${data.name}: ${message}`);
}
}
lines.sort();
for (const line of lines) {
console.log(line);
}
console.log(`Tests: ${lines.length}, passed: ${lines.length - failed}, failed: ${failed}`);
}

Try it

VAT with a decimal number

The price with VAT is the price times 1.2. Two tests:

What will the tests show?

export function withVat(price) {
return price * 1.2;
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { withVat } from "./vat.js";
test("coffee", () => {
assert.equal(withVat(10), 12);
});
test("tea", () => {
assert.equal(withVat(3), 3.6);
});
Answer

The idea

Yes or no, and an expected exception

Besides equality, you often check a condition, or that the code refuses. Here is Account from the module on exceptions, with a new method, canPay:

account.js
export class InsufficientFundsError extends Error {
constructor(shortfall) {
super(`Short by €${shortfall}`);
this.name = "InsufficientFundsError";
}
}
export class Account {
#balance;
constructor(balance) {
this.#balance = balance;
}
get balance() {
return this.#balance;
}
canPay(amount) {
return amount <= this.#balance;
}
withdraw(amount) {
if (amount <= 0) {
throw new RangeError("The amount must be positive");
}
if (amount > this.#balance) {
throw new InsufficientFundsError(amount - this.#balance);
}
this.#balance -= amount;
}
}
account.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { Account } from "./account.js";
test("can pay what is there", () => {
const account = new Account(100);
assert.ok(account.canPay(100));
assert.equal(account.canPay(101), false);
});
test("withdrawing more than there is", () => {
const account = new Account(100);
assert.throws(() => account.withdraw(130), {
name: "InsufficientFundsError",
message: "Short by €30",
});
assert.equal(account.balance, 100);
});
Output
PASS can pay what is there
PASS withdrawing more than there is
Tests: 2, passed: 2, failed: 0
  • assert.ok(condition) fails if the condition is not true. For "is not true" we compare with false.
  • assert.throws(function, what) fails if the function does not throw. You pass the code as an arrow function: assert.throws runs it itself and catches the error. The second argument describes the expected error: here its name and message.

The second test also checks something that is easy to forget: after the refusal the balance is the same.

The idea

Every test starts clean

Here the two tests share one account with €100:

account.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { Account } from "./account.js";
const account = new Account(100);
test("withdrawing €40 leaves €60", () => {
account.withdraw(40);
assert.equal(account.balance, 60);
});
test("withdrawing €70 leaves €30", () => {
account.withdraw(70);
assert.equal(account.balance, 30);
});
Output
FAIL withdrawing €70 leaves €30: Short by €10
PASS withdrawing €40 leaves €60
Tests: 2, passed: 1, failed: 1

account is created once for the whole file. node:test runs the tests in the order of the file, so the test for €40 is first. The second one has €60 left, and it withdraws €70. All it takes is for someone to move the tests around or run only the second one, and the result is different. A test that depends on another test is a trap. So the account is created again before each test, in beforeEach:

account.test.js
import { test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { Account } from "./account.js";
let account;
beforeEach(() => {
account = new Account(100);
});
test("withdrawing €40 leaves €60", () => {
account.withdraw(40);
assert.equal(account.balance, 60);
});
test("withdrawing €70 leaves €30", () => {
account.withdraw(70);
assert.equal(account.balance, 30);
});
Output
PASS withdrawing €40 leaves €60
PASS withdrawing €70 leaves €30
Tests: 2, passed: 2, failed: 0
beforeEach from node:test is called before each test in the file. The name of the test is text, so it can be a whole sentence.
More precisely, if you are curious
More things from node:test you will come across:
node:test
beforeEach / afterEach before / after each test (cleanup)
before / after once before / after all the tests
describe("...", () => { ... }) a group of related tests
test.skip("...", () => { ... }) the test is skipped for now
test.todo("...") a test that is not written yet
test("...", { timeout: 5000 }, () => { ... })
fails if it takes longer than 5 seconds
node --test --test-name-pattern="withdrawing"
only the tests with "withdrawing" in the name

The idea

One check, many cases

Four cases for the discount mean four almost identical tests. Since test is a plain function, you call it in a loop: one test for each case "price, percent, expected":

discount.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyDiscount } from "./discount.js";
const cases = [
[50, 10, 45],
[99, 0, 99],
[100, 10, 85],
[200, 20, 150],
];
for (const [price, percent, expected] of cases) {
test(`€${price} with ${percent}% off = €${expected}`, () => {
assert.equal(applyDiscount(price, percent), expected);
});
}
Output
PASS €100 with 10% off = €85
PASS €200 with 20% off = €150
PASS €50 with 10% off = €45
PASS €99 with 0% off = €99
Tests: 4, passed: 4, failed: 0

Each case is a separate test with a separate result, and its name is built from the values with a template string. A new case is a new row in the array, not a new test. The important cases are at the edges: exactly €100, €99, 0%.

Try it

Find the bug from the test

Delivery is €4, and free from €50 up. The test:

delivery.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { deliveryPrice } from "./delivery.js";
for (const [total, expected] of [[20, 4], [50, 0], [80, 0]]) {
test(`order of €${total}: delivery €${expected}`, () => {
assert.equal(deliveryPrice(total), expected);
});
}
Output
FAIL order of €50: delivery €0: 4 !== 0
PASS order of €20: delivery €4
PASS order of €80: delivery €0
Tests: 3, passed: 2, failed: 1

Which line of the code is the bug on?

Pick the line with the bug.

The idea

The test first

The checkout has to accept the promo code FALL10 for 10% off, whether it is in lowercase or uppercase. This time you write the tests first:

promo.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { promoPercent } from "./promo.js";
test("known code", () => {
assert.equal(promoPercent("FALL10"), 10);
});
test("code is case insensitive", () => {
assert.equal(promoPercent("fall10"), 10);
});
test("unknown code", () => {
assert.equal(promoPercent("NOPE"), 0);
});
  1. Red. For now the function returns 0. The tests fail, and that is good: it means they really check something.
export function promoPercent(code) {
return 0;
}
Output
FAIL code is case insensitive: 0 !== 10
FAIL known code: 0 !== 10
PASS unknown code
Tests: 3, passed: 1, failed: 2
  1. Green. You write the simplest code that makes them pass:
export function promoPercent(code) {
if (code.toUpperCase() === "FALL10") {
return 10;
}
return 0;
}
Output
PASS code is case insensitive
PASS known code
PASS unknown code
Tests: 3, passed: 3, failed: 0
  1. Clean up. Now you can move the codes into a Map, add new ones and clean up the function. The tests will tell you if you break something.

This is TDD (test-driven development). Writing the test first makes you decide what exactly the function has to do, before you think about how.

More precisely

Suites and fake objects

In node:test a group of related tests is describe: the tests inside are shown together, under the name of the group. The delivery tests, collected in a group:

delivery.test.js
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { deliveryPrice } from "./delivery.js";
describe("delivery", () => {
for (const [total, expected] of [[20, 4], [50, 0], [80, 0]]) {
test(`order of €${total}: delivery €${expected}`, () => {
assert.equal(deliveryPrice(total), expected);
});
}
});

A suite of several files is just a command that runs them together. Here are the three files of the checkout:

node --test discount.test.js account.test.js delivery.test.js
Output
PASS 10% off €50
PASS extra 5% from €100 up
PASS order of €20: delivery €4
PASS order of €50: delivery €0
PASS order of €80: delivery €0
PASS withdrawing €40 leaves €60
PASS withdrawing €70 leaves €30
Tests: 7, passed: 7, failed: 0
More precisely, if you are curious
  • Sometimes the code depends on something you do not want to call in a test: an exchange rate from a bank, a payment, an email. Then you pass in a fake object (a mock) that returns whatever you tell it to. node:test has mock.fn(): a function that returns whatever you tell it to and remembers how it was called:
converter.test.js
import { test, mock } from "node:test";
import assert from "node:assert/strict";
class Converter {
constructor(rates) {
this.rates = rates;
}
toUsd(eur) {
return eur * this.rates.eurTo("USD");
}
}
test("converts at today's rate", () => {
const rates = { eurTo: mock.fn(() => 1.1) };
const converter = new Converter(rates);
assert.ok(Math.abs(converter.toUsd(10) - 11) < 0.001);
assert.equal(rates.eurTo.mock.callCount(), 1);
assert.deepEqual(rates.eurTo.mock.calls[0].arguments, ["USD"]);
});
Output
PASS converts at today's rate
Tests: 1, passed: 1, failed: 0

The fake object is a plain object with an eurTo method. mock.callCount() tells you how many times it was called, and mock.calls[0].arguments with what.

  • More checks besides assert.equal, assert.ok and assert.throws:
Checks
assert.notEqual(a, b) are they different
assert.deepEqual(a, b) arrays and objects with the same contents
assert.notDeepEqual(a, b)
assert.ok(x) the condition is true
assert.match(text, /pattern/) the text matches a regular expression
assert.throws(() => ...) the code throws an exception
assert.rejects(async () => ...) the promise ends with an error
assert.fail("message") the test fails right away

Exercise

Write the tests

Solve these in your editor, with node:test (it comes with Node itself).

1. The discount calculator. Write discount.test.js for applyDiscount from this lesson. Cover these cases: €80 with 10% off is €72, €100 with 0% off is €95, €150 with 20% off is €112.50, €0 with 30% off is €0, and exactly €100 with 10% off is €85. Run them against the broken version with price > 100 too. Exactly the two cases with €100 should fail:

Result against the broken version
FAIL exactly €100 gets the extra 5%: 90 !== 85
FAIL €100 with 0% off = €95: 100 !== 95
PASS €0 with 30% off = €0
PASS €150 with 20% off = €112.5
PASS €80 with 10% off = €72
Tests: 5, passed: 3, failed: 2

2. The account. Write account.test.js for Account from the card "Yes or no, and an expected exception". Create the account with €100 in beforeEach. Check that:

  • withdrawing €30 leaves €70;
  • you can withdraw everything, and 0 is left;
  • withdrawing €150 throws InsufficientFundsError with the message "Short by €50", and the balance stays €100;
  • 0 and -20 throw RangeError (tests in a loop over the two amounts).

3. The bug in the average grade. The function looks fine:

grades.js
export function average(grades) {
let total = 0;
for (const grade of grades) {
total += grade;
}
return Math.trunc(total / grades.length);
}
Write two tests: the average of 4 and 6 is 5, and the average of 5 and 6 is 5.5. Here assert.equal is enough: 5.5 is stored exactly in binary. One of them fails:
Result
FAIL average with a fraction: 5 !== 5.5
PASS whole average
Tests: 2, passed: 1, failed: 1

Find the cause and fix the function, so that both pass.

Test

Check yourself

1. What does a unit test check?
2. assert.equal(applyDiscount(100, 10), 85) fails with "90 !== 85". What did the function return?
3. Why is the account created in beforeEach and not once at the top of the file?
4. How do you check that withdraw(130) throws InsufficientFundsError?
5. What is the order in TDD?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·