completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Unit testing

Learn unit testing with node:test, Vitest, Jest, clear assertions, Arrange-Act-Assert, and TDD by building a tiny JavaScript test runner.

By the end, you can
  • 01
    Write tests that say what code should doPick a small piece of code to test, give each test a name that reads like a sentence, and try normal, empty, and wrong input.
  • 02
    Read assertions and test runnersUse strict equality, deep equality, and error checks, and read tests written for node:test, Vitest, and Jest.
  • 03
    Write the test firstBuild a small feature in rounds: a failing test (red), the smallest fix (green), then a tidy-up (refactor).

Why code gets tests

A test is a small program that runs your code and checks the answer. It says what the code should do. Then it checks that the code really does it. A machine runs the tests for you, as often as you like.

Tests help you in four ways:

  • Before you publish a change, they tell you the important parts still work.
  • After you fix a bug, a test for that bug stops it from coming back. A bug that comes back is called a regression.
  • They show other people what the code is supposed to do.
  • They let you rewrite code more safely. Changing how code is written, without changing what it does, is called refactoring.

A unit is a small piece of code with one clear job that you can test quickly. It may be one function. It may also be a small file of code (a module) or one method of a class. The goal is quick feedback about one job.

A cart total worth testingPop out in the code editor (opens in a new tab)JavaScript
function total(cart) {
  return cart.reduce((sum, price) => sum + price, 0);
}
console.log(total([3, 5]));

The total function adds the prices in the cart. It starts from zero. The last line passes two prices, 3 and 5, and prints 8.

A test for this function should check the answer, 8. It should not care whether the function uses a loop or reduce inside. Sometimes two functions work together to give callers one result. Then you can test the two of them together as one unit.

Definition

Unit testing means writing small automated tests for one unit at a time. Each test:

  • has a name that says what the unit should do,
  • gets the input ready,
  • calls the unit once,
  • checks the result.

Good unit tests run fast. They give the same result every time they run.

Real-life analogyA checklist before a plane leaves the gate

Pilots don't trust their memory for safety checks. Before the plane moves, they read a short checklist, one item at a time. Unit tests are that checklist for your code. They are quick, they run the same way every time, and each one checks one thing.

In real life: Each checklist item names one thing to check
In JavaScript: Each test name says one thing the code should do
In real life: The crew checks the controls before takeoff
In JavaScript: The tests check the code before you publish it
In real life: A failed item stops the plane from leaving
In JavaScript: A failing test stops a risky change

A project has other kinds of tests too. Each row in this table is one kind. Read across to see how many of them a project usually has and what they check.

Kinds of tests, from small to large
Kind of testHow many?What does it check?
Unit testsMany.One small piece of code, like one function. They are fast and point to the exact job that broke.
Integration testsSome.A few real parts working together, like code that reads data and code that saves it.
UI and end-to-end testsFew.A whole task, done the way a user does it in a browser. They are slower and break more easily.
The testing trophyA balance.A different way to plan: put tests where they catch the most bugs for the least work, not only in one row.
A pyramid of three layers. The top layer, UI and end to end tests, is small: few tests, the slowest. The middle layer, integration tests: some tests, medium speed. The bottom layer, unit tests, is the widest: many tests, very fast.UI / end to endfew, slowestintegrationsome, mediumunitmany, very fast
Most tests are small, fast unit tests at the bottom. Higher up there are fewer tests, and each one checks a bigger piece and runs slower.

The next lessons on the Testing & debugging path cover fake helpers for tests (mocks and fakes), testing code that waits, testing pages in a real browser, and advanced debugging. If you want a refresher first, see pure functions, try/catch, error strategies, browser debugging, and npm scripts.

Build a tiny test runner

STEP THROUGH

A test runner is a program that runs your tests and tells you which ones passed. Real runners are big, but the main loop is small:

  1. Collect the tests. Each test is a function with a name.
  2. Run each test function.
  3. Catch any test that fails.
  4. Print a result for every test.

