Unit testing
Learn unit testing with node:test, Vitest, Jest, clear assertions, Arrange-Act-Assert, and TDD by building a tiny JavaScript test runner.
- 01Describe behavior as testsChoose a useful unit, name behavior in sentences, and cover normal, edge, and invalid inputs.
- 02Read assertions and runnersUse strict equality, deep equality, throws, and the basic syntax of node:test, Vitest, and Jest.
- 03Practice the TDD loopMove from red to green to refactor with a small feature while tests document the intended behavior.
Why professional code gets tests
Automated tests are executable expectations. They give you confidence before a release, protect regressions after a bug fix, document how code should behave, and make refactoring less scary because a machine checks the important promises every time.
A unit is the smallest useful behavior boundary you can test quickly. It may be one pure function, a module, or a class method with its dependencies kept small. The point is not isolation for its own sake; the point is fast feedback about one behavior.
Unit testing means writing small automated tests that name what one unit should do, arrange inputs, act once, and assert the result. Good unit tests are fast, deterministic, and written in the language of behavior.
Pilots do not rely on memory for routine safety checks. They use a short checklist that names the promises that matter before the plane moves. Unit tests are that checklist for code: quick, repeatable, and specific.
- In real life: Each checklist item names one safety promise
- In JavaScript: Each test name describes one behavior
- In real life: The crew checks important controls before takeoff
- In JavaScript: The suite checks edge cases before release
- In real life: A failed item stops the process early
- In JavaScript: A failing test blocks a risky change
Where the analogy stops: A checklist is followed by people and may rely on judgment. A unit test is executable code, so it only checks the behaviors you actually wrote down.
| Layer | How many | Why it matters |
|---|---|---|
| Unit | Many | Small pure functions and focused modules. Fast, precise feedback for refactoring. |
| Integration | Some | A few real pieces working together, such as a parser plus storage adapter. |
| UI / end to end | Few | User flows in a browser. Valuable confidence, slower and more brittle than unit tests. |
| Testing trophy idea | Balanced | Add tests where confidence is highest per maintenance cost, not only at one layer. |
This module stays in lanes. Here we focus on unit tests and TDD. The next lessons on the Testing & debugging path go deeper on mocks and fakes, async code, UI/end-to-end tests, and advanced debugging. For background, review pure functions, try/catch, error strategies, browser debugging, and npm scripts.
Build a tiny runner to understand the parts
STEP THROUGHTest frameworks look large, but the core loop is small: collect named functions, run each one, catch assertion failures, and print useful results. Building a tiny runner makes the moving parts visible before you meet real tools.
Read the source line by line. The test(name, fn) helper registers behavior. equal, deepEqual, and throws turn wrong behavior into errors. run() catches those errors so the suite can report every pass and fail instead of stopping at the first problem.
Step through a real tiny test runner. It registers behaviors first, then executes each function and collects pass/fail messages.
script
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()));The default suite has one failing behavior: the cart ignores SAVE10. That is a useful failure because it names the broken behavior and includes the message SAVE10 removes 10 dollars. Switch to the fixed implementation and replay the same suite to see all tests pass.
Assertions say what must be true
TRY ITAn assertion is the sentence that can fail. Use strict equality for primitives and exact references, deep equality for arrays and objects, and error assertions when invalid input should throw. Prefer one behavior per test so a failure points to one promise.
| Need | Common shape | Use it for |
|---|---|---|
| Strict equality | Object.is or assert.equal in node:assert/strict | Numbers, strings, booleans, null, and exact references. |
| Deep equality | assert.deepEqual or expect(value).toEqual(...) | Arrays and plain objects where structure matters more than reference identity. |
| Throws | assert.throws(() => fn(), /message/) | Synchronous invalid input and error strategy tests. |
| Rejects | await assert.rejects(promiseFn, /message/) | Promise failures; this lesson previews it before the async testing lesson. |
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)));FAIL 0 is not false: expected 0 to strictly equal falseStrict assertions do not coerce. This catches mistakes such as treating 0 and false as the same result.
assert.rejects, shown below as Node-only code.Strict comparisons avoid the trap of loose equality. 0 == false is true in JavaScript, but a test should normally use Object.is, assert.equal from node:assert/strict, or expect(value).toBe(expected). Deep equality is for structure; it should not hide which behavior matters. Error assertions should check the message or error class so any random throw does not pass.
import assert from "node:assert/strict";
await assert.rejects(
() => loadUser("offline"),
/offline/,
"offline users should reject with a clear message",
);Promise rejection tests use the same idea, but the assertion must be awaited. The async testing lesson will cover promises, timers, and flaky tests in depth.
Arrange, act, assert
SORT ITArrange-Act-Assert keeps tests readable. Arrange prepares inputs and expected values. Act calls the unit once. Assert compares the actual behavior to the expected behavior. Behavior-driven teams often say the same pattern as Given-When-Then.
// Arrange: prepare the input and expected behavior.const cart = [{ price: 20 }, { price: 5 }];const expected = 25; // Act: call the unit once.const actual = cart.reduce((total, item) => total + item.price, 0); // Assert: compare behavior, not implementation details.console.log(actual === expected ? "PASS cart totals prices" : "FAIL cart totals prices");Line 2 is arranged input. Line 6 is the action. Line 9 is the assertion. The test name should read like a sentence, such as “returns 0 for an empty cart.” Avoid names like “line 7 works” because implementation lines change during refactors.
- Create a cart with two prices
Call `cartTotal(cart, "SAVE10")` onceCheck that the result is `15`Prepare `[{ price: -1 }]` for an invalid-input caseRun the function inside `assert.throws`Require the error message to include `negative`
Place each card in Arrange, Act, or Assert. The order helps failures stay readable.
Choose edge cases before production users do: empty arrays, boundary values, duplicate values, invalid input, and the smallest or largest values your unit promises to handle. A unit test is most valuable when it catches a real mistake without requiring a browser or a database.
node:test, Vitest, and Jest
COMPAREReal projects use a runner instead of a tiny hand-written loop. Node 18+ and current Node 20/22 include node:test and node:assert. Run tests with node --test. Node 22 also supports watch mode with --watch, coverage with --experimental-test-coverage, and --test-only for tests marked .only.
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/);
});
});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 0That output is a trimmed TAP report generated from the file above during this lesson’s test suite. Durations are omitted so the lesson stays stable, but the pass/fail lines are real.
node --test
node --test tests/price.test.mjs
node --test --watch
node --test --experimental-test-coverage
node --test-onlyimport { 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", () => {});
});| Runner | What it is | Where it fits |
|---|---|---|
| node:test | Built into Node 18+ and current Node 20/22; pairs with node:assert/strict and runs with node --test. | Library code, CLI tools, repository tests, and projects that want no test dependency. |
| Vitest | Vite-native runner with a Jest-compatible expect API, fast watch feedback, browser-like environments, and in-source tests. | Vite apps, modern ESM/TypeScript projects, and teams that want Jest-style assertions with Vite transforms. |
| Jest | Long-running all-in-one framework known for snapshots, mocks, Babel/TypeScript transforms, and jsdom UI tests. | React codebases that already use Jest, snapshot workflows, and projects built around Jest plugins. |
Vitest is Vite-native and offers a Jest-compatible expect API, fast watch mode, and in-source tests with import.meta.vitest. Jest is a mature framework with a large ecosystem, snapshots, jsdom test environments for UI code, and a history of working through CommonJS, Babel, and TypeScript transforms.
import { describe, expect, test } from "vitest";
import { slugify } from "./slugify";
describe("slugify", () => {
test("lowercases and hyphenates words", () => {
expect(slugify("Hello Tests")).toBe("hello-tests");
});
});
// In-source testing is also possible with Vitest:
if (import.meta.vitest) {
const { expect, test } = import.meta.vitest;
test("keeps numbers", () => {
expect(slugify("Release 22")).toBe("release-22");
});
}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");
});
});This repository’s client tests use node:test. Look in apps/client-ui/tests and you will see files importing test from node:test and assertions from node:assert/strict. The lesson test you are reading follows the same pattern.
// 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");
});Test-driven development: red, green, refactor
STEP THROUGHTest-driven development turns tests into a design tool. Write a test that fails for the next behavior you want. Make it pass with the smallest change. Then refactor names, structure, and duplication while the tests stay green.
TDD is a loop: write one failing behavior, make it pass with the smallest change, then refactor while the tests stay green.
script
{ 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"));The slugify feature starts with a simple behavior: trim spaces and join words. The next test adds punctuation removal. The refactor keeps the behavior green while the implementation becomes a more general rule. The test list grows because each new example describes a behavior, not a private implementation step.
You can write tests before, during, or after implementation. TDD is useful when the next behavior is small and clear. If you are exploring an unknown API, spike first, then turn what you learned into tests.
Testing in a real project
CHANGE A BUGIn production code, unit tests help most when they describe stable behavior: parsing a price, formatting a slug, selecting a discount, choosing a validation error, or preserving a public function contract. They should not fail because you renamed a private variable.
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"));PASS empty cart is zeroPASS adds item pricesFAIL SAVE10 removes ten dollars: expected 15, got 25Only the discount behavior fails. A good failure points at behavior, not a private line number.
Write tests for the behavior that would worry you during a refactor. If changing a loop to reduce breaks a test, the test probably knows too much about implementation. If changing discount math breaks the “SAVE10 removes ten dollars” test, the test protected the right promise.
Common misconceptions
- “A unit is always one function.” A unit is a useful behavior boundary. Sometimes that is a function; sometimes it is a module with a small public API.
- “More mocks mean better unit tests.” Mocks can isolate, but too many mocks test implementation wiring instead of behavior. The next lesson covers mocks and fakes.
- “Snapshots are unit tests by default.” Snapshots can catch UI output changes, but they need review and intent. Jest popularized them; use them carefully.
- “One huge test gives more confidence.” Smaller tests usually fail with a clearer message. Put one behavior in each test when you can.
- “A passing suite proves there are no bugs.” Tests only prove the cases you wrote. Keep adding tests when bugs escape.
| Question | Unit test | Integration test | End-to-end test |
|---|---|---|---|
| Main question | Does one behavior work? | Do a few real pieces cooperate? | Can a user complete a flow? |
| Typical speed | Very fast | Medium | Slowest |
| Best failure | Names one broken behavior | Names a broken boundary | Names a broken user path |
| Next lesson link | You are here | Mocks and fakes help isolate boundaries | UI and E2E testing covers browser flows |
Practice exercises
5 EXERCISESRead the snippet and type the name of the behavior that fails.
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(", "));The discount branch returns the full sum, so the failed behavior is SAVE10 removes ten dollars.
Which kind of input should you add to reveal the bug in this slugify implementation?
function slugify(title) {
return title.toLowerCase().trim().replace(/\s+/g, "-");
}
const examples = ["Hello Tests", "", "Red, green!"];
console.log(examples.map(slugify).join("|"));A punctuation input such as Red, green! reveals the missing behavior because the current code returns red,-green!.
Type the three Arrange-Act-Assert steps in order.
const steps = ["Arrange", "Act", "Assert"];
console.log(steps.join(" -> "));The order is Arrange -> Act -> Assert. Given -> When -> Then is the same mental shape.
Type the TDD loop in order.
const loop = ["red", "green", "refactor"];
console.log(loop.join(" -> "));The loop is red -> green -> refactor. Red proves the test can fail; green proves the feature; refactor improves design safely.
Write the command that runs exactly tests/price.test.mjs with Node’s built-in test runner.
const command = ["node", "--test", "tests/price.test.mjs"];
console.log(command.join(" "));Run node --test tests/price.test.mjs to execute one file with Node's built-in test runner.
Check your understanding
8 QUESTIONSQuestion 1 of 8What is a useful definition of a unit test?
Choose an answer to see the explanation.
Question 2 of 8Which test name is most helpful?
Choose an answer to see the explanation.
Question 3 of 8What does this tiny assertion check print?
Read the code, then predictconst actual = 0; const expected = false; console.log(Object.is(actual, expected) ? "pass" : "fail");Choose an answer to see the explanation.
Question 4 of 8In Arrange-Act-Assert, where does
const actual = slugify(input)belong?Choose an answer to see the explanation.
Question 5 of 8What does this edge-case snippet print?
Read the code, then predictfunction slugify(title) { return title.toLowerCase().trim().replace(/\s+/g, "-"); } console.log(slugify("Red, green!"));Choose an answer to see the explanation.
Question 6 of 8Which Node command runs discovered test files?
Choose an answer to see the explanation.
Question 7 of 8Why would a Vite app often choose Vitest?
Choose an answer to see the explanation.
Question 8 of 8What is the TDD order?
Read the code, then predictconst loop = ["red", "green", "refactor"]; console.log(loop.join(" -> "));Choose an answer to see the explanation.
Key takeaways
- Unit tests describe fast, focused behavior and make regressions visible.
- A runner collects named tests; assertions throw failures; reports turn failures into feedback.
- Use strict equality, deep equality, throws, and awaited rejects for the behavior being checked.
- Arrange inputs, act once, and assert one behavior with a useful test name.
- node:test is built into Node; Vitest and Jest add different ecosystems and ergonomics.
- TDD is red, green, refactor: one failing behavior, one passing change, one safe cleanup.
Remember the one-liner.
A good unit test is a small executable sentence about what your code should do.
Up next in this module: mocks, spies, and fakes show how to isolate time, randomness, storage, and network boundaries without turning every test into an implementation script.