Small Rules, Big Difference
Good tests follow a few habits:
1. Clear names — greet falls back for an empty name says what broke without opening the file.
2. One behavior per test — small tests point straight at the problem.
3. Tables for many cases — one loop instead of copied tests.
4. Messages with got and want — a failure should explain itself.
5. Separate tests to see everything, one test where continuing is pointless.
6. Run tests often — after every small change, not once at the end.
7. Tests live next to the code — same folder, .test.ts suffix.
Failure Messages That Help
A failing test is read in a moment of confusion — the message has to carry the whole story: which call, what came back, what was expected.
A good shape is func(input) = got; want want. Wrapping the strings in JSON.stringify puts quotes around them, so a missing space or a missing comma is visible right away.
A message with the whole story
greet returns the wrong greeting. Our message shows the call, the got and the want, and the runner adds its own + actual - expected lines below it — the bug (wrong word, missing comma and exclamation mark) is visible in the output alone, without opening the code.
Output: TypeScript file and captured output
import { test } from "node:test"
import assert from "node:assert/strict"
// greet has a bug: wrong word, missing comma and "!".
function greet(name: string): string {
return "Hi " + name
}
test("greet by name", () => {
const got = greet("Ana")
const want = "Hello, Ana!"
assert.equal(got, want,
`greet("Ana") = ${JSON.stringify(got)}; want ${JSON.stringify(want)}`)
})
One Behavior per Test
A test that checks five things has five reasons to fail — and its name can only describe one of them. Splitting by behavior keeps every test small and its name honest. It matters twice over here, because a failed assertion stops its test: five checks in one test means you only ever see the first failure.
The normal greeting and the empty-name fallback are two behaviors, so they get two tests. When one fails, its name already tells you which behavior broke.
Two behaviors, two tests
greet uses the name checks the normal case, greet falls back for an empty name the fallback. The output lists them separately — a failure would point at exactly one behavior.
Output: TypeScript file and captured output
import { test } from "node:test"
import assert from "node:assert/strict"
function greet(name: string): string {
if (name === "") {
return "Hello, stranger!"
}
return "Hello, " + name + "!"
}
test("greet uses the name", () => {
const got = greet("Ana")
assert.equal(got, "Hello, Ana!", `greet("Ana") = ${JSON.stringify(got)}`)
})
test("greet falls back for an empty name", () => {
const got = greet("")
assert.equal(got, "Hello, stranger!", `greet("") = ${JSON.stringify(got)}`)
})
How Much Is Tested: Coverage
node --test --experimental-test-coverage measures which part of your code the tests actually execute. It prints one row per file with three percentages — lines, branches and functions — plus the line numbers nothing reached.
The number is a compass, not a goal: a low value shows untested paths. 100% coverage does not prove the code is correct, but the uncovered lines are guaranteed unchecked.
Measure the coverage
describeTemperature has three branches and the test exercises one. The report says line % 76.47 and branch % 60.00, and names the uncovered lines 6-7 9-10 — the freezing and the hot return. Tests for the cold and the hot branch would drive both numbers up.
The whole file is measured, the tests included, which is why the line number is not lower: the test lines themselves all ran.
Output: TypeScript file and captured output
import { test } from "node:test"
import assert from "node:assert/strict"
function describeTemperature(celsius: number): string {
if (celsius < 0) {
return "freezing"
}
if (celsius > 30) {
return "hot"
}
return "mild"
}
test("20 degrees is mild", () => {
const got = describeTemperature(20)
assert.equal(got, "mild", `describeTemperature(20) = ${JSON.stringify(got)}`)
})