Topic 13.6
Testing Basics
In one line
A test is a small piece of code that runs your code with a known input and checks the result with an assertion; a test runner such as Vitest or Jest runs all your tests and reports which pass, so bugs are caught by a machine every time you change something.
Think of it like this
A teacher's answer key. A teacher doesn't re-solve every sum when marking homework; they keep an answer key: "question 3: 42". Marking is then fast, the same every time, and anyone can do it. A test is an answer key for your code: "add(2, 3) should give 5". Each time you change the code, the computer marks it against the key in a fraction of a second.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Assertion
- A check that throws an error when a result isn't what you expected.
- Test (test case)
- A named function that runs your code and makes assertions about the result.
- Unit test
- A test of one small piece, usually one function, on its own.
- Test runner
- A tool (Vitest, Jest,
node --test) that finds and runs tests and reports results. - Matcher
- A check method in a runner, like
toBe,toEqualortoThrow. - Arrange, Act, Assert
- Set up the input, run the code once, check the result.
- Edge case
- An unusual input at the limits, like an empty list, zero or the biggest value.
- Regression test
- A test that reproduces a fixed bug so it can't come back unnoticed.
- Deterministic
- Gives the same result every time it runs.
- TDD
- Test-driven development: write a failing test first, then the code that makes it pass.
Step by step
01Start with an assertion
The smallest possible test: run the code, compare with the answer key, throw if they differ. The message says both values, so a failure explains itself.
function assertEqual(actual, expected) {
if (actual !== expected) {
throw new Error("expected " + JSON.stringify(expected) + " but got " + JSON.stringify(actual));
}
}
assertEqual(slugify("Hello World"), "hello-world"); // silent when it passes02Give tests names and keep going after a failure
A test helper runs one function, catches any assertion error (Topic 13.2), and reports PASS or FAIL with the name. One failing test doesn't stop the others. This is, at heart, what every test runner does.
function test(name, fn) {
try {
fn();
console.log("PASS", name);
} catch (err) {
console.log("FAIL", name, "→", err.message);
}
}
test("joins words with dashes", () => {
assertEqual(slugify("Hello World"), "hello-world");
});03Write it the real way with Vitest
Install once with npm install -D vitest and add "test": "vitest" to the scripts in package.json. Test files end in .test.js and sit next to the code they test. Jest uses the same describe/it/expect style.
import { describe, it, expect } from "vitest";
import { slugify } from "./slugify.js";
describe("slugify", () => {
it("joins words with dashes", () => {
// Arrange + Act
const result = slugify("Hello World");
// Assert
expect(result).toBe("hello-world");
});
it("returns an empty string for blank input", () => {
expect(slugify(" ")).toBe("");
});
it("rejects non-strings", () => {
expect(() => slugify(42)).toThrow(TypeError);
});
});04Run the tests
npx vitest run runs every test once (plain npx vitest keeps watching and reruns on each save). A green report means every assertion held; a red one shows the test name, the expected value and the received value.
05Add a test for every bug you fix
When a bug is reported, first write a test that shows it (it fails), then fix the code (it passes). The cycle is the same one TDD uses for new features.
Try it yourself
- 1
Decide and fix
In the first example, change the regex to
/[^\p{L}\p{N}]+/gu(any letter or digit in any language). Predict the report, then run it. All four should pass. - 2
Write an edge-case test
In the first example, add a test that
slugify(" ")returns"". Predict whether it passes before running it. - 3
Fix the wrong test
In the second example, the last test expects the wrong error type. Fix the test (not the code) so all four pass.
- 4
Fix the forgotten await
In the third example, make the last test
asyncandawaitthe promise. Predict the new output: the test should now FAIL withUser 9 not found, and theUncaughtline should disappear.
Code & diagrams
The regex `[^a-z0-9]` treats `é` as punctuation. The test found it; whether `é` should be kept is a product decision the test now records.
Preview
Console
Nothing logged yet.Expected console output
PASS joins words with dashes
PASS removes punctuation
PASS trims spaces at both ends
FAIL keeps accented letters → expected "café-menu" but got "caf-menu"
3 passed, 1 failedThe last test is wrong on purpose: a failing test can mean a bug in the test itself. Read the message before changing the code. (`JSON.stringify` is a quick deep check; real runners' `toEqual` also handles key order, `undefined`, Maps and more.)
Preview
Console
Nothing logged yet.Expected console output
[1, 2] === [1, 2] is false
PASS adds an item to a new array
PASS rejects 0 with a RangeError
PASS rejects text with a TypeError
FAIL rejects 11 with a TypeError → expected TypeError but got RangeErrorThe third test finished before the promise settled, so it passed with nothing checked. In Vitest or Jest, `return` or `await` the promise, or use `await expect(promise).rejects.toThrow()`.
Preview
Console
Nothing logged yet.Expected console output
PASS finds user 1
PASS rejects an unknown id
PASS BUG: forgot to await, so it always passes
Uncaught (in promise) Error: User 9 not foundNo install needed in Node.js 20 and later. Vitest and Jest add watch mode, nicer reports, mocking and coverage.
// cart.test.js: run with node --test
import { test } from "node:test";
import assert from "node:assert/strict";
import { addItem, setQuantity } from "./cart.js";
test("adds an item to a new array", () => {
const cart = ["pen"];
assert.deepEqual(addItem(cart, "book"), ["pen", "book"]);
assert.deepEqual(cart, ["pen"]);
});
test("rejects 0 with a RangeError", () => {
assert.throws(() => setQuantity(0), RangeError);
});
test("fetches a user", async () => {
const user = await fetchUser(1);
assert.equal(user.name, "Asha");
});Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Compare arrays with ===
In a test, write assertEqual(addItem(["pen"], "book"), ["pen", "book"]) using the === helper.
Break #2
Tests that depend on each other
Let test A add an item to a shared cart array and test B check that cart.length === 1.
Myth vs fact
Myth
Tests slow you down.
Fact
They cost time to write, and save more by catching bugs before users do and letting you change code without fear. A function you can't test easily is usually doing too much.
Myth
100% code coverage means no bugs.
Fact
Coverage only shows which lines ran during tests, not whether the assertions checked the right things. The slugify bug above came from an input nobody had tried.
Myth
A test that passes proves the code works.
Fact
It proves the code does what that test checks. A test with no assertion, or a forgotten await, passes no matter what.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Vitest's
toBeusesObject.is(soNaNequalsNaN, and0and-0differ),toEqualignoresundefinedproperties, andtoStrictEqualalso checks them and the object's class. Choosing the right matcher avoids false passes. - ▸
Test doubles: a mock or stub replaces a dependency (a
fetchcall, the clock) with a fake you control, so a test stays fast and deterministic. Vitest'svi.fn(),vi.spyOn()andvi.useFakeTimers()(Jest:jest.fn(),jest.useFakeTimers()) are the usual tools. Mock at the boundary (network, time), not your own logic. - ▸
Tests for DOM code run in a simulated browser such as jsdom or happy-dom (set Vitest's
environmentoption), or in a real browser with Vitest browser mode or Playwright. Testing Library encourages finding elements the way users do: by role and label text, which also tests accessibility. - ▸
Run tests automatically on every push in continuous integration (for example GitHub Actions running
npm test), so a failing test blocks the merge. Hectal itself checks every live example in this course this way, by running it in Chromium and comparing the console output.
Remember this
- 1
An assertion is a check that says "this must be true", and throws an error if it isn't:
if (actual !== expected) throw new Error(...). A test (or test case) is a named function that runs some code and makes assertions about it. If no assertion throws, the test passes; if one throws, it fails, and the error message says what was expected and what actually happened. You've already written an assertion in Topic 13.5; a test is assertions with a name. - 2
A unit test checks one small unit, usually one function, on its own. Good unit tests are fast (thousands run in seconds), independent (each one sets up its own data and doesn't depend on another test running first) and deterministic (same result every run: no real network, no current time, no random numbers). Pure functions (Topic 3.9) are the easiest to test: same input, same output, nothing else touched.
- 3
Most tests follow the Arrange, Act, Assert pattern (AAA). Arrange: set up the input (a cart with two items). Act: call the function once (
addItem(cart, pen)). Assert: check the result (the new cart has three items, the old one still has two). Name each test as a sentence about behaviour, like"adds an item without changing the original cart", so a failure reads like a bug report. - 4
Test more than the happy path. Good tests cover a normal case, the edges (an empty list, zero, one item, the largest allowed value), bad input (it should throw the right error type, Topic 13.3), and every bug you fix: a regression test reproduces the bug first (it fails), then passes after your fix, so the bug can never silently come back. Comparing objects and arrays needs care:
[1, 2] === [1, 2]isfalsebecause===compares references (Topic 6.5), so test helpers have a separate deep equality check. - 5
A test runner is a tool that finds your test files, runs every test, and prints a report. The two most used for JavaScript are Vitest (fast, pairs with Vite, Topic 12.4) and Jest (older, very widespread). Both use the same style:
describe("slugify", ...)groups tests,it(...)ortest(...)defines one, andexpect(value)gives matchers such as.toBe(5)(same value withObject.is),.toEqual({ a: 1 })(deep equality) and.toThrow(TypeError). Node.js also has a built-in runner,node --test, withnode:testandnode:assert. You install a runner with npm and run it with a script such asnpm test(Topic 12.3). - 6
Unit tests are the base of a test pyramid: many fast unit tests, fewer integration tests (several parts working together, such as a form plus its validation), and a few slow end-to-end tests that drive a real browser through the whole app with tools such as Playwright. Some teams write the test first, watch it fail, then write the code to make it pass: test-driven development (TDD). Tests don't prove code is bug-free, but they make changing it much safer.
Explain it without notes
What is the difference between an assertion, a test and a test runner?
What makes a good unit test?
Why do you need toEqual instead of toBe for arrays and objects?
What is a regression test, and why write one before fixing a bug?
Practice
Using the test and assertEqual helpers from the first live example, write three tests for isEven(n) (return n % 2 === 0;): a normal even number, an odd number, and the edge case 0.
Write the Vitest version of this: capitalize("asha") should be "Asha", capitalize("") should be "", and capitalize(5) should throw a TypeError.
Trade-offs
- ↔
Unit tests are fast and pinpoint failures but can miss problems between parts; end-to-end tests catch real user-facing breakage but are slow and more fragile. Most teams keep many unit tests and a few end-to-end tests.
- ↔
Tests that check internal details break on every refactor; tests that check behaviour (inputs and outputs) survive changes but may need more setup.
Done when you can
I can write an assertion and a named test from scratch.
I structure tests as Arrange, Act, Assert.
I test normal cases, edge cases and invalid input.
I use deep equality for arrays and objects.
I
awaitpromises in async tests.I can write and run a test with Vitest or Jest.