cf.completefrontendCode editorOpen lab
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
    Describe behavior as testsChoose a useful unit, name behavior in sentences, and cover normal, edge, and invalid inputs.
  • 02
    Read assertions and runnersUse strict equality, deep equality, throws, and the basic syntax of node:test, Vitest, and Jest.
  • 03
    Practice 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.

Definition

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.

Real-life analogyA checklist before a plane leaves the gate

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.

Where unit tests sit in the testing pyramid and trophy
LayerHow manyWhy it matters
UnitManySmall pure functions and focused modules. Fast, precise feedback for refactoring.
IntegrationSomeA few real pieces working together, such as a parser plus storage adapter.
UI / end to endFewUser flows in a browser. Valuable confidence, slower and more brittle than unit tests.
Testing trophy ideaBalancedAdd 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 THROUGH

Test 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.

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

Step through a real tiny test runner. It registers behaviors first, then executes each function and collects pass/fail messages.

Running in
  1. script
Next: line 2
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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()));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the cart implementation

The source is runnable in the browser. The runner reports one failing behavior until the discount branch is fixed.

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 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 IT

An 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.

Assertion choices
NeedCommon shapeUse it for
Strict equalityObject.is or assert.equal in node:assert/strictNumbers, strings, booleans, null, and exact references.
Deep equalityassert.deepEqual or expect(value).toEqual(...)Arrays and plain objects where structure matters more than reference identity.
Throwsassert.throws(() => fn(), /message/)Synchronous invalid input and error strategy tests.
Rejectsawait assert.rejects(promiseFn, /message/)Promise failures; this lesson previews it before the async testing lesson.
Assertion chooser
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

Strict assertions do not coerce. This catches mistakes such as treating 0 and false as the same result.

Each option runs a small, safe snippet and prints the exact result the assertion would report. Promise rejections use the same idea with 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.

Promise rejection previewJavaScript
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 IT

Arrange-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, Act, Assert in one tiny testPop out in the code editor (opens in a new tab)JavaScript
// 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.

Sort the test step
  • Create a cart with two prices
  • Call `cartTotal(cart, "SAVE10")` once
  • Check that the result is `15`
  • Prepare `[{ price: -1 }]` for an invalid-input case
  • Run the function inside `assert.throws`
  • Require the error message to include `negative`
Try it yourself
0 of 6 correct

Place each card in Arrange, Act, or Assert. The order helps failures stay readable.

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

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

COMPARE

Real 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.

node:test with node:assert/strictJavaScript
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/);
  });
});
Generated node:test output, durations omittedtext
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

That 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.

Useful Node 22 test commandsbash
node --test
node --test tests/price.test.mjs
node --test --watch
node --test --experimental-test-coverage
node --test-only
Suites, 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", () => {});
});
Choosing a JavaScript test runner
RunnerWhat it isWhere it fits
node:testBuilt 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.
VitestVite-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.
JestLong-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.

Vitest syntax, shown as tooling codeJavaScript
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");
  });
}
Jest syntax, shown as tooling codeJavaScript
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.

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");
});

Test-driven development: red, green, refactor

STEP THROUGH

Test-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 lab: grow a slugify feature
Step 0 of 5Ready
Your turn: follow the blue line

TDD is a loop: write one failing behavior, make it pass with the smallest change, then refactor while the tests stay green.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  { 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"));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose a TDD round

Each round adds behavior: first a failing test, then the smallest passing code, then a cleaner implementation.

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 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.

TDD is not a religion

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 BUG

In 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.

Introduce a bug and watch the test that 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"));
Suite outputdiscount 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 behavior fails. A good failure points at behavior, not a private line number.

Change one implementation branch at a time. The suite should tell you which behavior changed without reading the implementation first.

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.
Similar testing ideas that are easy to mix up
QuestionUnit testIntegration testEnd-to-end test
Main questionDoes one behavior work?Do a few real pieces cooperate?Can a user complete a flow?
Typical speedVery fastMediumSlowest
Best failureNames one broken behaviorNames a broken boundaryNames a broken user path
Next lesson linkYou are hereMocks and fakes help isolate boundariesUI and E2E testing covers browser flows

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict the failing behavior

Read the snippet and type the name of the behavior 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

    Which kind of input should you add to reveal the bug in this slugify implementation?

    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 test structure

      Type the three Arrange-Act-Assert steps in 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 · PracticeApply the TDD loop

        Type the TDD loop 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 exactly 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 8What is a useful definition of 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, where does const actual = slugify(input) belong?

              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 runs discovered test files?

              Choose an answer to see the explanation.

            7. Question 7 of 8Why would a Vite app often choose 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

            • 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.

            CompleteFrontend Clear concepts. Working examples.