Unit Testing
go test and Vitest done well: table-driven tests, subtests, matchers, helpers, golden files, fake timers, component tests with Testing Library, and how to shape code so the important logic is easy to test.
The 30-second version
Here is a real example. shippingCost decides what an order pays for delivery. Below it, five real tests run in your browser every time anything changes. Plant a bug with the buttons, and see which tests catch it.
1// Shipping fee in cents for an order total in cents.2export function shippingCost(totalCents, country) {3 if (totalCents <= 0) throw new Error("empty order");4 if (country !== "VN") return 1500; // $15 abroad5 if (totalCents >= 5000) return 0; // free from $506 return 500; // $57}
Try these three things, in order:
- Click line 6: small orders ship free. One test turns red, and its message says exactly what went wrong:
expected +0 to be 500. (Vitest writes zero as+0; §04 explains why.) - Remove the bugs and click line 5: off-by-one. Every test still passes! None of them tries an order of exactly $50, so nobody notices. Now tick the last test, is free at exactly $50, and the bug is caught.
- Switch to show coverage and remove the bugs. Coverage marks which lines the tests ran, and every line is green: the tests ran all of them. Yet in step 2 a bug on a green line got through. Running a line isn't the same as checking it.
- Still in the coverage view, untick charges $15 abroad. Line 4 turns yellow: its
ifstill runs in every test, but no test reaches itsreturn 1500any more.
That last point is the whole game. A test only helps if it would fail when the code is wrong. The rest of this topic is about writing tests like that, quickly, in Go and JavaScript.
What you'll learn
- §02–§04: the basics. How
go testand Vitest find and run tests, table-driven tests, and assertions whose failure messages tell you what broke. - §05–§06: structure. Setup and teardown, helpers, and golden files.
- §07–§09: hard-to-test code. Separating logic from side effects, controlling time, and testing React components the way users use them.
- §10–§12: tooling. Filters, the cache, coverage, parallel tests, the Race detector, and brief intros to benchmarks and Fuzzing.
- §13–§16: production. Keeping a big suite fast and trustworthy, a pitfalls gallery, a quiz and a cheat sheet.
go test and Vitest: the mechanics
Here is the code we'll test for most of this topic. Money is counted in whole cents (5000 means $50.00), because decimals like 0.1 can't be stored exactly by a computer and money has to add up exactly.
package shipping
import "errors"
var ErrEmptyOrder = errors.New("empty order")
// Cost returns the shipping fee in cents for an order total in cents.
func Cost(totalCents int, country string) (int, error) {
if totalCents <= 0 {
return 0, ErrEmptyOrder
}
if country != "VN" {
return 1500, nil // $15 abroad
}
if totalCents >= 5000 {
return 0, nil // free from $50
}
return 500, nil // $5
}The Go version returns an error as a second value instead of throwing, which is how Go reports failures (Go idioms: errors are values).
The test file
Tests live next to the code, in a file whose name tells the Test runner "this is a test". In Go the name must end in _test.go. Vitest looks for *.test.js or *.spec.js (and the .ts/.jsx/.tsx versions).
package shipping
import (
"errors"
"testing"
)
func TestCostFreeFrom50(t *testing.T) {
got, err := Cost(8000, "VN")
if err != nil {
t.Fatalf("unexpected error: %v", err) // stop this test right here
}
if got != 0 {
t.Errorf("Cost(8000, VN) = %d, want 0", got) // record a failure, keep going
}
}
func TestCostSmallOrder(t *testing.T) {
got, _ := Cost(2000, "VN")
if got != 500 {
t.Errorf("Cost(2000, VN) = %d, want 500", got)
}
}
func TestCostEmptyOrder(t *testing.T) {
_, err := Cost(0, "VN")
if !errors.Is(err, ErrEmptyOrder) {
t.Errorf("Cost(0, VN) error = %v, want ErrEmptyOrder", err)
}
}Every test has the same three beats: set up the inputs, call the code, check the result. The Anatomy of a test section of Why & What to Test goes through that shape slowly; here we focus on what the tools do with it.
What the runner does
Run them. In Go, ./... means "this folder and every folder below it". In JavaScript, vitest run runs once and exits; plain vitest stays open in Watch mode.
$ go test ./...
ok example.com/shop/shipping 0.002s
$ go test -v ./shipping
=== RUN TestCostFreeFrom50
--- PASS: TestCostFreeFrom50 (0.00s)
=== RUN TestCostSmallOrder
--- PASS: TestCostSmallOrder (0.00s)
=== RUN TestCostEmptyOrder
--- PASS: TestCostEmptyOrder (0.00s)
PASS
ok example.com/shop/shipping 0.002sWithout -v (Go) or --reporter=verbose (Vitest), passing tests are summarised in one line. That's deliberate: you only want details about what failed.
Reading a failure
Now break the code: change the small-order fee from 500 to 600. Real output (Go 1.24, Vitest 5.0.5):
$ go test ./shipping
--- FAIL: TestCostSmallOrder (0.00s)
shipping_test.go:21: Cost(2000, VN) = 600, want 500
FAIL
FAIL example.com/shop/shipping 0.002s
FAILBoth tell you which test, which line, what came back and what was expected. Go prints exactly the message you wrote, so the message is your job: the convention is Func(inputs) = got, want expected. Vitest builds the message for you from the matcher.
Table-driven tests and subtests
The three tests in §02 are nearly copies of each other. Add "just under the limit" and "abroad" and you have five copies, each a chance for a copy-paste mistake. A Table-driven test keeps the cases as data and loops over them.
func TestCost(t *testing.T) {
tests := []struct {
name string
total int
country string
want int
}{
{"free at exactly 50", 5000, "VN", 0},
{"small domestic order", 2000, "VN", 500},
{"just under the limit", 4999, "VN", 500},
{"abroad", 8000, "US", 1500},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { // ← one subtest per row
got, err := Cost(tc.total, tc.country)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("Cost(%d, %q) = %d, want %d", tc.total, tc.country, got, tc.want)
}
})
}
}Watching it run
Suppose Cost has a classic bug: totalCents > 5000 instead of >=. Step through what happens.
1func TestCost(t *testing.T) {2 tests := []struct {3 name string4 total int5 country string6 want int7 }{8 {"free at exactly 50", 5000, "VN", 0},9 {"small domestic order", 2000, "VN", 500},10 {"abroad", 8000, "US", 1500},11 }12 for _, tc := range tests {13 t.Run(tc.name, func(t *testing.T) {14 got, _ := Cost(tc.total, tc.country)15 if got != tc.want {16 t.Errorf("Cost(%d, %q) = %d, want %d",17 tc.total, tc.country, got, tc.want)18 }19 })20 }21}
Cost has a bug: it uses > where it should use >=.)Real output with that bug (the full four-row table):
$ go test ./shipping
--- FAIL: TestCost (0.00s)
--- FAIL: TestCost/free_at_exactly_50 (0.00s)
table_test.go:24: Cost(5000, "VN") = 500, want 0
FAIL
FAIL example.com/shop/shipping 0.002s
FAILOnly the row that sits exactly on the rule's edge caught it. Values like that are called boundary values, and they deserve a row in every table: here 4999 and 5000. The other edge, 0, returns an error instead of a fee, so it keeps its own test (TestCostEmptyOrder from §02), as the "wrong tool" rule below explains. (Vitest prints +0 because it tells 0 and -0 apart; §04 explains.)
Running one row
Every subtest has a full name, Parent/row_name, with spaces turned into underscores. go test -run takes a pattern and runs only the tests whose names match it. In Vitest, -t filters by test name.
$ go test -v -run '^TestCost$/^abroad$' ./shipping
=== RUN TestCost
=== RUN TestCost/abroad
--- PASS: TestCost (0.00s)
--- PASS: TestCost/abroad (0.00s)
PASS
ok example.com/shop/shipping 0.002sIn Vitest's output, ↓ marks a test that was skipped by the filter. -t is also a pattern, matched against the full name (describe titles plus the test title).
When a table is the wrong tool
Tables shine when every row needs the same setup and the same check. If one case needs a fake clock, another a temp directory, and a third checks a log line, forcing them into one table gives you rows full of setup func() and check func() fields that nobody can read. Write separate tests then. A good rule: if you need an if inside the loop body to treat some rows differently, split the table.
Assertions that explain the failure
Go: you write the comparison
Go's standard library has no assert function. You compare with if and report with t.Errorf. The convention is got (what the code returned) and want (what you expected), in that order:
if got != want {
t.Errorf("Cost(%d, %q) = %d, want %d", total, country, got, want)
}!= works for numbers, strings, booleans, and structs whose fields are all comparable (fields that == works on: numbers, strings, bools, pointers, other such structs, but no slices or maps). It does not work for slices or maps. The compiler stops you:
invalid operation: got != want (slice can only be compared to nil)For those, the standard library has helpers, and one third-party package is worth knowing:
type Reminder struct { // a reminder to send: who, and how much they owe
To string
FeeCents int
}
got := []Reminder{{"bob@x.com", 75}, {"cy@x.com", 450}}
want := []Reminder{{"bob@x.com", 75}, {"cy@x.com", 500}}
if !slices.Equal(got, want) { // standard library (Go 1.21+), element-by-element ==
t.Errorf("got %v, want %v", got, want)
}
if !reflect.DeepEqual(got, want) { // standard library, compares anything, deeply
t.Errorf("got %+v, want %+v", got, want)
}
if diff := cmp.Diff(want, got); diff != "" { // github.com/google/go-cmp
t.Errorf("Reminders() mismatch (-want +got):\n%s", diff)
}Here's what each one printed for the same mismatch (real output):
c_test.go:22: slices.Equal: got [{bob@x.com 75} {cy@x.com 450}], want [{bob@x.com 75} {cy@x.com 500}]
c_test.go:25: DeepEqual: got [{To:bob@x.com FeeCents:75} {To:cy@x.com FeeCents:450}], want [{To:bob@x.com FeeCents:75} {To:cy@x.com FeeCents:500}]
c_test.go:28: Reminders() mismatch (-want +got):
[]cmpdemo.Reminder{
{To: "bob@x.com", FeeCents: 75},
{
To: "cy@x.com",
- FeeCents: 500,
+ FeeCents: 450,
},
}With two short values, any of them is fine. With a struct of twenty fields, only cmp.Diff points straight at the one field that differs. Many teams also use testify (assert.Equal(t, want, got)), a popular third-party package that prints similar diffs. Either is fine; mixing styles in one codebase isn't.
Vitest: pick the right matcher
In Vitest you call a Matcher on expect(received), and it builds the message. The three you'll use most compare in different ways:
| Matcher | Asks | Use it for |
|---|---|---|
toBe(x) | Is it the same value? (Object.is, almost the same as ===) | numbers, strings, booleans, "is it this exact object?" |
toEqual(x) | Does it have the same contents, deeply? Ignores undefined fields and classes. | objects and arrays, most of the time |
toStrictEqual(x) | Same contents, and same undefined fields, same classes | data you send as JSON or compare by type |
Predict each result before you reveal it. Every answer below comes from running Vitest 5.0.5:
expect([1, 2]).toBe([1, 2])expect([1, 2]).toEqual([1, 2])expect([1, 2]).toStrictEqual([1, 2])expect([1, 2]).toBeCloseTo([1, 2])numbers onlyA few more matchers cover most other needs:
expect(total).toBeCloseTo(0.3); // computed decimals: equal to 2 decimal places
expect(order).toMatchObject({ status: "paid" }); // only checks the fields you list
expect(list).toContain("tea"); // array includes this item (string includes text)
expect(list).toHaveLength(3);
expect(() => parse("")).toThrow("empty input"); // must wrap the call in a function (§02)
expect(result).not.toBeNull(); // .not flips any matcherHow much should one test check?
Check the behaviour the test is named after, and little else. A test called "rejects an empty order" should check the error, not also the log format and the number of database calls. Every extra check is another reason the test can break when nothing important changed, which is how suites become something people "just update" instead of trust. Why & What to Test has more on testing behaviour rather than implementation details.
Setup, teardown and helpers
That known starting state (sample data, a temp folder, a fresh object) is called a Test fixture. If tests share state instead, test B can pass only because test A ran first and left data behind. Run B alone, or in a different order, and it fails for no visible reason. Keeping tests independent of each other is called Test isolation, and setup/teardown is how you get it.
Vitest: four hooks
Vitest gives you beforeEach/afterEach (around every test) and beforeAll/afterAll (once around the whole describe block). Step through the order. We checked it by logging from each hook in a real run:
1describe("cart", () => {2 let cart;3 beforeAll(() => { /* open price list */ });4 beforeEach(() => { cart = []; });5 afterEach(() => { /* tidy up */ });6 afterAll(() => { /* close price list */ });78 it("starts empty", () => {9 expect(cart).toEqual([]);10 });1112 describe("with one item", () => {13 beforeEach(() => { cart.push("tea"); });14 it("has one item", () => {15 expect(cart).toHaveLength(1);16 });17 });18});
beforeAll runs once, before the first test in this describe. Use it for expensive things that tests only read.The rule worth remembering: teardown code goes in afterEach, not at the bottom of the test. If an expect fails halfway through a test, the rest of the test body never runs, but afterEach still does.
Go: setup is just code, cleanup is t.Cleanup
Go has no hooks. Setup is ordinary code at the top of the test, usually wrapped in a Test helper. Teardown is registered with t.Cleanup(fn), which runs fn when the test (or subtest) finishes, pass or fail. Switch the widget above to Go t.Cleanup to see the order. Here's the real program it's based on, and its output:
func openTestDB(t *testing.T) *DB {
t.Helper()
db := connectTestDB(t) // our setup code; its Close() logs "db closed"
t.Log("db opened")
t.Cleanup(db.Close) // ← the helper cleans up after itself
return db
}
func TestCart(t *testing.T) {
db := openTestDB(t) // line 17
dir := t.TempDir() // a fresh empty directory, deleted automatically at the end
t.Cleanup(func() {
_, err := os.Stat(dir)
t.Log("cleanup: temp dir still exists:", err == nil)
})
t.Log("test body uses", db != nil, dir != "")
}$ go test -v ./life
=== RUN TestCart
life_test.go:17: db opened
life_test.go:23: test body uses true true
life_test.go:21: cleanup: temp dir still exists: true
db_test.go:9: db closed
--- PASS: TestCart (0.00s)Two more built-ins follow the same pattern: t.Setenv("TZ", "UTC") sets an environment variable and restores the old value afterwards, and t.Chdir(dir) (Go 1.24+) changes the working directory for one test.
t.Helper: blame the right line
Look at that output again: db opened was logged at life_test.go:17, which is the line in TestCart that called the helper, not the line inside the helper. That's what t.Helper() does. It matters most for assertion helpers:
func assertEqual(t *testing.T, got, want int) {
t.Helper() // ← report failures at the caller's line
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
func TestSum(t *testing.T) {
assertEqual(t, 1+1, 2)
assertEqual(t, 2+2, 5) // line 14
}--- FAIL: TestSum (0.00s)
h_test.go:8: got 4, want 5--- FAIL: TestSum (0.00s)
h_test.go:14: got 4, want 5Without it, every failure points at line 8, the same line inside assertEqual, and you can't tell which of the calls failed. Put t.Helper() as the first line of every function that takes t and isn't a test itself.
Once per package: TestMain
If a whole package needs one expensive setup, say starting a server, you can define TestMain. It runs instead of the tests, and calls m.Run() to run them:
func TestMain(m *testing.M) {
stop := startFakeServer() // once, before any test in this package
code := m.Run() // runs every test; returns the exit code
stop() // once, after all of them
os.Exit(code)
}It's Go's version of a file-wide beforeAll/afterAll. Reach for it rarely: shared state across a package is exactly what makes tests depend on each other. Most unit tests need nothing more than a helper and t.Cleanup.
testdata and golden files
The testdata folder
Go treats a folder named testdata specially: the build tools ignore it, so you can put any files there without them being compiled. Tests run with the package folder as their working directory, so a test can open testdata/whatever with a plain relative path. That makes testdata the conventional home for input files and expected outputs.
A golden-file test in Go
Here's a function that formats a receipt, and a test that compares its output with a Golden file:
var update = flag.Bool("update", false, "rewrite golden files")
func TestRender(t *testing.T) {
got := Render([]Line{{"coffee", 2, 350}, {"bagel", 1, 425}})
golden := filepath.Join("testdata", "receipt.golden")
if *update { // go test -update: save today's output as the new expected output
if err := os.MkdirAll("testdata", 0o755); err != nil { // create the folder if missing
t.Fatal(err)
}
if err := os.WriteFile(golden, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatal(err)
}
if got != string(want) {
t.Errorf("Render() mismatch\n--- got ---\n%s--- want ---\n%s", got, want)
}
}-update. The test writes today's output to testdata/receipt.golden. You read it once and commit it.-update again, then review the diff in git before committing.Here is the whole loop, for real:
$ go test ./receipt # no golden file yet
--- FAIL: TestRender (0.00s)
receipt_test.go:26: open testdata/receipt.golden: no such file or directory
$ go test ./receipt -update # write it…
ok example.com/shop/receipt 0.002s
$ cat receipt/testdata/receipt.golden
coffee x2 7.00
bagel x1 4.25
TOTAL 11.25
$ go test ./receipt # …and from now on, compare against it
ok example.com/shop/receipt 0.002sNow someone changes the width of the item lines' price column from %8.2f to %7.2f (the TOTAL line has its own format, so it stays the same). Nobody would spot that in code review, but the golden file does:
--- FAIL: TestRender (0.00s)
receipt_test.go:29: Render() mismatch
--- got ---
coffee x2 7.00
bagel x1 4.25
TOTAL 11.25
--- want ---
coffee x2 7.00
bagel x1 4.25
TOTAL 11.25The same idea in Vitest: snapshots
Vitest builds the "write once, compare forever" loop in. A Snapshot test saves the output the first time it runs, and compares on every run after that:
it("renders a receipt", async () => {
const out = renderReceipt([{ name: "coffee", qty: 2, cents: 350 }, { name: "bagel", qty: 1, cents: 425 }]);
await expect(out).toMatchFileSnapshot("./__snapshots__/receipt.txt");
});The first run printed Snapshots 1 written. After the same %8 → %7 style change, it failed with a line-by-line diff (- Expected / + Received). To accept a change on purpose, run npx vitest run -u. toMatchFileSnapshot stores the output as a normal file you can open; toMatchSnapshot() stores all snapshots of a test file together in one generated .snap file.
Where golden files go wrong
Blind -update
The test fails, someone runs -update (or vitest -u), the test goes green, and the diff gets committed without anyone reading it. Now the golden file faithfully records the bug. The update is not the fix; reviewing the diff is. Keep golden files small enough that a reviewer will actually read a change to them.
Output that changes by itself
If the output contains the current time, a random ID, or the order of a Go map (which is deliberately random), the golden file never matches twice. Pass the time in (§07), use fixed IDs in tests, and sort map keys before printing.
Pure logic vs side effects: shaping code to test
In code terms, the calculator is a Pure function: same inputs, same result, nothing changed outside. The vending machine has side effects: it reads and changes the world. Reading the clock counts too: time.Now() returns something different every call, so a function that calls it gives different answers on different days.
One feature, two shapes
A library sends a reminder for every overdue book and charges 25¢ per day late, capped at $5. Flip between the two versions and look at what a test of the fee rule would need:
func SendReminders(db *sql.DB, mailer *Mailer) error {rows, err := db.Query("SELECT email, due FROM loans")I/Oif err != nil {return err}defer rows.Close()I/Ofor rows.Next() {I/Ovar email stringvar due time.Timeif err := rows.Scan(&email, &due); err != nil {I/Oreturn err}late := time.Since(due)clockif late <= 0 {logiccontinuelogic}fee := min(int(late.Hours()/24)*25, 500)logicif err := mailer.Send(email, fee); err != nil {I/Oreturn err}}return rows.Err()I/O}
- ✗ A database with a loans table, filled with test rows
- ✗ A mail server, or a fake that records what was sent
- ✗ A way to control time.Since: “3 days late” depends on when the test runs
- ✗ To check one fee rule, you must set up all three
In the tangled version, two lines of business logic sit between a database query, the real clock and a mail server. To check "3 days late costs 75¢" you'd need all three. In the split version the rule lives in Reminders, which takes plain data and returns plain data. This shape has a name: Functional core, imperative shell.
Here's the test for the core. No database, no mail server, no waiting:
// In loans.go:
// type Loan struct { Email, Title string; Due time.Time }
// type Reminder struct { To string; FeeCents int }
func TestReminders(t *testing.T) {
now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) // a fixed "today"
day := 24 * time.Hour
loans := []Loan{
{"ann@x.com", "Dune", now.Add(1 * day)}, // not due yet
{"bob@x.com", "Emma", now.Add(-3 * day)}, // 3 days late
{"cy@x.com", "Ulysses", now.Add(-60 * day)}, // very late: capped
}
got := Reminders(loans, now)
want := []Reminder{{"bob@x.com", 75}, {"cy@x.com", 500}}
if !slices.Equal(got, want) {
t.Errorf("Reminders() = %+v, want %+v", got, want)
}
}Both tests pass in about a millisecond, and adding a rule ("no fee on the first day") is one more row. The thin SendReminders shell still needs testing, but it has almost no decisions left. One integration test against a real database covers it.
Four moves that make code testable
You rarely get to design code from scratch. These four refactors cover most "this is impossible to test" moments.
1. Pass values in instead of reaching out. Reminders(loans, now) takes now as a parameter instead of calling time.Now() inside. main passes the real time; tests pass any date they like. The same works for random numbers, IDs, and configuration.
2. Return data instead of doing the thing. Reminders returns a list of reminders to send; it doesn't send them. The decision ("who gets what") becomes testable data, and the action ("send it") is a dumb loop in the shell.
3. Write to an io.Writer, not to the screen. A function that calls fmt.Println can only be checked by capturing the terminal. A function that takes an io.Writer can write to os.Stdout in production and to an in-memory buffer in a test:
// Report writes a summary to any io.Writer.
func Report(w io.Writer, rs []Reminder) {
for _, r := range rs {
fmt.Fprintf(w, "%s owes $%.2f\n", r.To, float64(r.FeeCents)/100)
}
}
func TestReport(t *testing.T) {
var buf bytes.Buffer // an in-memory io.Writer
Report(&buf, []Reminder{{"bob@x.com", 75}})
if got, want := buf.String(), "bob@x.com owes $0.75\n"; got != want {
t.Errorf("Report wrote %q, want %q", got, want)
}
}4. When the code really must call out, for example a checkout that has to charge a card in the middle, accept the dependency as a small Interface (Go) or a parameter (JS). That gives the test a Seam where it can plug in a stand-in. Building those stand-ins well (fakes, stubs, mocks) is the whole subject of Mocks, Stubs & Fakes, and Dependency Injection covers the wiring.
Time in tests: fake timers and synctest
Here's a Debounce helper: a search box calls onType on every keystroke, but the actual search only runs once the user has stopped typing for 300 ms.
// Calls fn only after `ms` of quiet: each new call restarts the wait.
export function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}A test that uses the real clock would type, await a 300 ms sleep, then check. That's slow (multiply by a few hundred tests), and it's a classic source of flaky tests: on a busy build server, "300 ms later" can easily arrive late. The Flaky Tests topic is full of these.
Fake timers in Vitest
vi.useFakeTimers() swaps setTimeout, setInterval, Date and friends for Fake timers. Time stops. It only moves when the test calls vi.advanceTimersByTime(ms), which instantly runs every timer that would have fired in that window, in order.
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { debounce } from "./debounce.js";
beforeEach(() => vi.useFakeTimers()); // setTimeout & friends now use a fake clock
afterEach(() => vi.useRealTimers()); // put the real ones back for other tests
it("only searches once the user stops typing", () => {
const search = vi.fn(); // a function that records its calls
const onType = debounce(search, 300);
onType("g");
vi.advanceTimersByTime(200); // 200 ms pass, instantly
onType("go"); // restarts the 300 ms wait
vi.advanceTimersByTime(299);
expect(search).not.toHaveBeenCalled();
vi.advanceTimersByTime(1); // now it's 300 ms after "go"
expect(search).toHaveBeenCalledTimes(1);
expect(search).toHaveBeenCalledWith("go");
});Real result: ✓ only searches once the user stops typing 5ms. Five milliseconds to test 500 ms of typing.
Try it yourself. Every button press adds a line to the test on the right, so you can see the script you'd write:
vi.useFakeTimers();Press onType, then move the clock.
Switch to real timers to see the difference: the clock runs by itself, and a test would have to sit and wait for it.
More things you can do with the fake clock
vi.runAllTimers(); // fire every pending timer, however far away
vi.setSystemTime(new Date("2026-01-31T23:59:00Z")); // new Date() and Date.now() now return this
await vi.advanceTimersByTimeAsync(3000); // advance AND let awaited code run in betweensetSystemTime is how you test "what happens at midnight on the last day of the month" on any day of the year.
Go: control time with synctest
Go's first option you've already seen: pass the time in (Reminders(loans, now) in §07), or pass a small clock interface (Mocks, Stubs & Fakes shows that pattern).
For code that uses timers and time.Sleep internally, Go 1.25 added testing/synctest to the standard library. The test body runs inside a bubble with a fake clock that starts at midnight, January 1, 2000. Inside the bubble, when every Goroutine is blocked waiting on time, the clock jumps straight to the next timer. Here's the same debounce in Go:
func Debounce(d time.Duration, fn func(string)) func(string) {
var mu sync.Mutex
var t *time.Timer
return func(s string) {
mu.Lock()
defer mu.Unlock()
if t != nil {
t.Stop() // cancel the previous wait
}
t = time.AfterFunc(d, func() { fn(s) }) // call fn(s) after d, on its own goroutine
}
}func TestDebounce(t *testing.T) {
synctest.Test(t, func(t *testing.T) { // ← everything inside uses a fake clock
calls := make(chan string, 10)
onType := Debounce(300*time.Millisecond, func(s string) { calls <- s })
onType("g")
time.Sleep(200 * time.Millisecond) // fake time: returns at once
onType("go") // restarts the 300 ms wait
time.Sleep(299 * time.Millisecond)
synctest.Wait() // let every other goroutine in the bubble finish what it can
if len(calls) != 0 {
t.Fatal("searched too early")
}
time.Sleep(1 * time.Millisecond) // 300 ms after "go"
synctest.Wait()
if got := <-calls; got != "go" {
t.Fatalf("searched for %q, want %q", got, "go")
}
})
}$ go test -v -race . # Go 1.26 (synctest needs Go 1.25+)
=== RUN TestDebounce
--- PASS: TestDebounce (0.00s)
PASS
ok example.com/sync 1.012sThe test reports 0.00s even though it "slept" for half a second. (The 1.012s on the last line is the whole test program. The race detector adds a fixed pause of about 1 s when a program exits, so every -race run shows roughly that.)
Testing React components the way users use them
What you need
A React component needs a page to render into, and Node doesn't have one. jsdom / happy-dom builds a fake page (document, window, elements, events) inside Node. It has no real layout or painting, but for "click this, does that text appear?" it's enough, and much faster than starting a browser. On top of it, React Testing Library renders components and finds elements, and user-event types and clicks like a person.
npm i -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-domThe config below uses import, so your package.json needs "type": "module" (or name the file vitest.config.mjs); otherwise Vite warns about ESM syntax in a CommonJS file. @testing-library/jest-dom is named after Jest, the older test runner, but its matchers work with Vitest too.
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom", // every test file gets a fake browser page
setupFiles: ["./test/setup.js"], // runs before each test file
},
});import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/vitest"; // adds matchers like toBeInTheDocument()
afterEach(() => cleanup()); // unmount whatever the last test renderedWe'll come back to that cleanup line, because leaving it out causes a confusing bug.
The component and its test
A newsletter form: type an email, press Subscribe, get a thank-you message. An email without @ shows an error instead.
import { useState } from "react";
export function Subscribe({ onSubscribe }) {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
const [done, setDone] = useState(false);
function handleSubmit(e) {
e.preventDefault();
if (!email.includes("@")) return setError("Please enter a valid email");
onSubscribe(email);
setDone(true);
}
if (done) return <p role="status">Thanks! Check your inbox.</p>;
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input id="email" value={email} onChange={(e) => setEmail(e.target.value)} />
{error && <p role="alert">{error}</p>}
<button className="btn-primary">Subscribe</button>
</form>
);
}import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it, vi } from "vitest";
import { Subscribe } from "./Subscribe.jsx";
it("subscribes with a valid email", async () => {
const user = userEvent.setup();
const onSubscribe = vi.fn();
render(<Subscribe onSubscribe={onSubscribe} />);
await user.type(screen.getByRole("textbox", { name: "Email" }), "ann@example.com");
await user.click(screen.getByRole("button", { name: "Subscribe" }));
expect(onSubscribe).toHaveBeenCalledWith("ann@example.com");
expect(screen.getByRole("status")).toHaveTextContent("Thanks! Check your inbox.");
});
it("shows an error for a bad email", async () => {
const user = userEvent.setup();
render(<Subscribe onSubscribe={vi.fn()} />);
await user.type(screen.getByRole("textbox", { name: "Email" }), "nope");
await user.click(screen.getByRole("button", { name: "Subscribe" }));
expect(screen.getByRole("alert")).toHaveTextContent("Please enter a valid email");
expect(screen.queryByRole("status")).not.toBeInTheDocument();
}); ✓ src/Subscribe.test.jsx > subscribes with a valid email 218ms
✓ src/Subscribe.test.jsx > shows an error for a bad email 43msNotice what the test doesn't know: the state variable names, that there's a handleSubmit, or the CSS class. You could rewrite the component with useReducer or a form library and this test would still pass, as long as users still see the same thing. That's exactly when you want a test to keep passing.
Why "by role" beats every other query
Here's the same button found four different ways. Change the markup the way real projects do, and watch which queries break, and whether breaking was the right call:
<form> <label for="email">Email</label> <input id="email"> <button class="btn-primary" data-testid="subscribe-btn">Subscribe</button> </form>
container.querySelector(".btn-primary")screen.getByTestId("subscribe-btn")screen.getByText("Subscribe")screen.getByRole("button", { name: "Subscribe" })The pattern: querySelector(".btn-primary") and getByTestId break on changes no user can see (false alarms), and they miss the <div> swap, a real bug that locks out keyboard and Screen reader users. getByRole survives cosmetic changes, fails when the visible label changes (fair: the test should say the new label), and catches the <div>. This is the real error Testing Library gave for the <div> version of the form. Notice the list: the only thing it can find is the textbox. The <div> isn't a button to assistive technology at all.
TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "Subscribe"
Here are the accessible roles:
textbox:
Name "Email":
<input
id="email"
/>
…Testing Library's own recommended order is: getByRole first, then getByLabelText (form fields), then getByText (non-interactive text), and getByTestId only as a last resort. The Accessibility topic explains roles and names in depth.
The cleanup trap
Testing Library is supposed to unmount everything after each test automatically. But it does that by calling a global afterEach, and Vitest doesn't create globals unless you set globals: true. Without one or the other, every test's components pile up in the same document:
it("one", () => { render(<button>Save</button>); });
it("two", () => {
render(<button>Save</button>);
expect(screen.getAllByRole("button")).toHaveLength(1);
});AssertionError: expected [ Array(2) ] to have a length of 1 but got 2Test two sees test one's button. In bigger suites this shows up as "Found multiple elements with the role button" in a test that only renders one. The afterEach(() => cleanup()) line in the setup file (or globals: true in the config) fixes it. We confirmed both: the failure above, and a pass with the setup file.
jsdom or happy-dom?
Both work with Testing Library. Put // @vitest-environment happy-dom at the top of a file (or environment: "happy-dom" in the config) to switch. Measured on this machine for the two-test file above: about 1.0–1.1 s per run with jsdom 29 and 0.5–0.65 s with happy-dom 21, mostly time spent setting up the environment. happy-dom is faster; jsdom is older and follows the standards more closely. Start with either, switch if you hit a gap.
Running tests: filters, the cache, coverage, watch mode
The everyday commands
| I want to… | Go | Vitest |
|---|---|---|
| Run everything once | go test ./... | npx vitest run |
| Re-run on every save | (use your editor or a file watcher) | npx vitest (Watch mode) |
| See every test name | go test -v ./... | --reporter=verbose |
| Run tests matching a name | -run '^TestCost$' (§03) | -t "abroad" |
| Run one file / package | go test ./shipping | npx vitest run shipping (matches file paths) |
| Stop at the first failure | -failfast | --bail=1 |
| Run in random order | -shuffle=on | --sequence.shuffle |
| Skip slow tests | -short (tests check testing.Short()) | .skip, or a separate config |
In Vitest you can also mark tests in code: it.only(…) runs just that test in its file, it.skip(…) skips it, it.todo("…") lists a test you still have to write. A committed .only would silently skip the rest of the file, so when the CI variable is set Vitest refuses: we got Error: [Vitest] Unexpected .only modifier. Remove it or pass --allowOnly argument to bypass this error.
Go's test cache
Run the same passing package twice and the second run is instant:
$ go test ./shipping
ok example.com/shop/shipping 0.002s
$ go test ./shipping
ok example.com/shop/shipping (cached)
$ go test -count=1 ./shipping
ok example.com/shop/shipping 0.002sThe Test cache remembers results for packages whose source, test files and flags haven't changed. It also notices environment variables and files the test read: we added a test that reads SHIP_REGION, and running with SHIP_REGION=eu re-ran the package instead of using the cache. It can't notice a database or a web server changing under you, so when a test talks to anything outside the package, or you're hunting a flaky test, add -count=1 to force a real run. Only runs that use a package path (./..., ./shipping) are cached at all.
Skipping slow tests with -short
func TestImportBigCatalog(t *testing.T) {
if testing.Short() {
t.Skip("slow: parses a 200 MB file") // shown with -v as SKIP
}
// …
}go test -short ./... then skips it. A common setup: -short in the editor's save loop, the full suite in CI.
Coverage: what ran, not what was checked
Code coverage measures which code ran during the tests. With only the three tests from §02 (nothing tests "abroad"):
$ go test -cover ./shipping
ok example.com/shop/shipping 0.003s coverage: 85.7% of statements
$ go test -coverprofile=c.out ./shipping && go tool cover -func=c.out
example.com/shop/shipping/shipping.go:8: Cost 85.7%
total: (statements) 85.7%
$ go tool cover -html=c.out # opens the file in a browser, uncovered code in redLook at the Vitest row: Lines 100%, yet line 4 is listed as uncovered. Line 4 is if (country !== "VN") return 1500;. The if ran in every test, so the line counts as covered, but the return 1500 part never did. Statement and Branch coverage catch it; line coverage doesn't. That's exactly what step 4 in §01 showed: the widget's line 4 turned yellow when you unticked the "abroad" test.
And remember the off-by-one experiment from §01: 100% coverage, every line green, bug not caught. Coverage tells you what's definitely untested (the red parts), which is useful. It can't tell you that the green parts are tested well. Why & What to Test has the longer version; §13 shows a tool that measures "would a test notice?" directly.
Parallel tests and the race detector
Who runs in parallel by default
- Go: different packages are built and run at the same time, as separate programs. Tests inside one package run one after another, unless they call
t.Parallel(). - Vitest: each test file runs in its own isolated worker, and files run at the same time. Tests inside one file run one after another, unless you use
describe.concurrentorit.concurrent.
So in both tools, the default unit of parallelism is the file or package, and inside it things are sequential. That default is safe. Opting in is where speed and danger both come from.
t.Parallel, measured
Four subtests that each take 100 ms (here a time.Sleep, standing in for real work):
func TestSlow(t *testing.T) {
for _, name := range []string{"a", "b", "c", "d"} {
t.Run(name, func(t *testing.T) {
t.Parallel() // ← this subtest may run at the same time as its siblings
slowCheck() // 100 ms
})
}
}t.Parallel() the package took 0.403 s; with it, 0.102 s. Each subtest still took 0.10 s; they just overlapped.How it works: when a subtest calls t.Parallel(), it pauses and hands control back to the loop. Once the loop has started every subtest and the parent function returns, all the paused ones resume together, up to -parallel at a time (by default, the number of CPUs Go uses). That's why the loop variable matters: each subtest's closure must see its own name, which Go 1.22+ guarantees (§03).
Shared state: a race in your tests
Parallel tests are a great way to discover that two tests use the same thing. Here, a package-level cache is shared by both subtests, and the cache isn't safe for concurrent use. (Each parallel subtest runs on its own Goroutine, Go's lightweight thread.)
var shared = NewCache() // ← one cache shared by every test (a map inside)
func TestCache(t *testing.T) {
for _, k := range []string{"apple", "bagel"} {
t.Run(k, func(t *testing.T) {
t.Parallel()
for i := range 1000 {
shared.Set(k, i) // two goroutines writing one map
}
if got := shared.Get(k); got != 999 {
t.Errorf("Get(%q) = %d, want 999", k, got)
}
})
}
}Plain go test passed it 20 times out of 20 on this machine. With the Race detector (-race), it failed 15 times out of 20:
$ go test -race ./racey
==================
WARNING: DATA RACE
Write at 0x00c0000a8540 by goroutine 9:
runtime.mapassign_faststr()
example.com/shop/racey.(*Cache).Set()
…/racey/cache.go:8 +0xa4
example.com/shop/racey.TestCache.func1()
…/racey/cache_test.go:12 +0x5b
Previous write at 0x00c0000a8540 by goroutine 8:
…same lines, the other subtest…
==================
testing.go:1490: race detected during execution of test
--- FAIL: TestCache (0.00s)
--- FAIL: TestCache/apple (0.00s)
FAILTwo lessons. First, -race only reports races that actually happen while it watches: it caught this one in 15 of 20 runs, not every time. Run it on every CI build so the odds add up. Second, the fix is usually in the test: give each test its own cache (c := NewCache() inside the subtest). If the code is meant to be shared between goroutines in production, the fix belongs in the code too: a mutex inside Cache.
Vitest: concurrent tests inside a file
describe.concurrent("price lookups", () => {
it("vn", async ({ expect }) => { expect(await lookup("VN")).toBe(500); });
it("us", async ({ expect }) => { expect(await lookup("US")).toBe(1500); });
});Tests in a describe.concurrent block start together, which helps only when they await something slow. Take expect from the test's argument (async ({ expect }) =>) as shown: with several tests running at once, that local expect knows which test it belongs to, which matters for snapshots and expect.assertions. Everything said above about shared state applies: two concurrent tests that both change one module-level variable will step on each other.
Benchmarks and fuzzing, in brief
_test.go files: benchmarks ask "how fast?", and fuzz tests ask "is there any input that breaks this?". Both are worth knowing exist; neither replaces ordinary tests.Benchmarks
A Benchmark is a function named BenchmarkXxx that takes *testing.B. Go runs its loop body as many times as it needs to get a stable measurement. Here, two ways to join 100 words:
func BenchmarkJoinPlus(b *testing.B) {
for b.Loop() { // Go 1.24+: keep looping until the timing is stable
joinPlus(words) // s += w + "," in a loop
}
}
func BenchmarkJoinBuilder(b *testing.B) {
for b.Loop() {
joinBuilder(words) // strings.Builder
}
}$ go test -bench . -benchmem ./bench
cpu: Intel(R) Xeon(R) Processor @ 2.30GHz
BenchmarkJoinPlus-4 121100 9645 ns/op 21088 B/op 100 allocs/op
BenchmarkJoinBuilder-4 1670090 708.8 ns/op 1016 B/op 7 allocs/op
PASSReading a line: the function ran 121,100 times, each run took 9,645 ns (about 10 µs), allocated 21,088 bytes in 100 separate allocations (requests for new memory). -4 is the number of CPUs used. Building the string with += copies it on every step, which is why it's about 13× slower here and allocates 20× more memory (measured on this machine; your numbers will differ, the ratio should be similar).
Benchmarks don't run with plain go test; you ask for them with -bench <regex> (. means all). Single runs are noisy, so for a real decision run each benchmark several times (-count=10) and compare the two sets with the benchstat tool from golang.org/x/perf. For JavaScript, Vitest has a vitest bench mode (its API has changed between versions, so follow the docs for yours). Measuring well is a topic of its own; the Big-O topic shows where micro-measurements mislead.
Fuzzing: let the computer find the input
Your tests only try the inputs you thought of. Fuzzing generates thousands of inputs per second, guided by which code paths they reach, looking for one that breaks a rule you state. Go has it built in (since Go 1.18).
Here's a function with a bug most people wouldn't think to test:
// Truncate shortens s to at most n characters, adding "…" if it cut anything.
func Truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…" // ← len and s[:n] count BYTES, not characters
}func FuzzTruncate(f *testing.F) {
f.Add("hello world", 5) // seed inputs: a starting point for the fuzzer
f.Add("hi", 10)
f.Fuzz(func(t *testing.T, s string, n int) {
if n < 0 || !utf8.ValidString(s) {
t.Skip() // only test inputs that make sense
}
out := Truncate(s, n)
if !utf8.ValidString(out) { // the rule: never produce broken text
t.Errorf("Truncate(%q, %d) = %q: not valid UTF-8", s, n, out)
}
})
}$ go test -fuzz FuzzTruncate -fuzztime 30s ./fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 4 workers
fuzz: minimizing 38-byte failing input file
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzTruncate (0.21s)
--- FAIL: FuzzTruncate (0.00s)
truncate_test.go:17: Truncate("ˊ؛", 3) = "ˊ\xd8…": not valid UTF-8
Failing input written to testdata/fuzz/FuzzTruncate/03a91f0449f5df6d
To re-run:
go test -run=FuzzTruncate/03a91f0449f5df6d
FAILIt found the bug in 0.21 seconds. The first failing input it hit was 38 bytes long; "minimizing" means it then searched for a smaller input that still fails (this is called Shrinking). The result: ˊ and ؛ are one character each, but two bytes each in UTF-8. Cutting at byte 3 keeps the first character plus half of the second, and \xd8 is that orphaned half.
Most importantly, the failing input was saved under testdata/, so from now on plain go test runs it as an ordinary test case and fails until the bug is fixed. The fix is to count runes instead of bytes:
r := []rune(s) // ← cut by characters, not bytes
if len(r) <= n {
return s
}
return string(r[:n]) + "…"With that, the saved case passes and a further 10-second fuzz run (about 240,000 inputs) found nothing.
The same idea in JavaScript: property-based tests
JavaScript has no built-in fuzzer, but a Property-based test library like fast-check does the same job inside Vitest. The JS version of the bug is similar: s.length and slice count UTF-16 code units, and emoji and some other characters take two.
import { expect, it } from "vitest";
import fc from "fast-check";
import { truncate } from "./truncate.js"; // s.length <= n ? s : s.slice(0, n) + "…"
it("never cuts a character in half", () => {
fc.assert(
fc.property(fc.string({ unit: "grapheme" }), fc.nat(20), (s, n) => {
expect(truncate(s, n).isWellFormed()).toBe(true); // no half characters left behind
}),
);
});Error: Property failed after 5 tests
{ seed: 1604752442, path: "4:1:4:2:2:3:6:9:11:14", endOnFailure: true }
Counterexample: ["𐀀𐀀",3]
Shrunk 9 time(s)fast-check found a failing string on its 5th try, then shrank it nine times to the simplest one it could: two copies of a character that takes two code units, cut at 3. The seed lets you replay exactly that run. Fixing it with [...s] made the property pass. [...s] splits by code points (one Unicode number each), so it never leaves half of a two-unit character behind. It can still split a flag like 🇻🇳 or a skin-toned 👍🏽, which are several code points that look like one character; the property only checks for broken halves, so it doesn't catch that.
Staff level: what a unit is, and suites that stay fast and honest
What is a "unit", anyway?
Two schools answer differently, and the difference shapes a whole codebase. A solitary test isolates one function or class and replaces every collaborator with a Test double. A sociable test lets the code use its real in-memory collaborators (the real price calculator, the real validator) and only replaces things that are slow, external or random: databases, networks, the clock.
| Solitary | Sociable | |
|---|---|---|
| A test fails when | this one class misbehaves | this behaviour is broken, wherever the bug is |
| Breaks on refactors | often: the doubles encode who calls whom | rarely: only the inputs and outputs are fixed |
| Finds the bug's location | precisely | you may need to look one level down |
| Typical result | many doubles, tests mirror the code's structure | fewer doubles, tests describe behaviour |
For most application code, sociable is the better default: a "unit" is a unit of behaviour (calculate an order's shipping, decide who gets a reminder), not necessarily one function. Use doubles at the edges you can't run in memory. That's also why §07 pushes I/O to the shell: once the core is pure, sociable tests of it need no doubles at all. Mocks, Stubs & Fakes goes deep on when doubles help and when they hurt.
Test through the front door
Go lets a test file in the same folder declare a different package, with _test added to the name. It then sees only what other packages see: the exported names.
package shipping_test // ← a separate package: only exported names are visible
import (
"testing"
"example.com/shop/shipping"
)
func TestCostAbroad(t *testing.T) {
got, err := shipping.Cost(8000, "US")
if err != nil || got != 1500 {
t.Errorf("Cost(8000, US) = %d, %v; want 1500, nil", got, err)
}
}Tests written this way can't depend on private helpers, so you can rename and restructure the inside of the package freely. They also double as usage examples. Use same-package tests (package shipping, as in §02) when a tricky internal function really deserves its own tests. In JavaScript the same discipline is simply: import what the module exports, and resist exporting things "just for tests".
Measure whether tests would notice
§01's widget did by hand what Mutation testing tools do automatically: make one small change to the code (>= → >, 500 → 0, delete a line), run the tests, and see whether any fails. A change no test notices is a surviving mutant, a concrete list of "this behaviour is unchecked". In §01, the off-by-one survived even at 100% coverage until the boundary test was added.
For JavaScript, Stryker (@stryker-mutator/core) is the established tool and has a Vitest runner. Go has community tools rather than an official one. Mutation runs are slow (the suite runs once per mutant), so teams usually run them on changed files only, or nightly. Read the survivors like a code review: some are real gaps, some are equivalent changes no test could catch.
Determinism: red must mean broken
A test that fails one run in fifty teaches everyone to click "re-run". The usual causes in unit tests are the real clock (§08), randomness, shared state (§05, §11), time zones and test order. Fixes that belong in every suite:
- Pass seeds and print them.
go test -shuffle=onprints the order it used (-test.shuffle 1793396099455202048); re-run a failure with-shuffle=1793396099455202048. fast-check prints itsseedthe same way (§12). - Pin the environment. Run tests with
TZ=UTC(or set it in a Vitest setup file /t.Setenv), and don't depend on the machine's language settings when formatting numbers and dates. - Run in random order regularly, so hidden dependencies between tests surface on your terms.
The Flaky Tests topic is about diagnosing the ones that slip through.
A speed budget
Our whole shipping package runs in 2 ms; a pure unit test costs microseconds. Suites get slow from a few specific things, in roughly this order:
- Real waiting: sleeps, real timers, retries with real backoff. Fake the clock (§08).
- Real I/O inside "unit" tests: a database, the network, big files. Move it behind the shell (§07) and test it separately.
- Heavy environments: every Vitest file that uses jsdom pays its setup cost (about half the run time in our component test). Use the DOM environment only for files that render components; Vitest's
projectssetting can give different folders different environments. - Process start-up: each Go package is a separate test binary, each Vitest file a separate isolated module graph. That's the price of isolation, and usually worth it.
A useful target: the unit suite for the code you're changing finishes before you've switched windows, so running it never feels like a decision.
A CI recipe
CI should run the strict versions of everything, so local runs can be quick:
name: test
on: [push, pull_request]
jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with: { go-version: stable }
- run: go vet ./...
- run: go test -race -shuffle=on -count=1 ./...
js:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22 }
- run: npm ci
- run: npx vitest run --coverage # CI=true: no new snapshots, no .onlygo vet catches test mistakes the compiler allows (§14 has one). -race and -shuffle find hidden sharing. And on CI, Vitest refuses .only and won't write missing snapshots (§06, §10), so a forgotten local shortcut fails the build instead of silently skipping tests.
Pitfalls gallery
Two kinds of bad test: ones that pass when the code is broken (dangerous, because nobody looks at green tests) and ones that fail when nothing is wrong (expensive, because people learn to ignore red). Every example below was run for real.
The assertion that runs after the test is over
it("loads Bob", () => {
loadUser().then((u) => {
expect(u.name).toBe("Bob"); // loadUser resolves to { name: "Ann" } after 10 ms
});
});What happens depends on what else is in the file, and both outcomes are bad. The test function returns straight away and Vitest records a pass. The failing expect runs 10 ms later:
- If this is the last test in the file, the run is already over: ✓, Exit code 0, and the bug goes unnoticed.
- If another test is still running, Vitest catches the stray error and blames that test. Here is the real output when we added a 50 ms test called "something slower" after it:
✓ src/a.test.js > loads Bob 1ms
✓ src/a.test.js > something slower 50ms
⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
AssertionError: expected 'Ann' to be 'Bob' // Object.is equality
❯ src/a.test.js:5:20
…
The last test to run before this error was "something slower". This means either:
- the error was thrown while Vitest was running this test, or
- the error was thrown after the test completed, and this was the most recent test at that point.
Test Files 1 passed (1)
Tests 2 passed (2)
Errors 1 errorBoth tests show ✓, yet the run fails with exit code 1. The error points at the right line, but at the wrong test, so people go hunting in "something slower". Fix: return loadUser().then(…), or better, make the test async and await the call. With return added, we got the expected AssertionError: expected 'Ann' to be 'Bob'.
Vitest 5 does catch the most common form, an un-awaited expect(promise).resolves…: it fails with Promise returned by 'expect(actual).resolves.toBe(expected)' was not awaited. The .then form above it can't see.
An expect with no matcher
it("rejects bad input", () => {
expect(() => parse("")); // ← forgot .toThrow()
});expect(x) on its own checks nothing; this test passes no matter what parse does. Same for a test whose expect sits in a callback that never runs. If a test's assertions are inside callbacks or loops, add expect.assertions(1) (exactly one must run) or expect.hasAssertions() at the top.
t.Fatal from a goroutine the test started
func TestBackground(t *testing.T) {
done := make(chan bool)
go func() {
defer close(done)
t.Fatal("oops") // ← FailNow from a goroutine the test didn't start in
}()
<-done
}t.Fatal stops the test by ending the goroutine it's called from. Called from another goroutine, it can't stop the test function itself, and the behaviour isn't reliable. go vet flags it: call to (*testing.T).Fatal from a non-test goroutine. Note that go test runs only a few vet checks automatically, and this isn't one of them, so run go vet ./... yourself (§13's CI recipe does). Fix: use t.Error in the goroutine, or send the error back over a channel and call t.Fatal in the test function.
Tests that pass alone and fail together
A package-level variable (Go) or module-level let (JS) that tests change: a cache, a counter, a "current user". Test A leaves it dirty; test B passes or fails depending on whether A ran first. In parallel, it's a data race (§11). Fix: build fresh state in each test (beforeEach, or a helper that returns a new value), and run with -shuffle=on / --sequence.shuffle in CI to flush these out.
Cleanup at the bottom of the test
it("writes a temp file", () => {
const dir = makeTempDir();
expect(writeReport(dir)).toBe(true); // ← if this fails…
removeDir(dir); // …this never runs
});A failing expect throws, so the last line is skipped, and the next test finds a leftover directory. Put teardown in afterEach (Vitest) or register it with t.Cleanup right after creating the thing (Go), §05. The same goes for vi.useRealTimers() after fake timers (§08).
Money in floating point
expect(0.1 + 0.2).toBe(0.3); // AssertionError: expected 0.30000000000000004 to be 0.3That's not a test bug, it's a code smell the test found: binary floating-point numbers can't store most decimal fractions exactly. Count money in integer cents, as this whole topic does. For genuinely decimal math (averages, physics), use toBeCloseTo (§04) and, in Go, compare with a tolerance: math.Abs(got-want) < 1e-9.
Recomputing the answer in the test
it("calculates the fee", () => {
const days = 3;
expect(lateFee(days)).toBe(Math.min(days * 25, 500)); // ← the same formula as the code
});If the formula is wrong, the test is wrong in exactly the same way, and it passes. Write the expected value as a literal you worked out by hand: expect(lateFee(3)).toBe(75). Tables (§03) make literals painless.
Testing how, not what
Asserting that an internal helper was called twice, checking a component's state variable, or snapshotting a whole rendered page. All of these break when you refactor without changing behaviour. Test inputs and outputs, query the page by role (§09), and keep snapshots small (§06). Why & What to Test has the long version.
A sleep instead of a signal
await new Promise((r) => setTimeout(r, 500)); // "should be done by now"
expect(saved).toBe(true);Too short on a slow CI machine (flaky), too long everywhere else (slow). Use fake timers when the delay is a timer (§08), findBy… / waitFor when you're waiting for the page (§09), or await the actual promise. Flaky Tests is mostly about this one.
Check yourself: predict the result
Predict the answer before you click. Each explanation points back to the section that covers it. Every behaviour here was checked by running it with Go 1.24 or Vitest 5.
Parse("") returns (0, someError). How many of your failure messages (the t.Errorf texts) does this test print?
func TestParse(t *testing.T) {
n, err := Parse("")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if n != 42 {
t.Errorf("Parse = %d, want 42", n)
}
}A package has TestCost, TestCostSmallOrder and TestReport. You run go test -run TestCost.
const got = { id: 1, note: undefined };
expect(got).toEqual({ id: 1 }); // A
expect(got).toStrictEqual({ id: 1 }); // BWith fake timers on and onType = debounce(search, 300):
onType("g");
vi.advanceTimersByTime(200);
onType("go");
vi.advanceTimersByTime(200);
// how many times has search been called?loadUser() resolves to { name: "Ann" } after 10 ms. This is the only test in its file.
it("loads Bob", () => {
loadUser().then((u) => {
expect(u.name).toBe("Bob");
});
});t.Cleanup(func() { t.Log("A") })
t.Cleanup(func() { t.Log("B") })
t.Log("body")Which query keeps passing when a designer renames CSS classes, and fails if someone replaces the <button> with a clickable <div>?
Cheat sheet
[]struct{…} + t.Run(tc.name, …). Vitest: it.each([...])("$name", …). Always include the boundary rows.toBe = same value or same object. toEqual = same contents. toStrictEqual also checks undefined fields and classes.afterEach in Vitest, t.Cleanup in Go (last registered runs first). Never at the bottom of the test body.t, so failures point at the caller.now in. Write to an io.Writer.vi.useFakeTimers() + advanceTimersByTime (the …Async variant if the code awaits). Go: testing/synctest (1.25+).getByRole("button", { name: "Subscribe" }) first; test ids last. Set up RTL cleanup in Vitest.| I want to… | Go | Vitest |
|---|---|---|
| Run everything | go test ./... | npx vitest run |
| Re-run on save | your editor / a watcher | npx vitest |
| Run one test or row | -run '^TestCost$/^abroad$' | -t "abroad", or it.only locally |
| Skip the cache / repeat | -count=1 / -count=100 | (no cache) |
| Coverage | -cover, -coverprofile=c.out + go tool cover -html=c.out | --coverage (@vitest/coverage-v8) |
| Find data races | -race | n/a |
| Random order | -shuffle=on (prints the seed) | --sequence.shuffle |
| Golden files / snapshots | testdata/ + your -update flag | toMatchFileSnapshot, update with -u |
| Benchmark | -bench . -benchmem, for b.Loop() | vitest bench (experimental) |
| Fuzz / properties | go test -fuzz FuzzX; failures saved to testdata/fuzz | fast-check fc.assert(fc.property(…)) |
| Compare structs, slices | ==, slices.Equal, cmp.Diff(want, got) | toEqual / toStrictEqual |
| Fake the clock | pass now in, or synctest.Test | vi.useFakeTimers(), vi.setSystemTime(…) |