You'll build a tiny runner first, so you can see each of these parts. Then the real tools will feel familiar.

The tiny runner has these functions:

  • test(name, fn) stores a test. It does not run it yet.
  • equal, deepEqual, and throws are assertions. An assertion is a check. If the result is wrong, it throws an error.
  • run() runs every stored test. It catches the errors, so one failing test doesn't stop the others. It reports a pass or a fail for each test.

First, a very small example of "store now, run later":

Store a test now, run it laterPop out in the code editor (opens in a new tab)JavaScript
const tests = [];
tests.push(() => console.log("checked cart"));
console.log(tests.length);
tests[0]();

Line 2 puts a function into the tests array. It doesn't call the function. So line 3 prints 1, and nothing else is printed yet. Line 4 calls the stored function, and it prints checked cart. A runner does the same thing, and also reports which tests failed.

Tiny runner lab: one test fails
Step 0 of 7Ready
Your turn: follow the blue line

Step through a tiny test runner. It stores the tests first. Then it runs each one and prints PASS or FAIL for it.

Running in
  1. script
Next: line 2
function createTinyRunner() {  function test(name, fn) {    cases.push({ name, fn });  }  function equal(actual, expected, message) {    if (!Object.is(actual, expected)) {      throw new Error(message ?? `expected ${JSON.stringify(actual)} to equal ${JSON.stringify(expected)}`);    }  }  function deepEqual(actual, expected, message) {    if (JSON.stringify(actual) !== JSON.stringify(expected)) {      throw new Error(message ?? `expected ${JSON.stringify(actual)} to deepEqual ${JSON.stringify(expected)}`);    }  }  function throws(fn, messageIncludes) {    try {      fn();    } catch (error) {      if (messageIncludes && !String(error.message).includes(messageIncludes)) {        throw new Error(`expected error message to include ${messageIncludes}`);      }      return;    }    throw new Error("expected function to throw");  }  function run() {    return cases.map(({ name, fn }) => {      try {        fn();        return { name, status: "pass" };      } catch (error) {        return { name, status: "fail", message: error.message };      }    });  }  return { test, equal, deepEqual, throws, run };} function cartTotal(items, code = "") {  let total = 0;  for (const item of items) {    if (item.price < 0) throw new Error("negative prices are invalid");    total += item.price;  }  if (code === "SAVE10") return total; // bug: discount ignored  return total;}function discountLabels(items) {  return items.flatMap((item) => item.discounts ?? []);}function formatResults(results) {  return results.map((result) => result.status === "pass"    ? `PASS ${result.name}`    : `FAIL ${result.name}: ${result.message}`).join("\n");} const { test, equal, deepEqual, throws, run } = createTinyRunner();test("totals item prices", () => {  equal(cartTotal([{ name: "Book", price: 20 }, { name: "Pen", price: 5 }]), 25);});test("treats an empty cart as 0", () => {  equal(cartTotal([]), 0);});test("collects discount labels", () => {  deepEqual(discountLabels([{ name: "Book", price: 20, discounts: ["SAVE10"] }]), ["SAVE10"]);});test("rejects negative prices", () => {  throws(() => cartTotal([{ name: "Bug", price: -1 }]), "negative");});test("applies SAVE10 discount", () => {  equal(cartTotal([{ name: "Book", price: 20 }, { name: "Pen", price: 5 }], "SAVE10"), 15, "SAVE10 removes 10 dollars");}); console.log(formatResults(run()));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the cart code

This code runs in your browser. With the bug, one test fails. With the fixed discount, all five tests pass.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

The code starts with one bug: the cart ignores the discount code SAVE10. So one test fails. Its line says which job broke (applies SAVE10 discount) and gives the message SAVE10 removes 10 dollars. Choose Fixed discount and step through again. Now all five tests pass.

A box called cases holds 5 stored tests. An arrow labelled run() leads to a console window with five lines: four PASS lines and one FAIL line, FAIL applies SAVE10 discount.5 testscasesstored by test()run()consolePASS totals item pricesPASS treats an empty cart as 0PASS collects discount labelsPASS rejects negative pricesFAIL applies SAVE10 discount
test() only stores each test in cases. run() calls them one by one, catches any failure, and prints one line per test.

This runner is only for learning. Its deepEqual turns both values into JSON text and compares the text. That works for simple data. But two objects with the same properties in a different order give different text, so it would call them different. For real projects, use a real test runner.

Assertions say what must be true

TRY IT

An assertion is the part of a test that can fail. It compares the result your code gave (the actual value) with the result you want (the expected value). Different values need different checks:

  • Strict equality checks that two values are exactly the same. Use it for numbers, strings, and true/false. For objects, it checks that both are the very same object.
  • Deep equality checks arrays and objects part by part. Two separate objects pass if all their parts match.
  • Error checks pass only when the code throws an error. Use them when bad input should throw.

Check one job in each test. Then a failing test points to one job.

Same value, different objectsPop out in the code editor (opens in a new tab)JavaScript
const cart = { total: 8 };
const copy = { total: 8 };
console.log(Object.is(cart, copy));
console.log(cart.total === copy.total);

cart and copy are two separate objects, so line 3 prints false. Object.is checks whether two things are exactly the same value, and two separate objects are never the same. Their total properties are both 8, so line 4 prints true. In a test, use assert.deepEqual when the whole object matters. Compare just one property when only that value matters.

The table lists the common assertions. Read each row as: what you want to check, how the code looks, and when to use it.

Which assertion to use
What do you want to check?How does the code look?When do you use it?
Is it exactly the same value?Object.is(a, b) or assert.equal from node:assert/strict.For numbers, strings, true/false, null, and "is this the very same object?".
Do all the parts match?assert.deepEqual or expect(value).toEqual(...).For arrays and objects, when the parts matter and not which object it is.
Does it throw an error?assert.throws(() => fn(), /message/).For code that should throw an error on wrong input.
Does a promise fail?await assert.rejects(promiseFn, /message/).For promises that should fail (reject). The async testing lesson covers this.
Pick an assertion
Assertion examplePop out in the code editor (opens in a new tab)JavaScript
function equal(actual, expected) {  if (!Object.is(actual, expected)) {    throw new Error(`expected ${JSON.stringify(actual)} to strictly equal ${JSON.stringify(expected)}`);  }}function check(name, fn) {  try { fn(); return "PASS " + name; }  catch (error) { return "FAIL " + name + ": " + error.message; }}console.log(check("0 is not false", () => equal(0, false)));
Outputstrict
failureFAIL 0 is not false: expected 0 to strictly equal false
Try it yourself

A strict check doesn't convert values first. So it catches a mix-up like 0 and false, and this test fails.

Each button shows a small assertion and what it prints. For promises, Node has assert.rejects, shown further down.

A good test also tries unusual input, like an empty cart. Input at the edges like this is called an edge case.

An empty cart totals 0Pop out in the code editor (opens in a new tab)JavaScript
function total(cart) {
  return cart.reduce((sum, price) => sum + price, 0);
}
console.log(total([]));

The last line passes an empty array and prints 0. That works because reduce starts from the 0 at the end of line 2. Without that starting value, reduce on an empty array throws an error. So the empty cart deserves its own test.

A negative price throws an errorPop out in the code editor (opens in a new tab)JavaScript
function total(cart) {
  if (cart.some((price) => price < 0)) throw new RangeError("negative price");
  return cart.reduce((sum, price) => sum + price, 0);
}
try { total([3, -1]); }
catch (error) { console.log(error instanceof RangeError, error.message); }

Line 2 checks for a price below zero. The price -1 is below zero, so it throws a RangeError. The catch on line 6 prints true negative price. In a Node test you would write assert.throws(() => total([3, -1]), RangeError), or check the message.

Notice the () => in front. assert.throws needs a function that it can call itself. If you write total([3, -1]) directly, the error is thrown before assert.throws even starts.

Use strict checks in tests. Loose equality, ==, converts values before it compares them. For example, 0 == false is true. A strict check tells 0 and false apart. These are strict:

  • Object.is(actual, expected)
  • assert.equal from node:assert/strict
  • expect(actual).toBe(expected) in Vitest and Jest

An error check should also look at the error's type or message. Otherwise, a test that throws for some other reason would still pass.

Some code returns a promise: a value that arrives later. If the promise fails (it rejects), you check that with assert.rejects. This code only runs in Node, so it has no Run button:

Check that a promise rejectsJavaScript
import assert from "node:assert/strict";

await assert.rejects(
  () => loadUser("offline"),
  /offline/,
  "offline users should reject with a clear message",
);

It works like assert.throws, but you must write await in front. Then the test waits for the promise to finish. The lesson on testing async code covers this in more detail.

Three steps in every test: arrange, act, assert

SORT IT

Most tests have the same three steps. Keeping them in this order makes a test easy to read:

  1. Arrange: get the input and the expected answer ready.
  2. Act: call the code you are testing, once.
  3. Assert: check that the actual answer matches the expected answer.

Some teams say the same three steps as Given, When, Then: given this input, when I call the code, then I get this answer.

Arrange, Act, Assert with a cartPop out in the code editor (opens in a new tab)JavaScript
// Arrange: get the input and the expected answer ready.const cart = [{ price: 20 }, { price: 5 }];const expected = 25; // Act: run the code once.const actual = cart.reduce((total, item) => total + item.price, 0); // Assert: check the answer.console.log(actual === expected ? "PASS cart totals prices" : "FAIL cart totals prices");

Lines 2 and 3 arrange: a cart with two prices, and the expected answer, 25. Line 6 acts: it adds the prices once. Line 9 asserts: it compares the two numbers and prints PASS cart totals prices.

Three steps. 1, Arrange: a cart holds two items, price 20 and price 5, and the expected answer is 25. 2, Act: cart.reduce adds the prices once. 3, Assert: the check 25 === 25 is true, so the test prints PASS.1. Arrangeprice: 20price: 5cartexpected = 252. Actcart.reduceactual = 253. Assert25 === 25PASS
Arrange gets the cart and the expected answer ready. Act runs the code once and gets 25. Assert checks that 25 is the expected answer.

Every test also needs a name. A good name reads like a sentence about what the code should do, such as "returns 0 for an empty cart". Avoid names like "line 7 works". Line numbers move when you rewrite the code, so the name would soon be wrong.

Name the empty-cart testPop out in the code editor (opens in a new tab)JavaScript
function total(cart) { return cart.reduce((sum, price) => sum + price, 0); }
const cart = [];                 // Arrange
const actual = total(cart);     // Act
console.log(actual === 0 ? "PASS returns 0 for an empty cart" : "FAIL returns 0 for an empty cart"); // Assert

Line 2 arranges an empty cart. Line 3 calls total once. Line 4 checks the answer and prints PASS returns 0 for an empty cart. In a real test file you would write test("returns 0 for an empty cart", () => ...). Then the runner prints that sentence if the test fails.

Another job, such as "rejects negative prices", goes in its own test. Don't add a second call to this one.

Sort each step of a test
  • Make a cart with two prices
  • Call `cartTotal(cart, "SAVE10")` once
  • Check that the result is `15`
  • Make `[{ price: -1 }]` to test wrong input
  • Call the function inside `assert.throws`
  • Check that the error message has the word `negative`
Try it yourself
0 of 6 correct

Put each card under Arrange, Act, or Assert.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Test the edge cases before your users find them. Good ones to try:

  • empty input, like an empty array,
  • values right at a limit, like 0,
  • the same value twice,
  • wrong input, like a negative price,
  • the smallest and largest values your code should handle.

A unit test is most useful when it catches a real mistake without a browser or a database.

Real test runners: node:test, Vitest, and Jest

COMPARE

Real projects use a real test runner instead of a tiny one. Node.js has one built in, since Node 18. It comes in two parts:

  • node:test gives you test, describe, and it to write tests.
  • node:assert/strict gives you strict assertions, like assert.equal.

You run the tests from a terminal with node --test. The code in this section is for test files in Node, so it has no Run button.

A Node test file for formatPriceJavaScript
import { describe, it } from "node:test";
import assert from "node:assert/strict";

function formatPrice(cents) {
  if (!Number.isInteger(cents) || cents < 0) {
    throw new RangeError("cents must be a non-negative integer");
  }
  return "$" + (cents / 100).toFixed(2);
}

describe("formatPrice", () => {
  it("formats cents as dollars", () => {
    assert.equal(formatPrice(1299), "$12.99");
  });

  it("rejects negative cents", () => {
    assert.throws(() => formatPrice(-1), /non-negative/);
  });
});

describe on line 11 puts related tests into one group, named after the function. Each it inside it is one test. Line 13 checks a normal price. Line 17 checks that a negative price throws an error.

When you run this file, Node prints a report. The report uses a plain-text format called TAP. Each ok line is a test that passed, and the totals come at the end:

What node --test printed for this file (times removed)text
TAP version 13
# Subtest: formatPrice
    # Subtest: formats cents as dollars
    ok 1 - formats cents as dollars
    # Subtest: rejects negative cents
    ok 2 - rejects negative cents
    1..2
ok 1 - formatPrice
1..1
# tests 2
# suites 1
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0

This lesson's own tests really run the file above to get this report. The time each test took is removed, because it changes on every run. The pass and fail lines are real.

These commands are the ones you'll use most. Each line is one command:

  • node --test finds all your test files and runs them.
  • node --test tests/price.test.mjs runs just that one file.
  • --watch runs the tests again each time you save a file (Node 22).
  • --experimental-test-coverage shows which lines of your code the tests ran.
  • --test-only runs only the tests marked with .only.
Useful Node test commandsbash
node --test
node --test tests/price.test.mjs
node --test --watch
node --test --experimental-test-coverage
node --test --test-only

Node also lets you mark tests in a few special ways:

  • test.skip keeps a test in the file but doesn't run it.
  • test.todo notes a test you plan to write later.
  • test.only marks the tests to run when you use --test-only.
Groups, skip, todo, and onlyJavaScript
import { describe, it, test } from "node:test";
import assert from "node:assert/strict";

describe("slugify", () => {
  it("lowercases and hyphenates words", () => {
    assert.equal(slugify("Hello Tests"), "hello-tests");
  });

  test.skip("documents a skipped behavior", () => {});
  test.todo("supports non-Latin characters");
  test.only("run this with node --test-only", () => {});
});

This file expects a slugify function from your project. Remove .only when you're done. If you forget it, the other tests can stop running and you might not notice.

Two other runners are popular: Vitest and Jest. You install them from npm. The table compares all three. Read each row as: the runner, what it is, and which projects it suits.

Three JavaScript test runners
RunnerWhat is it?Which projects does it suit?
node:testIt comes with Node 18 and newer. You check values with node:assert/strict and run the tests with node --test.Small libraries, command-line tools, and projects that don't want to install a test tool. This website uses it.
VitestA test tool you install. It is made for Vite projects and checks values with expect, like Jest. It reruns tests quickly while you work.Projects built with Vite, and newer projects that use import and TypeScript.
JestAn older test tool you install. It has snapshots, mocks, a pretend web page (jsdom), and many plugins.React projects that already use Jest, and projects that rely on its snapshots or plugins.

Vitest is made for projects built with Vite, a popular build tool. It reads your files the same way Vite does. It checks values with expect, like Jest does. It can rerun tests quickly while you work. It can even run tests written inside the source file, using import.meta.vitest, after you turn on its includeSource setting.

Jest is older and has many plugins. It is known for:

  • snapshots: it saves a copy of some output and warns you when the output changes,
  • jsdom: a pretend web page inside Node, for testing page code,
  • tools that convert TypeScript and newer syntax before the tests run.
A Vitest test fileJavaScript
import { describe, expect, test } from "vitest";
import { slugify } from "./slugify";

describe("slugify", () => {
  test("lowercases and hyphenates words", () => {
    expect(slugify("Hello Tests")).toBe("hello-tests");
  });
});

// Vitest can also run tests written inside the source file:
if (import.meta.vitest) {
  const { expect, test } = import.meta.vitest;
  test("keeps numbers", () => {
    expect(slugify("Release 22")).toBe("release-22");
  });
}
A Jest test fileJavaScript
import { describe, expect, test } from "@jest/globals";
import { render } from "@testing-library/react";

test("button keeps its accessible name", () => {
  const { container } = render(<button>Save</button>);
  expect(container.firstChild).toMatchSnapshot();
});

describe("price math", () => {
  test("formats cents", () => {
    expect(formatPrice(1299)).toBe("$12.99");
  });
});

These two files need Vitest or Jest installed and set up in a project. The Jest file also needs a setup that understands JSX (the <button> tag), a pretend web page, and a formatPrice function from your project. Neither file runs in the browser editor.

This website's own tests use node:test. They live in the folder apps/client-ui/tests. Each file imports test from node:test and assertions from node:assert/strict. This lesson's own test file looks like this:

This lesson's test file follows the repository patternJavaScript
// apps/client-ui/tests/lesson-unit-testing.test.mjs
import assert from "node:assert/strict";
import { test } from "node:test";
import { article } from "../src/components/courses/javascript/unit-testing/unit-testing-lesson.ts";

test("the article definition is valid", () => {
  assert.equal(article.curriculum?.lesson, "unit-testing");
});

Write the test first: red, green, refactor

STEP THROUGH

In test-driven development (TDD), you write the test before the code. You work in small rounds. Each round has three steps:

  1. Red: write a test for the next thing the code should do. Run it and watch it fail.
  2. Green: make the smallest change to the code that makes the test pass.
  3. Refactor: tidy the code, like its names and repeated parts, while all tests keep passing.

Many test runners show failing tests in red and passing tests in green. That's where the names come from.

Three cards in a loop: red, write a failing test. Green, make it pass. Refactor, clean up the code. A curved arrow goes from refactor back to red, for the next behavior.write a failing testredmake it passgreenclean up the coderefactornext behavior
Each round adds one behavior. First the new test fails (red), then a small change makes it pass (green), then you tidy the code while every test keeps passing.
Red: the discount isn't written yetPop out in the code editor (opens in a new tab)JavaScript
function total(cart, code = "") {
  return cart.reduce((sum, price) => sum + price, 0);
}
const actual = total([20, 5], "SAVE10");
console.log(actual === 15 ? "PASS applies SAVE10" : "FAIL applies SAVE10");

Line 2 adds up the prices, but nothing uses the discount code yet. Line 4 calls the function with SAVE10. The answer is 25, not 15, so line 5 prints FAIL applies SAVE10.

This is the right kind of red. The test fails because the discount is missing, not because of a typing mistake. Next, add the discount to the function and run the same test again. Only tidy the code after it passes.

TDD lab: build slugify one test at a time
Step 0 of 5Ready
Your turn: follow the blue line

TDD repeats three steps: write one failing test, make it pass with the smallest change, then tidy the code while the tests keep passing.

Running in
  1. script
Next: line 1
  { name: "trims spaces and joins words", input: " Hello Tests ", expected: "hello-tests" },  { name: "removes punctuation", input: "Red, green, refactor!", expected: "red-green-refactor" },  { name: "keeps numbers", input: "Release 22", expected: "release-22" },];const activeTests = allTests.slice(0, 1); function slugify(title) {  return title;} function check({ name, input, expected }) {  const actual = slugify(input);  return actual === expected    ? `PASS ${name}`    : `FAIL ${name}: expected ${expected}, got ${actual}`;} console.log(activeTests.map(check).join("\n"));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Choose a TDD round

