Skip to content
Software Atlas

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.

⏱ ~50 min read16 sections8 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA unit test is a tiny program that calls one piece of your code with known inputs and checks the answer. It runs in milliseconds, so you can run hundreds of them every time you save.

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.

A live test runner
Real JavaScript tests, running in your browser against the function shown first. Plant a bug, switch tests on and off, and watch what fails.
shipping.jsoriginal
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 abroad
5 if (totalCents >= 5000) return 0; // free from $50
6 return 500; // $5
7}
shipping.test.js4/4 pass
Plant a bug
All 4 pass. Now plant a bug below.

Try these three things, in order:

  1. 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.)
  2. 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.
  3. 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.
  4. Still in the coverage view, untick charges $15 abroad. Line 4 turns yellow: its if still runs in every test, but no test reaches its return 1500 any 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 test and 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.
Foundations§02

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.

shipping/shipping.go
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).

shipping/shipping_test.go
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

What a test runner does, in four steps
1. Find
go test · files ending in _test.go
vitest · files named *.test.js / *.spec.js
2. Collect
go test · every func TestXxx(t *testing.T)
vitest · every it(…) / test(…) call
3. Run
go test · each test; t.Error marks it failed
vitest · each test; a thrown error marks it failed
4. Report
go test · ok / FAIL + file:line + your message
vitest · ✓ / × + diff + code frame
A failure doesn't stop the other tests: each one passes or fails on its own, and the report lists every failure at once.

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

Without -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
FAIL

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

Foundations§03

Table-driven tests and subtests

In one sentenceWhen several tests differ only in their inputs and expected answer, write the check once and list the cases in a table.

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.

shipping/table_test.go
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.

A table-driven test, row by row
Three cases, one check. Watch which row finds the bug.
Code
1func TestCost(t *testing.T) {
2 tests := []struct {
3 name string
4 total int
5 country string
6 want int
7 }{
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}
the table
○ free at exactly 50
○ small domestic order
○ abroad
current subtest
—
go test output
nothing yet
The table: a list of cases, one per row. Each row has a name, the inputs and the answer we want. (The real Cost has a bug: it uses > where it should use >=.)
1 / 8

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
FAIL

Only 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.002s

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

Practitioner§04

Assertions that explain the failure

In one sentenceA test is only as useful as its failure message. When it goes red at 2 a.m. in a build log, the message should say what was called, what came back, and what was expected, with no debugger needed.

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:

c_test.go
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:

MatcherAsksUse 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 classesdata 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:

Which matcher passes?
Pick a pair of values. Every result and message below was produced by 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 only

A 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 matcher

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

Practitioner§05

Setup, teardown and helpers

In one sentenceEvery test should start from the same known state and leave nothing behind. Setup builds that state; teardown removes it, even when the test fails.

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:

Setup and teardown, in order
Step through the hooks. Switch languages to compare Vitest hooks with Go's t.Cleanup.
Code
1describe("cart", () => {
2 let cart;
3 beforeAll(() => { /* open price list */ });
4 beforeEach(() => { cart = []; });
5 afterEach(() => { /* tidy up */ });
6 afterAll(() => { /* close price list */ });
7
8 it("starts empty", () => {
9 expect(cart).toEqual([]);
10 });
11
12 describe("with one item", () => {
13 beforeEach(() => { cart.push("tea"); });
14 it("has one item", () => {
15 expect(cart).toHaveLength(1);
16 });
17 });
18});
cart
(not set yet)
running now
beforeAll
order so far
beforeAll
beforeAll runs once, before the first test in this describe. Use it for expensive things that tests only read.
1 / 9

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:

life_test.go
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:

h_test.go
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
}
✕ Without t.Helper(): points inside the helper
--- FAIL: TestSum (0.00s)
    h_test.go:8: got 4, want 5
✓ With t.Helper(): points at the failing call
--- FAIL: TestSum (0.00s)
    h_test.go:14: got 4, want 5

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

Practitioner§06

testdata and golden files

In one sentenceWhen the expected output is big (a receipt, an HTML email, a JSON response), don't paste it into the test. Store it in a file, compare against the file, and regenerate the file when the output changes on purpose.

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:

receipt/receipt_test.go
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)
    }
}
The golden-file loop
FIRST TIME
Run with -update. The test writes today's output to testdata/receipt.golden. You read it once and commit it.
EVERY RUN
The test renders again and compares with the file. Same bytes → pass. Any difference → fail, showing both versions.
OUTPUT CHANGED
A bug? Fix the code. On purpose? Run -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.002s

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

The 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:

receipt.test.js
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.

Practitioner§07

Pure logic vs side effects: shaping code to test

In one sentenceCode that only computes is easy to test: call it, check the answer. Code that reaches out (to the database, the network, the clock) needs the outside world set up first. So keep the decisions in the first kind and make the second kind thin.

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:

What does it take to unit-test this?
The same feature twice: overdue library books get a reminder and a fee of 25¢ per day late, capped at $5.
reminders.go3 logic · 6 I/O · 1 clock
func SendReminders(db *sql.DB, mailer *Mailer) error {
rows, err := db.Query("SELECT email, due FROM loans")I/O
if err != nil {
return err
}
defer rows.Close()I/O
for rows.Next() {I/O
var email string
var due time.Time
if err := rows.Scan(&email, &due); err != nil {I/O
return err
}
late := time.Since(due)clock
if late <= 0 {logic
continuelogic
}
fee := min(int(late.Hours()/24)*25, 500)logic
if err := mailer.Send(email, fee); err != nil {I/O
return err
}
}
return rows.Err()I/O
}
To unit-test the fee rule you need…
  • ✗ 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
The two lines of fee logic are buried between database calls, mail calls and the real clock.

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:

loans/loans_test.go
// 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:

loans/report.go + 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.

Practitioner§08

Time in tests: fake timers and synctest

In one sentenceNever make a test wait for time to pass. Take control of the clock and move it forward yourself.

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.

debounce.js
// 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.

debounce.test.js
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:

Drive the clock yourself
onType = debounce(search, 300). Type, then move time forward. With fake timers, time only moves when the test says so.
fake clock
0 ms
Frozen until you advance it.
pending timer
none
search() calls0
none yet
the test you just wrote
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 between

setSystemTime 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:

debounce.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
    }
}
debounce_test.go
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.012s

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

Practitioner§09

Testing React components the way users use them

In one sentenceTest a component the way a user meets it: find the field by its label, type, press the button by its name, read what appears. Don't reach for CSS classes or component internals.

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

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

vitest.config.js
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
  },
});
test/setup.js
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 rendered

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

Subscribe.jsx
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>
  );
}
Subscribe.test.jsx
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 43ms

Notice 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:

Which query survives the change?
Change the markup the way real projects do. Each query runs against the real DOM of the preview.
markup
<form>
  <label for="email">Email</label>
  <input id="email">
  <button class="btn-primary" data-testid="subscribe-btn">Subscribe</button>
</form>
what the user sees
EmailSubscribe
container.querySelector(".btn-primary")
… running
screen.getByTestId("subscribe-btn")
… running
screen.getByText("Subscribe")
… running
screen.getByRole("button", { name: "Subscribe" })
… running
The original markup.

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 2

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

Practitioner§10

Running tests: filters, the cache, coverage, watch mode

In one sentenceMost days you run a handful of tests in a tight loop while you edit, then the whole suite before you push. Both tools have flags for each.

The everyday commands

I want to…GoVitest
Run everything oncego test ./...npx vitest run
Re-run on every save(use your editor or a file watcher)npx vitest (Watch mode)
See every test namego test -v ./...--reporter=verbose
Run tests matching a name-run '^TestCost$' (§03)-t "abroad"
Run one file / packagego test ./shippingnpx 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.002s

The 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 red

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

Practitioner§11

Parallel tests and the race detector

In one sentenceRunning tests side by side makes a suite faster, and it also flushes out code (and tests) that secretly share state. Turn on the race detector so you find out which.

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.concurrent or it.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):

par_test.go
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
        })
    }
}
Four 100 ms subtests (measured on this machine, 4 CPUs)
without t.Parallel(): one after another
a
a
b
b
c
c
d
d
with t.Parallel(): all four at once
a
a
b
b
c
c
d
d
Without 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.)

cache_test.go
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)
FAIL

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

Staff-level§12

Benchmarks and fuzzing, in brief

In one sentenceTwo more things live in the same _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:

bench_test.go
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
PASS