In green, the first test passes and a new punctuation test fails. In refactor, all three tests pass.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

The lab builds a slugify function. It turns a title into a short web address part, like hello-tests. Pick a round and step through:

  • red: there is one test. It fails, because slugify just returns the title.
  • green: the first test now passes. A new test, for removing punctuation, fails. That is the red step of the next round.
  • refactor: the punctuation test passes too, and a third test checks numbers. All three pass.

The lab squeezes two rounds into three views. In real TDD, you watch each new test fail before you write the code for it.

You don't have to use TDD every time

You can write tests before, during, or after the code. TDD works best when the next job is small and clear. If you are still trying out something new, write some quick try-out code first. Then turn what you learned into tests.

Testing in a real project

CHANGE A BUG

In a real project, unit tests help most when they check what the code gives back, not how it works inside. Good things to test:

  • reading a price from text,
  • making a slug from a title,
  • picking the right discount,
  • choosing the right error message for bad input,
  • what a function promises to return to its callers.

A test should not fail just because you renamed a variable inside a function.

In the playground below, each button puts in a different bug. Watch which test fails.

Add a bug and see which test fails
Cart total testsPop out in the code editor (opens in a new tab)JavaScript
function total(items, code = "") {  if (items.length === 0) return 0;  const sum = items.reduce((amount, item) => amount + item, 0);  if (code === "SAVE10") return sum; // bug  return sum;} const checks = [  { name: "empty cart is zero", actual: total([]), expected: 0 },  { name: "adds item prices", actual: total([20, 5]), expected: 25 },  { name: "SAVE10 removes ten dollars", actual: total([20, 5], "SAVE10"), expected: 15 },]; console.log(checks.map(({ name, actual, expected }) =>  actual === expected    ? `PASS ${name}`    : `FAIL ${name}: expected ${expected}, got ${actual}`).join("\n"));
Test resultsdiscount bug
passPASS empty cart is zero
passPASS adds item prices
failFAIL SAVE10 removes ten dollars: expected 15, got 25
Try it yourself

Only the discount test fails. Its name tells you which job broke, without reading the code first.

Each button changes one line of the code. The test results tell you which job changed, before you read the code.

Write tests for the jobs you would worry about while rewriting the code. Say you change a loop into reduce, and a test breaks. First check whether the answer changed. If it didn't, the test knows too much about how the code works inside. But if a change to the discount math breaks the "SAVE10 removes ten dollars" test, the test did its job.

A rewrite that keeps the same answerPop out in the code editor (opens in a new tab)JavaScript
function totalWithLoop(cart) {
  let sum = 0;
  for (const price of cart) sum += price;
  return sum;
}
function totalWithReduce(cart) { return cart.reduce((sum, price) => sum + price, 0); }
console.log(totalWithLoop([3, 5]) === totalWithReduce([3, 5]));

totalWithLoop uses a for loop. totalWithReduce uses reduce. Both add the prices from zero, so line 7 prints true.

In a real test, check the expected total, 8, instead of comparing the two versions. Both versions could have the same mistake, and they would still agree. When a bug slips past your tests, add a test for it, and keep that test.

Common misconceptions

  • “A unit is always one function.” A unit is a small piece of code with one clear job. Sometimes that is one function. Sometimes it is a small module that offers a few functions to the rest of the code.
  • “More mocks make better unit tests.” A mock is a fake stand-in for a real part, like a pretend server. A few mocks help you test one part on its own. Too many mocks test how the parts are wired together, not what the code gives back. The next lesson covers mocks and fakes.
  • “Snapshot tests are unit tests.” A snapshot test saves a copy of some output and warns you when it changes. It can catch a change in what a page shows. But a person still has to decide whether the change is right. Jest made snapshots popular. Use them with care.
  • “One huge test gives more confidence.” When a small test fails, its message points to one job. Put one job in each test when you can.
  • “If all tests pass, there are no bugs.” Tests only check the cases you wrote. When a bug slips through, add a test for it.

Unit tests, integration tests, and end-to-end tests are easy to mix up. In this table, each column is one kind of test. Read down a column to see what that kind checks.

Three kinds of tests, side by side
QuestionUnit testIntegration testEnd-to-end test
What does it check?Does one job work?Do a few real parts work together?Can a user finish a task in the browser?
How fast is it?Very fast.Medium.The slowest.
What does a failure tell you?Which job is broken.Where two parts don't fit together.Which step of the user's task is broken.
Where can I learn more?Right here, in this lesson.The mocks and fakes lesson.The UI and end-to-end testing lesson.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upWhich test fails?

Read the code. Before you run it, type the name of the test that fails.

Starter codePop out in the code editor (opens in a new tab)JavaScript
function total(items, code = "") {
  const sum = items.reduce((amount, item) => amount + item, 0);
  if (code === "SAVE10") return sum; // bug
  return sum;
}
const checks = [
  ["empty cart is zero", total([]), 0],
  ["adds item prices", total([20, 5]), 25],
  ["SAVE10 removes ten dollars", total([20, 5], "SAVE10"), 15],
];
console.log(checks.filter(([, actual, expected]) => actual !== expected).map(([name]) => name).join(", "));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeFind the missing edge case

    This slugify has a bug. What kind of input should a new test use to show it?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function slugify(title) {
      return title.toLowerCase().trim().replace(/\s+/g, "-");
    }
    const examples = ["Hello Tests", "", "Red, green!"];
    console.log(examples.map(slugify).join("|"));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeName the three steps of a test

      Type the three steps of a test, Arrange, Act, and Assert, in the right order.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const steps = ["Arrange", "Act", "Assert"];
      console.log(steps.join(" -> "));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeName the TDD steps

        Type the three TDD steps in order.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const loop = ["red", "green", "refactor"];
        console.log(loop.join(" -> "));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeRun one Node test file

          Write the command that runs only the file tests/price.test.mjs with Node's built-in test runner.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const command = ["node", "--test", "tests/price.test.mjs"];
          console.log(command.join(" "));

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            Lesson quiz · 8 questionsScore: first tries count
            1. Question 1 of 8Which sentence best describes a unit test?

              Choose an answer to see the explanation.

            2. Question 2 of 8Which test name is most helpful?

              Choose an answer to see the explanation.

            3. Question 3 of 8What does this tiny assertion check print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const actual = 0;
              const expected = false;
              console.log(Object.is(actual, expected) ? "pass" : "fail");

              Choose an answer to see the explanation.

            4. Question 4 of 8In Arrange-Act-Assert, which step is const actual = slugify(input)?

              Choose an answer to see the explanation.

            5. Question 5 of 8What does this edge-case snippet print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function slugify(title) {
                return title.toLowerCase().trim().replace(/\s+/g, "-");
              }
              console.log(slugify("Red, green!"));

              Choose an answer to see the explanation.

            6. Question 6 of 8Which Node command finds your test files and runs them?

              Choose an answer to see the explanation.

            7. Question 7 of 8Why do many Vite projects pick Vitest?

              Choose an answer to see the explanation.

            8. Question 8 of 8What is the TDD order?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const loop = ["red", "green", "refactor"];
              console.log(loop.join(" -> "));

              Choose an answer to see the explanation.

            Key takeaways

            • A unit test runs one small piece of code and checks its answer. It runs fast, so you can run it often.
            • A test runner stores your tests, runs each one, and reports which passed and which failed.
            • An assertion is a check that throws an error when the answer is wrong. Pick strict equality, deep equality, throws, or rejects to match what you check.
            • Arrange the input, act once, and assert the answer. Give each test a name that says what the code should do.
            • node:test comes with Node. Vitest suits Vite projects. Jest is older and has many plugins.
            • TDD repeats red, green, refactor: a failing test, the smallest change that passes, then a tidy-up.

            Remember the one-liner.
            A good unit test is a short program that says what your code should do, and checks it.

            Up next: mocks, spies, and fakes. They are stand-ins for things that are hard to test, like the clock, random numbers, saved data, and the network.

            CompleteFrontend Clear concepts. Working examples.