Reading 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.go (buggy)
// 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
}
truncate_test.go
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
FAIL

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

truncate.test.js
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§13

Staff level: what a unit is, and suites that stay fast and honest

In one sentenceA unit test suite is worth what it costs to trust. It has to be fast enough to run constantly, deterministic enough that red means broken, and sharp enough to notice real bugs. Each of those is a design choice.

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.

SolitarySociable
A test fails whenthis one class misbehavesthis behaviour is broken, wherever the bug is
Breaks on refactorsoften: the doubles encode who calls whomrarely: only the inputs and outputs are fixed
Finds the bug's locationpreciselyyou may need to look one level down
Typical resultmany doubles, tests mirror the code's structurefewer 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.

shipping/cost_test.go
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=on prints the order it used (-test.shuffle 1793396099455202048); re-run a failure with -shuffle=1793396099455202048. fast-check prints its seed the 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:

  1. Real waiting: sleeps, real timers, retries with real backoff. Fake the clock (§08).
  2. Real I/O inside "unit" tests: a database, the network, big files. Move it behind the shell (§07) and test it separately.
  3. 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 projects setting can give different folders different environments.
  4. 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:

.github/workflows/test.yml
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 .only

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

Practitioner§14

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.

#1

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 error

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

#2

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.

#3

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.

#4

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.

#5

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

#6

Money in floating point

expect(0.1 + 0.2).toBe(0.3); // AssertionError: expected 0.30000000000000004 to be 0.3

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

#7

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.

#8

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.

#9

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.

Practitioner§15

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.

QUESTION 1 / 7
Errorf or Fatalf?

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)
    }
}
QUESTION 2 / 7
What does -run select?

A package has TestCost, TestCostSmallOrder and TestReport. You run go test -run TestCost.

QUESTION 3 / 7
Strict or not?
const got = { id: 1, note: undefined };
expect(got).toEqual({ id: 1 });       // A
expect(got).toStrictEqual({ id: 1 }); // B
QUESTION 4 / 7
How many searches?

With 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?
QUESTION 5 / 7
Pass or fail?

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");
  });
});
QUESTION 6 / 7
Cleanup order
t.Cleanup(func() { t.Log("A") })
t.Cleanup(func() { t.Log("B") })
t.Log("body")
QUESTION 7 / 7
The best query

Which query keeps passing when a designer renames CSS classes, and fails if someone replaces the <button> with a clickable <div>?

Answered 0 / 7
Foundations§16

Cheat sheet

🎯
A test must be able to failIf you plant the bug, it goes red, and the message says which input, what came back and what was wanted.
📋
Tables for many casesGo: []struct{…} + t.Run(tc.name, …). Vitest: it.each([...])("$name", …). Always include the boundary rows.
⚖️
toBe vs toEqualtoBe = same value or same object. toEqual = same contents. toStrictEqual also checks undefined fields and classes.
🧹
Teardown that always runsafterEach in Vitest, t.Cleanup in Go (last registered runs first). Never at the bottom of the test body.
📍
t.Helper()First line of every Go helper that takes t, so failures point at the caller.
🧮
Pure core, thin shellDecisions in functions that take data and return data. Pass now in. Write to an io.Writer.
⏱️
Never wait for timevi.useFakeTimers() + advanceTimersByTime (the …Async variant if the code awaits). Go: testing/synctest (1.25+).
♿
Query like a usergetByRole("button", { name: "Subscribe" }) first; test ids last. Set up RTL cleanup in Vitest.
I want to…GoVitest
Run everythinggo test ./...npx vitest run
Re-run on saveyour editor / a watchernpx 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-racen/a
Random order-shuffle=on (prints the seed)--sequence.shuffle
Golden files / snapshotstestdata/ + your -update flagtoMatchFileSnapshot, update with -u
Benchmark-bench . -benchmem, for b.Loop()vitest bench (experimental)
Fuzz / propertiesgo test -fuzz FuzzX; failures saved to testdata/fuzzfast-check fc.assert(fc.property(…))
Compare structs, slices==, slices.Equal, cmp.Diff(want, got)toEqual / toStrictEqual
Fake the clockpass now in, or synctest.Testvi.useFakeTimers(), vi.setSystemTime(…)
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in TestingMocks, Stubs & Fakes
Read →