Skip to content
Software Atlas

Mocks, Stubs & Fakes

Test doubles: the five kinds and when to use each, building them by hand and with gomock, Vitest's vi.fn / vi.spyOn / vi.mock, faking HTTP and time — and when mocking hurts more than it helps.

⏱ ~45 min read14 sections7 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA test double is a stand-in you plug into your code during a test, in place of something slow, risky or unpredictable — a payment API, an email server, the clock.

Here is the example we'll use the whole way through. A checkout function takes an order, charges the customer's card through a payments provider, then emails a receipt. In a test you really don't want either of those to happen. So the same checkout code gets different things plugged in:

Same code, different plugs
The checkout code never changes. Only what's plugged into it does.
Code under test
checkout.Service
charges the card, then emails a receipt
Payments
Payment provider's API — charges a real card, ~300 ms over the internet
Mailer
Email server — a real customer gets a real email
Logger
Writes to the server's log
Real run: hundreds of milliseconds, a real charge, a real email, and the result depends on the network and on someone else's server.

The five kinds, in one line each

You'll hear "mock" used for all of them. They're actually different tools, and picking the right one is most of the skill (§03 goes through each):

KindWhat it doesCrash-test version
DummyFills a parameter; never usedAn empty passenger seat
StubReturns a canned answerA recorded crash you replay
SpyRecords calls; you read them afterwardsSensors inside the dummy
MockKnows which calls to expect; fails the test itselfAn inspector with a checklist
FakeA small working version (memory instead of a database)A dummy built with real joints and weight

The one warning to remember

A double is a claim about how the real thing behaves. If the claim is wrong, your test passes and production fails. The rest of this topic is about building doubles that are cheap, and keeping them honest: hand-written vs generated (§05–§06), Vitest's tools (§07), HTTP (§08), time (§09), when mocking hurts (§10), and contract tests that check the double against reality (§11).

Foundations§02

Why not just use the real thing?

In one sentenceYou replace a Dependency in a test when using the real one would make the test slow, random, dangerous, or unable to reach the case you care about.

First, two words we'll use constantly. The code under test is the thing the test is checking — our checkout. Its dependencies are everything it calls that isn't itself: the payments provider, the mailer, the clock.

A worked example with numbers

Say your shop has 400 tests that go through checkout. Against the provider's test environment (a sandbox), one charge takes around 300 ms and the receipt email another 200 ms — rough but typical numbers for calls over the internet.

  • Real dependencies: 400 × 0.5 s = 200 seconds per test run. Nobody runs that before every commit.
  • Doubles: 400 × about 1 ms = under half a second. You run it every time you save a file.

Speed isn't the only problem. Here are all four:

1

Slow

Network calls take hundreds of milliseconds. Multiply by hundreds of tests, and the suite stops being something you run while you work.

2

Random

The sandbox is down, the Wi-Fi drops, the clock ticks past midnight. The test fails for reasons unrelated to your code: a Flaky test.

3

Has side effects

A real charge, a real email to a real customer, a row written to a shared database that the next test trips over.

And the fourth, which is the one people forget: some cases are hard to reach for real. How do you make a real payments provider answer "card declined", or take 30 seconds and then time out, on exactly the run you want? With a double it's one line: "this time, answer declined". Error paths are where bugs hide, and doubles are how you test them.

What you give up

A double makes the test fast and predictable by replacing reality with your belief about reality. If your belief is wrong — the real API returns 402 where you assumed 400, or rejects amounts your fake accepts — the test is green and production is broken.

What not to replace

Not everything your code touches needs a double. Don't replace:

  • Your own plain logic. If checkout calls a totalWithTax(items) helper that just does arithmetic, let it run. Replacing it only makes the test check that the helper was called, not that the total is right.
  • Simple values — dates you construct, structs, arrays, maps. Use real ones.
  • The code under test itself. If you find yourself replacing half of checkout to test the other half, the function is doing too much.

Replace things at the edges of your program: things that cross the network, touch the disk, read the clock, or produce random numbers.

Foundations§03

Five kinds of double

In one sentenceThe kinds differ in two things: what the double does when called (nothing, a canned answer, real behaviour) and what the test checks (the result, or the calls).

The names come from Gerard Meszaros's book xUnit Test Patterns (2007), which also coined "test double". Day to day people say "mock" for all five, and that's fine in conversation — but when you're choosing what to write, the differences matter.

The code we're testing

checkout/checkout.go
// The two things checkout needs, described from checkout's side.
type Payments interface {
    Charge(ctx context.Context, card string, cents int) (chargeID string, err error)
}
type Mailer interface {
    SendReceipt(ctx context.Context, to, chargeID string) error
}

var ErrDeclined = errors.New("card declined")

type Service struct {
    pay  Payments
    mail Mailer
    log  *slog.Logger
}

func NewService(pay Payments, mail Mailer, log *slog.Logger) *Service {
    return &Service{pay: pay, mail: mail, log: log}
}

func (s *Service) Checkout(ctx context.Context, o Order) (string, error) {
    if o.Cents <= 0 {
        return "", fmt.Errorf("invalid amount %d", o.Cents)
    }
    id, err := s.pay.Charge(ctx, o.Card, o.Cents)
    if err != nil {
        return "", fmt.Errorf("charge: %w", err)
    }
    if err := s.mail.SendReceipt(ctx, o.Email, id); err != nil {
        s.log.Warn("receipt not sent", "err", err) // the order still succeeded
    }
    return id, nil
}

The JS version receives its dependencies as one object and pulls them out with destructuring: { payments, mailer, log } in the parameter list means "take those three properties from the second argument". Passing dependencies in like this, instead of importing them, is Dependency injection — its own topic — and it's what makes every double below possible.

Each kind, on this example

Dummy — "just fill the slot." Testing that a zero amount is rejected? Checkout returns before it touches payments or mail, so pass nil (Go) or {} (JS). If the code ever did use it, the test would crash — which is a useful alarm in itself.

Stub — "always answer this." To test the declined path, give Checkout a Payments whose Charge always returns ErrDeclined. The test then checks what checkout did with that answer: it returned an error.

Spy — "write down what happened." To check "a declined card gets no receipt", plug in a mailer that appends every call to a list. After Checkout returns, the test looks at the list: it must be empty.

Mock — "here's what I expect; complain if it's different." Like a spy, but the expectations are set before the code runs, and the mock fails the test on its own the moment an unexpected call arrives, or at the end if an expected one never came. Go's mocking libraries work this way (§06).

Fake — "a tiny working version." A FakePayments that keeps totals in a map, declines one special card number, and hands out IDs ch_1, ch_2… The test checks the end state ("this card was charged 1500 in total") rather than the calls.

Try the picker. Each kind shows its code for this example in Go and JS:

Which double do I need?
Answer for one dependency in one test, or jump straight to a kind.
Questions
  1. In this test, will the code actually call this dependency?
Result
Answer the questions on the left.

Results or calls?

The biggest difference is between the last two. A fake leads you to a state check: look at the result. A mock leads you to an interaction check: look at the calls. Both are useful; they fail for different reasons. A state check only fails when the outcome is wrong. An interaction check also fails when the outcome is right but reached a different way — §10 lets you watch that happen.

Practitioner§04

Seams: small interfaces where you use them

In one sentenceA double can only go where the code has a Seam — a spot where the dependency is passed in rather than built inside. In Go, the seam is a small interface declared by the code that uses it.

Dependency Injection explains how to pass dependencies in. This page is about the shape of the seam, because that shape decides how painful every double will be.

Declare the interface where it's used, and keep it tiny

Imagine the payments provider's Go client has 30 methods: Charge, Refund, ListCharges, CreateCustomer… Checkout uses exactly one. So checkout declares exactly one:

checkout/checkout.go
// Declared in package checkout — the CONSUMER — not in the provider's package.
type Payments interface {
    Charge(ctx context.Context, card string, cents int) (chargeID string, err error)
}

That one decision pays off in every test: a stub for a one-method interface is three lines. If checkout instead depended on a 30-method interface, every fake would need 30 methods, 29 of them panic("not used").

It works because Go interfaces are implicit: the provider's *payclient.Client never mentions checkout.Payments. It has a Charge method with the right signature, so it fits. The provider doesn't even need to know checkout exists. (Go Idioms calls this "accept interfaces, return structs".)

Make the compiler check your doubles

Since nothing says "implements", a typo in a fake's method signature would only show up where the fake is used. One line turns it into a build error right next to the fake:

var _ Payments = (*FakePayments)(nil) // ← fails to compile if FakePayments stops fitting

Here's what you get when someone changes cents to int64 in the fake but not in the interface (real compiler output, Go 1.24):

checkout/fake.go:15:18: cannot use (*FakePayments)(nil) (value of type *FakePayments) as Payments value in variable declaration: *FakePayments does not implement Payments (wrong type for method Charge)
		have Charge(context.Context, string, int64) (string, error)
		want Charge(context.Context, string, int) (string, error)

One-off stubs without a new type: function adapters

Writing a named struct for every canned answer gets old. The standard library's trick from http.HandlerFunc works for any one-method interface. (The test below also uses two helpers from the next section: quiet, a logger that throws everything away, and spyMailer, a mailer that just records who it "sent" to.)

checkout/doubles_test.go
// PaymentsFunc lets a plain function act as a Payments.
type PaymentsFunc func(ctx context.Context, card string, cents int) (string, error)

func (f PaymentsFunc) Charge(ctx context.Context, card string, cents int) (string, error) {
    return f(ctx, card, cents) // ← the method just calls the function
}

func TestTimeoutIsReported(t *testing.T) {
    slow := PaymentsFunc(func(context.Context, string, int) (string, error) {
        return "", context.DeadlineExceeded // ← this test's canned answer
    })
    svc := NewService(slow, &spyMailer{}, quiet)
    _, err := svc.Checkout(context.Background(), Order{Email: "a@x", Card: "tok_visa", Cents: 1500})
    if !errors.Is(err, context.DeadlineExceeded) {
        t.Fatalf("err = %v, want DeadlineExceeded", err)
    }
}

Seams in JavaScript

JavaScript is duck-typed: any object with a charge method works as payments. No interface to declare, so a double is just { charge: async () => "ch_1" }. In TypeScript, interfaces are structural like Go's, so the same object type-checks without an implements clause.

The JS trap is the opposite one. It's so easy to write import { charge } from "./payments.js" at the top of checkout.js that many codebases have no seam at all: checkout grabs the real module itself. You can still test it, by having the test runner swap the module — that's vi.mock, in §07. It works, but it's a heavier tool with sharper edges than passing the dependency in.

Practitioner§05

Hand-written doubles

In one sentenceMost doubles are a few lines of ordinary code. Write them by hand first; reach for a library only when that gets repetitive.

Here is a complete Go test file for checkout using four kinds from §03. Every double is a normal type with normal methods.

checkout/doubles_test.go
package checkout

// ---- stub: always the same answer ----
type declinedPayments struct{}

func (declinedPayments) Charge(context.Context, string, int) (string, error) {
    return "", ErrDeclined
}

// ---- spy: remembers every call ----
type spyMailer struct {
    mu    sync.Mutex
    calls []string
}

func (m *spyMailer) SendReceipt(_ context.Context, to, chargeID string) error {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.calls = append(m.calls, to+" "+chargeID)
    return nil
}

// ---- fake: a tiny working gateway ----
type FakePayments struct {
    mu      sync.Mutex
    charged map[string]int // card → total cents
    n       int
}

func NewFakePayments() *FakePayments { return &FakePayments{charged: map[string]int{}} }

func (f *FakePayments) Charge(_ context.Context, card string, cents int) (string, error) {
    f.mu.Lock()
    defer f.mu.Unlock()
    if card == "tok_declined" { // one magic card number that always fails
        return "", ErrDeclined
    }
    f.n++
    f.charged[card] += cents
    return fmt.Sprintf("ch_%d", f.n), nil
}

func (f *FakePayments) Total(card string) int {
    f.mu.Lock()
    defer f.mu.Unlock()
    return f.charged[card]
}

var quiet = slog.New(slog.DiscardHandler) // a logger that throws everything away

func TestRejectsZeroAmount(t *testing.T) {
    svc := NewService(nil, nil, quiet) // ← dummies: this path never touches them
    if _, err := svc.Checkout(context.Background(), Order{Cents: 0}); err == nil {
        t.Fatal("want error for 0 cents")
    }
}

func TestDeclinedSendsNoReceipt(t *testing.T) {
    mail := &spyMailer{}
    svc := NewService(declinedPayments{}, mail, quiet) // ← stub + spy
    _, err := svc.Checkout(context.Background(), Order{Email: "ana@example.com", Card: "tok_visa", Cents: 1500})
    if !errors.Is(err, ErrDeclined) {
        t.Fatalf("err = %v, want ErrDeclined", err)
    }
    if len(mail.calls) != 0 {
        t.Errorf("receipts sent: %v, want none", mail.calls)
    }
}

func TestCheckoutChargesAndSendsReceipt(t *testing.T) {
    pay := NewFakePayments() // ← fake
    mail := &spyMailer{}
    svc := NewService(pay, mail, quiet)

    id, err := svc.Checkout(context.Background(), Order{Email: "ana@example.com", Card: "tok_visa", Cents: 1500})
    if err != nil {
        t.Fatal(err)
    }
    if got := pay.Total("tok_visa"); got != 1500 { // ← state check: the end result
        t.Errorf("charged %d cents, want 1500", got)
    }
    if want := []string{"ana@example.com " + id}; !slices.Equal(mail.calls, want) {
        t.Errorf("receipts = %v, want %v", mail.calls, want)
    }
}

Real output from go test -v (Go 1.24, measured on this machine):

=== RUN   TestRejectsZeroAmount
--- PASS: TestRejectsZeroAmount (0.00s)
=== RUN   TestDeclinedSendsNoReceipt
--- PASS: TestDeclinedSendsNoReceipt (0.00s)
=== RUN   TestCheckoutChargesAndSendsReceipt
--- PASS: TestCheckoutChargesAndSendsReceipt (0.00s)
PASS
ok  	example.com/shop/checkout	0.003s

And if a dummy does get used — say someone changes the zero-amount test to 1500 cents but leaves the nils in — Go stops loudly instead of silently passing. Note that a panic doesn't fail just this one test: it crashes the whole test binary for the package, so the tests after it never run at all.

--- FAIL: TestDummyUsed (0.00s)
panic: runtime error: invalid memory address or nil pointer dereference [recovered]

The same doubles in JavaScript

In JS a double is usually just an object literal. No types to declare, no lock needed (one thread, see Async / Await):

checkout.test.js
import { it, expect } from "vitest";
import { checkout, DeclinedError } from "./checkout.js";

class FakePayments {                       // fake: real (tiny) behaviour
  charged = new Map();
  n = 0;
  async charge(card, cents) {
    if (card === "tok_declined") throw new DeclinedError("declined");
    this.charged.set(card, (this.charged.get(card) ?? 0) + cents);
    return `ch_${++this.n}`;
  }
}

function spyMailer() {                     // spy: remembers calls
  const calls = [];
  return { calls, async sendReceipt(to, id) { calls.push([to, id]); } };
}

const order = { email: "ana@example.com", card: "tok_visa", cents: 1500 };

it("charges the card and sends one receipt", async () => {
  const payments = new FakePayments();
  const mailer = spyMailer();
  const id = await checkout(order, { payments, mailer, log: console });

  expect(payments.charged.get("tok_visa")).toBe(1500);    // state check
  expect(mailer.calls).toEqual([["ana@example.com", id]]);
});

it("sends no receipt when the card is declined", async () => {
  const payments = { charge: async () => { throw new DeclinedError("declined"); } }; // stub
  const mailer = spyMailer();
  await expect(checkout(order, { payments, mailer, log: console })).rejects.toBeInstanceOf(DeclinedError);
  expect(mailer.calls).toEqual([]);
});

When hand-written stops scaling

Hand-written doubles are easy to read and never surprise you. They get tedious when an interface has many methods, or when dozens of tests each need slightly different canned answers. Two common fixes:

  1. Give the fake knobs. Add a field like FailWith error to FakePayments; when it's set, Charge returns it. One fake, many scenarios.
  2. Generate the doubles. Tools can write a mock for any interface. That's §06 — along with why the generated kind brings its own costs.
Practitioner§06

Generated mocks: gomock and mockery

In one sentenceA mock generator reads an interface and writes the mock type for you. You then say, in each test, which calls you expect and what they return.

Go has two popular tools. Both are fine; teams usually pick one and stick with it.

  • gomock — go.uber.org/mock (a maintained fork of the original golang/mock). Its mockgen command writes the mocks.
  • mockery — writes mocks built on the popular testify assertion library.

Generating with mockgen

The generated code imports the gomock library, so add it to your module once:

go get go.uber.org/mock@v0.6.0

Then put a //go:generate comment at the top of the file that holds the interfaces, then run go generate ./...:

checkout/checkout.go
//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -source=checkout.go -destination=mock_checkout_test.go -package=checkout

package checkout

For each interface you get a type: Payments → MockPayments, created with NewMockPayments(ctrl).

Writing a test with expectations

checkout/gomock_test.go
func TestCheckoutWithGomock(t *testing.T) {
    ctrl := gomock.NewController(t) // ← checks all expectations when the test ends
    pay := NewMockPayments(ctrl)
    mail := NewMockMailer(ctrl)

    pay.EXPECT().Charge(gomock.Any(), "tok_visa", 1500).Return("ch_1", nil)
    mail.EXPECT().SendReceipt(gomock.Any(), "ana@example.com", "ch_1").Return(nil)

    svc := NewService(pay, mail, quiet)
    if _, err := svc.Checkout(context.Background(), Order{Email: "ana@example.com", Card: "tok_visa", Cents: 1500}); err != nil {
        t.Fatal(err)
    }
    // no assertions about calls here: the controller does them
}

Read pay.EXPECT().Charge(gomock.Any(), "tok_visa", 1500).Return("ch_1", nil) as: "expect one call to Charge with any context, "tok_visa" and 1500; when it comes, return "ch_1", nil". gomock.Any() matches anything — used here for the context, which the test doesn't care about.

Now break it. Suppose the test expected 15 instead of 1500. Real output (gomock v0.6.0, paths shortened):

--- FAIL: TestFailWrongArgs (0.00s)
    checkout.go:41: Unexpected call to *checkout.MockPayments.Charge([context.Background tok_visa 1500]) at checkout.go:41 because:
        expected call at gomockfail_test.go:14 doesn't match the argument at index 2.
        Got: 1500 (int)
        Want: is equal to 15 (int)
    controller.go:97: missing call(s) to *checkout.MockPayments.Charge(is anything, is equal to tok_visa (string), is equal to 15 (int))
    controller.go:97: missing call(s) to *checkout.MockMailer.SendReceipt(is anything, is equal to ana@example.com (string), is equal to ch_1 (string))
    controller.go:97: aborting test due to missing call(s)

Two kinds of failure, both typical of a strict mock: an unexpected call (the code called something the test didn't announce) and missing calls (announced, never made). The mailer's expectation is "missing" only because the test stopped at the first problem.

More precise expectations

gomock.InOrder( // ← calls must happen in this order
    pay.EXPECT().Charge(gomock.Any(), "tok_visa", 1500).Return("ch_1", nil).Times(1),
    mail.EXPECT().SendReceipt(gomock.Any(), "ana@example.com", "ch_1").Return(nil),
)

// Compute the answer from the arguments instead of a fixed Return:
pay.EXPECT().Charge(gomock.Any(), gomock.Any(), gomock.Any()).
    DoAndReturn(func(_ context.Context, card string, cents int) (string, error) {
        return "ch_" + card, nil
    })

Times(1) is the default (written out here for clarity); Times(0) means "must not be called", and AnyTimes() turns the expectation into a plain stub. Each of these makes the test check more — which, as §10 shows, cuts both ways.

The same with mockery

mockery is a separate command you install once (go install github.com/vektra/mockery/v3@v3.9.0), configured by a file instead of comments. Running mockery in the module root generates mocks_test.go next to the interfaces (mockery v3 defaults). The generated mocks use testify, so the module also needs go get github.com/stretchr/testify, and the test imports github.com/stretchr/testify/mock:

packages:
  example.com/shop/checkout:
    interfaces:
      Payments:
      Mailer:

Almost the same shape: mock.Anything instead of gomock.Any(), and the mock takes t directly instead of a controller. Both tests above were run with mockery v3.9.0 / testify v1.12.1 and gomock v0.6.0, and the generated code compiles and runs on Go 1.24. One wrinkle: the mockery v3.9.0 tool itself requires a newer Go (Go 1.26 at the time of writing), so on an older Go, go install quietly downloads a newer toolchain to build it.

Hand-written or generated?

Hand-written (§05)Generated
Reading a testPlain Go; nothing to learnNeed to know the library's API
Interface changesFix each fake by hand (the compiler tells you where)Re-run go generate
Default styleFakes and state checksExpected calls (interaction checks)
Breaks on harmless refactorsRarelyOften, if expectations are exact
Good forMost dependencies, especially small interfacesChecking that a call happened (or didn't), many-method interfaces you can't shrink
Practitioner§07

Vitest: vi.fn, vi.spyOn and vi.mock

In one sentencevi.fn() makes a function that records its calls and returns whatever you tell it to. vi.spyOn does the same to an existing method. vi.mock replaces a whole module for code that imports it directly.

Vitest calls all of these "mocks". In §03's terms, a vi.fn() is a spy that you can also stub: it never fails a test by itself — your expect(...) lines do the checking. (Jest's jest.fn, jest.spyOn and jest.mock work almost identically.)

vi.fn(): a stub and a spy in one

checkout.test.js
import { describe, it, expect, vi } from "vitest";
import { checkout, DeclinedError } from "./checkout.js";

const order = { email: "ana@example.com", card: "tok_visa", cents: 1500 };

describe("checkout", () => {
  it("charges the card and sends one receipt", async () => {
    const payments = { charge: vi.fn().mockResolvedValue("ch_1") };  // ← stub: resolves "ch_1"
    const mailer = { sendReceipt: vi.fn().mockResolvedValue(undefined) };

    const id = await checkout(order, { payments, mailer, log: console });

    expect(id).toBe("ch_1");
    expect(payments.charge).toHaveBeenCalledWith("tok_visa", 1500); // ← spy: check the call
    expect(mailer.sendReceipt).toHaveBeenCalledTimes(1);
    expect(mailer.sendReceipt).toHaveBeenCalledWith("ana@example.com", "ch_1");
  });

  it("sends no receipt when the card is declined", async () => {
    const payments = { charge: vi.fn().mockRejectedValue(new DeclinedError("declined")) };
    const mailer = { sendReceipt: vi.fn() };

    await expect(checkout(order, { payments, mailer, log: console })).rejects.toBeInstanceOf(DeclinedError);
    expect(mailer.sendReceipt).not.toHaveBeenCalled();
  });
});

When a call doesn't match, Vitest shows a diff of what was expected and what actually arrived (real output, Vitest 5.0.5):

AssertionError: expected "vi.fn()" to be called with arguments: [ 'tok_visa', 15 ]

Received:

  1st vi.fn() call:

  [
    "tok_visa",
-   15,
+   1500,
  ]

Number of calls: 1

vi.spyOn(): watch (or replace) a real method

Sometimes the dependency is a real object, and you only want to observe one method, or stub one while keeping the rest. vi.spyOn(object, "method") swaps that method on the object for a recording version:

it("still succeeds when the receipt fails, and logs a warning", async () => {
  const payments = { charge: vi.fn().mockResolvedValue("ch_2") };
  const mailer = { sendReceipt: vi.fn().mockRejectedValue(new Error("smtp down")) };
  const log = { warn: () => {} };
  const warn = vi.spyOn(log, "warn");      // ← keeps calling the real warn, and records

  await expect(checkout(order, { payments, mailer, log })).resolves.toBe("ch_2");
  expect(warn).toHaveBeenCalledWith("receipt not sent", expect.any(Error));
});

// Stubbing a global for one test:
const spy = vi.spyOn(Math, "random").mockReturnValue(0.0042);
// … test …
spy.mockRestore();                         // ← put the real Math.random back

A spy changes the real object, so it stays changed until someone restores it. Keep that in mind; it comes back in the cleanup section below.

vi.mock(): when there is no seam

Here is checkout written the common way, importing its dependencies directly. There's nothing to pass in:

checkout-direct.js
import { charge } from "./payments.js";      // ← no injection: imported directly
import { sendReceipt } from "./mailer.js";

export async function checkout(order) {
  const chargeId = await charge(order.card, order.cents);
  await sendReceipt(order.email, chargeId);
  return chargeId;
}

vi.mock("./payments.js", factory) — where the factory is a function that returns the fake module — tells Vitest: "whenever anything in this test file's world imports ./payments.js, give it what factory returns instead". Without a factory, Vitest builds an auto-mock. The surprising part is when that happens. Step through it:

How vi.mock swaps a module
Step through what Vitest does with this test file.
Code
1import { it, expect, vi } from "vitest";
2import { checkout } from "./checkout-direct.js";
3import { charge } from "./payments.js";
4
5vi.mock("./payments.js", () => ({ charge: vi.fn() }));
6vi.mock("./mailer.js");
7
8it("charges, then emails the receipt", async () => {
9 vi.mocked(charge).mockResolvedValue("ch_9");
10 await checkout({ email: "ana@example.com", card: "tok_visa", cents: 1500 });
11 expect(charge).toHaveBeenCalledWith("tok_visa", 1500);
12});
Module registrywhat an import gets
./payments.js → your factory
./mailer.js → auto-mock
Inside checkout-direct.js
empty
In the test
empty
Before the file runs, Vitest moves every vi.mock call to the very top — above the imports — even though you wrote them on lines 5–6. So the replacements are registered first. (This move is called hoisting.)
1 / 6

So hoisting — moving vi.mock above the imports — is what makes it work at all: by the time checkout-direct.js loads, the replacement is already registered. (ES modules resolve imports before any of the file's own code runs, so a vi.mock that stayed on line 5 would come too late.)

The catch: because the factory runs before every other line of the file, it can't use a variable you declared in the file. Say you want to keep a handle on the fake and write this:

import { checkout } from "./checkout-direct.js";
const fakeCharge = vi.fn();                                // ← declared on line 2…
vi.mock("./payments.js", () => ({ charge: fakeCharge })); // ← …but this factory runs first

Vitest 5.0.5 fails with:

Error: [vitest] There was an error when mocking a module. If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file.
Caused by: ReferenceError: Cannot access 'fakeCharge' before initialization

The fix is vi.hoisted, which is moved to the top as well:

checkout-direct.test.js
import { it, expect, vi } from "vitest";
import { checkout } from "./checkout-direct.js";

const { fakeCharge } = vi.hoisted(() => ({ fakeCharge: vi.fn() })); // ← runs before vi.mock
vi.mock("./payments.js", () => ({ charge: fakeCharge }));
vi.mock("./mailer.js");

it("uses the hoisted fake", async () => {
  fakeCharge.mockResolvedValue("ch_7");
  await expect(checkout({ email: "a@x", card: "tok_visa", cents: 1500 })).resolves.toBe("ch_7");
});

Cleaning up between tests

Mocks hold state: recorded calls, programmed answers, and (for spies) a replaced method on a real object. If one test leaves that behind, the next test sees it. These are the three methods, called on one mock (all measured on Vitest 5.0.5):

MethodClears recorded callsDrops programmed answersPuts the original method back on the object (spies)
mockClear()✓
mockReset()✓✓ — a vi.fn() then returns undefined; a spy calls the real method again (it's still installed and recording)
mockRestore()✓✓✓

The config switches are a different story — they don't all mirror the methods:

  • clearMocks: true runs mockClear() on every mock before each test. It's on by default in Vitest 5.0.5, so call counts start at zero in each test. (Older Vitest versions had it off; check your version's docs.)
  • mockReset: true runs mockReset() on every mock before each test. A vi.fn().mockReturnValue("ch_1") created at the top of the file then returns undefined in every test — even the first. A leftover spy on Math.random goes back to real random numbers.
  • restoreMocks: true is narrower than its name suggests: it only undoes vi.spyOn spies, putting the real methods back. Plain vi.fn()s keep their programmed answers.

With only the default, a vi.spyOn(Math, "random").mockReturnValue(0.5) made in one test is still active in the next one. The fix:

vitest.config.js
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    restoreMocks: true, // ← every vi.spyOn is undone before each test
  },
});

If you also want mockReset, program the answers inside each test (or a beforeEach), never at the top of the file.

Practitioner§08

Faking HTTP: httptest.Server and MSW

In one sentenceFor code that makes HTTP calls, don't replace the client — replace the server. Then your real request-building, JSON and error-handling code all run in the test.

So far Payments was an interface, and a stub replaced the whole thing. But someone has to write the real Payments: the code that turns Charge("tok_visa", 1500) into an HTTP request, sends it, and turns 402 Payment Required into ErrDeclined. That adapter has plenty of room for bugs — wrong URL, wrong JSON field name, a status code nobody handled — and stubbing Payments skips all of it.

The fix is to keep the client real and give it a fake server to talk to. Compare the four options:

Where does the fake answer?
Four ways a test can deal with one HTTP call. Compare which layers still run.
1. Your code: client.charge()real
2. fetch() / http.Clientreal
3. HTTP stack (builds the request bytes)← the fake answers here
4. TCP socketskipped
5. The internetskipped
6. Payments serverskipped
Your code and the real fetch run unchanged. MSW hooks into Node's HTTP machinery inside the same process, matches the request against your handlers and answers it. No port is opened, nothing leaves the process.

The client we want to test

In the Go version, map[string]any is a map from string keys to values of any type — a quick way to build a JSON object.

payclient/client.go
func (c *Client) Charge(ctx context.Context, card string, cents int) (string, error) {
    body, err := json.Marshal(map[string]any{"card": card, "amount": cents})
    if err != nil {
        return "", err
    }
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/charges", bytes.NewReader(body))
    if err != nil {
        return "", err
    }
    req.Header.Set("Content-Type", "application/json")
    resp, err := c.http.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    switch resp.StatusCode {
    case http.StatusOK:
        var out struct{ ID string `json:"id"` }
        if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
            return "", fmt.Errorf("payments: bad response: %w", err)
        }
        return out.ID, nil
    case http.StatusPaymentRequired: // 402
        return "", checkout.ErrDeclined
    default:
        return "", fmt.Errorf("payments: unexpected status %d", resp.StatusCode)
    }
}

The Client struct holds baseURL and an *http.Client, both passed to New(baseURL, httpClient). Making the base URL a parameter is the seam: production passes the provider's address, tests pass the fake server's.

Go: httptest.NewServer

httptest.Server starts a real HTTP server on a random Port of 127.0.0.1, running a handler you write inside the test:

payclient/client_test.go
func TestChargeSendsJSONAndReadsID(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost || r.URL.Path != "/v1/charges" {
            t.Errorf("got %s %s, want POST /v1/charges", r.Method, r.URL.Path)
        }
        var body struct {
            Card   string `json:"card"`
            Amount int    `json:"amount"`
        }
        if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
            t.Errorf("bad JSON body: %v", err)
        }
        if body.Card != "tok_visa" || body.Amount != 1500 {
            t.Errorf("body = %+v, want tok_visa / 1500", body)
        }
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"id":"ch_123"}`))
    }))
    defer srv.Close()

    c := New(srv.URL, srv.Client()) // ← point the real client at the fake server
    id, err := c.Charge(context.Background(), "tok_visa", 1500)
    if err != nil || id != "ch_123" {
        t.Fatalf("Charge = %q, %v; want ch_123, nil", id, err)
    }
}

func TestChargeDeclined(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusPaymentRequired) // 402
    }))
    defer srv.Close()

    _, err := New(srv.URL, srv.Client()).Charge(context.Background(), "tok_visa", 1500)
    if !errors.Is(err, checkout.ErrDeclined) {
        t.Fatalf("err = %v, want ErrDeclined", err)
    }
}

The server is a fake, but everything between your code and it is real: the request is encoded, sent over a real socket, parsed by Go's real HTTP server. That makes httptest the right tool for testing timeouts:

func TestChargeGivesUpOnSlowServer(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        io.Copy(io.Discard, r.Body) // read the request first…
        <-r.Context().Done()        // …then hang until the client hangs up
    }))
    defer srv.Close()

    ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
    defer cancel()
    _, err := New(srv.URL, srv.Client()).Charge(ctx, "tok_visa", 1500)
    if !errors.Is(err, context.DeadlineExceeded) {
        t.Fatalf("err = %v, want DeadlineExceeded", err)
    }
}

<-r.Context().Done() waits until the request's context.Context is cancelled, which happens when the client disconnects. context.WithTimeout gives the client's context a 50 ms deadline, so the whole test takes about 50 ms instead of hanging. All three tests pass in 0.057 s (measured on this machine).

Node: MSW

MSW takes a different route. Instead of starting a server, it intercepts requests inside your Node process, before they reach the network, and answers them from handlers you write. Your client calls the real global fetch, unchanged:

payments-client.test.js
import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { createPaymentsClient } from "./payments-client.js";
import { DeclinedError } from "./checkout.js";

// Default behaviour for every test: charges succeed.
const server = setupServer(
  http.post("https://api.pay.test/v1/charges", async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: `ch_for_${body.card}_${body.amount}` });
  }),
);

beforeAll(() => server.listen({ onUnhandledFrame: "error" })); // ← unknown request = test fails
afterEach(() => server.resetHandlers());                        // ← drop per-test overrides
afterAll(() => server.close());

const client = createPaymentsClient({ baseUrl: "https://api.pay.test" });

describe("payments client", () => {
  it("posts JSON and returns the charge id", async () => {
    await expect(client.charge("tok_visa", 1500)).resolves.toBe("ch_for_tok_visa_1500");
  });

  it("maps 402 to DeclinedError", async () => {
    server.use( // ← override, for this test only
      http.post("https://api.pay.test/v1/charges", () => new HttpResponse(null, { status: 402 })),
    );
    await expect(client.charge("tok_visa", 1500)).rejects.toBeInstanceOf(DeclinedError);
  });
});

The onUnhandledFrame: "error" option matters. Without it, a request no handler matches only prints a warning and then goes to the real network. With it, here is what calling an unknown URL looks like (MSW 3.0.3):

[MSW] Error: intercepted a request without a matching request handler:

  • POST https://api.pay.test/v1/refunds

TypeError: fetch failed
Caused by: InternalError: [MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledFrame" option.

Which one?

  • Go: httptest.Server. It's in the standard library, and the real transport runs, so timeouts and connection handling are tested too.
  • Node: MSW when the code calls fetch (or a library on top of it) directly. The same handlers can also run in the browser through a Service Worker (a background script a page installs that can answer the page's own network requests), so frontend tests and local development can share them.
  • Either: if the client already takes fetch or a base URL as a parameter (Dependency Injection shows that style), a tiny injected fake is simpler still — at the cost of not testing the HTTP layer.

All of these share one weakness: the fake server answers the way you think the provider answers. If the provider returns 400 with {"code":"card_declined"} instead of 402, every test above stays green. §11 deals with that.

Practitioner§09

Faking time and randomness

In one sentenceCode that waits (retries, timeouts, debounces) is tested with a pretend clock that the test moves forward by hand — so a 3-second wait takes no real time and lands on exactly the same millisecond every run.

Dependency Injection §07 covers the simplest case: code that reads the time (time.Now(), new Date()) gets a now function passed in, and tests pass a frozen one. Read that first if it's new. This page handles the harder case: code that waits.

The problem with waiting for real

Here's a retry helper: try fn; if it fails, wait 1 s and try again; then wait 2 s and try a third time. That growing wait is called Exponential backoff.

retry/retry.go
// Do calls fn up to 3 times, waiting 1s, then 2s, between attempts.
func Do(ctx context.Context, fn func() error) error {
    wait := time.Second
    var err error
    for attempt := 1; attempt <= 3; attempt++ {
        if err = fn(); err == nil {
            return nil
        }
        if attempt == 3 {
            break
        }
        select {
        case <-time.After(wait): // ← a real wait
        case <-ctx.Done():
            return ctx.Err()
        }
        wait *= 2
    }
    return err
}

Tested for real, "gives up after three tries" takes 3 seconds. Twenty tests like that: a minute. And if you shrink the waits to make tests faster, timing differences between your laptop and a busy CI machine start making them fail at random. Neither is acceptable.

Fake timers: time moves when the test says so

Fake timers replace setTimeout, setInterval and Date with versions driven by a pretend clock. Calling setTimeout(fn, 1000) just puts fn on a list with "due at t=1000". Nothing runs until the test advances the clock. Try it — and then flip to sync to see the classic mistake:

Fake clock lab
retry(fn) waits 1 s, then 2 s, between attempts. Under fake timers, time only moves when the test says so — and promise steps (microtasks) only run when the test awaits.
Fake clockt = 0 ms · real time spent: ~0 ms
0s1s2s3s4s5s6s
fn called: 0×timer: nonemicrotask queue: emptyretry(): not started
Press “call retry(fn)”. The first attempt runs at once; after that, nothing happens until you advance the fake clock. Then switch to sync and try “advance 3 s” straight away — and again after “await …Async(0)”.

Here is the same thing as a real Vitest test:

retry.test.js
import { it, expect, vi, beforeEach, afterEach } from "vitest";
import { retry } from "./retry.js";

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

it("waits 1 s, then 2 s, between attempts", async () => {
  const fn = vi.fn().mockRejectedValue(new Error("boom"));
  const done = retry(fn);
  done.catch(() => {}); // we check the rejection at the end

  await vi.advanceTimersByTimeAsync(0);
  expect(fn).toHaveBeenCalledTimes(1);   // first try is immediate

  await vi.advanceTimersByTimeAsync(999);
  expect(fn).toHaveBeenCalledTimes(1);   // 999 ms: still waiting

  await vi.advanceTimersByTimeAsync(1);
  expect(fn).toHaveBeenCalledTimes(2);   // 1 s: second try

  await vi.advanceTimersByTimeAsync(2000);
  expect(fn).toHaveBeenCalledTimes(3);   // +2 s: third try

  await expect(done).rejects.toThrow("boom");
});

Why the Async version? Because retry takes two promise steps between attempts, and each one runs as a Microtask — a small job JavaScript runs only once the current code (here, your test) pauses at an await:

  1. fn() returns a rejected promise. The catch block that calls sleep(1000) doesn't run right away; it's queued as a microtask. So straight after retry(fn), no timer exists yet (vi.getTimerCount() is 0 — we checked).
  2. When the sleep timer fires, it only resolves the sleep promise. The next attempt — the code after await sleep(wait) — is queued as another microtask.

The plain vi.advanceTimersByTime(3000) is synchronous, so it never lets either microtask run. Called straight after retry(fn), it moves the clock to t=3000 and fires nothing: the timer hasn't been created. Then the queued catch runs and schedules sleep(1000) for t=4000. Even if you first await vi.advanceTimersByTimeAsync(0) so the timer exists, a sync advance fires it but the second attempt still waits in the queue. Either way, the check on the next line sees one call. Real output from that mistake (Vitest 5.0.5):

AssertionError: expected "vi.fn()" to be called 3 times, but got 1 times

advanceTimersByTimeAsync runs the queued microtasks before and after each timer it fires, which is why every attempt shows up on time. The widget above models both steps: try sync + "advance 3 s" right after "call retry(fn)", then press "await …Async(0)" and watch the timer appear at t=4000.

Two more fake-clock tools: vi.setSystemTime(new Date("2026-01-01T09:00:00Z")) makes new Date() and Date.now() return that moment, and vi.runAllTimers() keeps firing timers until none are left (careful: with setInterval that never ends, and Vitest stops after 10,000 timers by default).

Go: synctest (1.25+), or inject the sleep

Go 1.25 added testing/synctest. Code inside synctest.Test runs in a bubble with its own fake clock: whenever every Goroutine in the bubble is durably blocked — sleeping, waiting on a timer, on a channel created inside the bubble, or in sync.WaitGroup.Wait — the clock jumps straight to the next timer. A goroutine waiting on a sync.Mutex or on network I/O does not count (something outside the bubble could wake it), so code that blocks that way won't make fake time move. The retry.Do above is tested unchanged:

retry/retry_test.go (Go 1.25+)
func TestRetryWaitsOneThenTwoSeconds(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        start := time.Now() // ← the bubble's fake clock
        var at []time.Duration
        err := Do(context.Background(), func() error {
            at = append(at, time.Since(start))
            return errors.New("boom")
        })
        if err == nil {
            t.Fatal("want error after 3 attempts")
        }
        want := []time.Duration{0, time.Second, 3 * time.Second}
        if len(at) != len(want) {
            t.Fatalf("got %d attempts, want %d", len(at), len(want))
        }
        for i := range want {
            if at[i] != want[i] {
                t.Errorf("attempt %d at %v, want %v", i+1, at[i], want[i])
            }
        }
    })
}

We ran this with Go 1.25.0: it passes in 0.002 s, and the attempts land at exactly 0 s, 1 s and 3 s of fake time. On Go 1.24, the package exists only behind an experiment flag (GOEXPERIMENT=synctest) with an older synctest.Run(func()) API, so don't build on it there.

Without synctest, apply the usual fix: make the wait a dependency. Here is a second version of Do that takes a sleep function as a parameter; production passes one that really waits, tests pass one that records the duration and returns at once:

retry/retry.go (works on any Go version)
type Sleep func(ctx context.Context, d time.Duration) error

func Do(ctx context.Context, sleep Sleep, fn func() error) error { /* … calls sleep(ctx, wait) … */ }

// in the test:
var waits []time.Duration
fakeSleep := func(_ context.Context, d time.Duration) error {
    waits = append(waits, d) // ← record instead of waiting
    return nil
}
err := Do(context.Background(), fakeSleep, failingFn)
// waits == [1s 2s]

That's a spy on time: the test checks which waits were requested, which is exactly the behaviour we care about.

Randomness

Random values are the same problem in another costume: a test can't assert on something different every run. Two fixes, both covered with code in Dependency Injection §07:

  • Inject the source. Pass rand.IntN (Go) or Math.random (JS) in; tests pass a function returning a fixed value, or a PRNG with a fixed seed, which gives "random-looking but identical every run".
  • Stub the global, briefly. In Vitest, vi.spyOn(Math, "random").mockReturnValue(0.0042) — and restore it (§07), or every later test in the file gets 0.0042 too.
Practitioner§10

When mocking hurts

In one sentenceA test should fail when the behaviour is wrong — and only then. Mocks make it easy to write tests that also fail when the behaviour is fine but the steps changed.

A test that fails on a harmless change is a false alarm. One false alarm costs a few minutes. A suite full of them costs far more: people learn that red means "update the mocks", stop reading failures, and eventually a real bug goes by as one more red test that someone "fixed" by changing the expectation.

Watch it happen

Below, the same checkout function is tested by two suites. Both test the same two behaviours: the happy path and a declined card. The mock-style suite uses strict mocks that expect exact calls with exact arguments (the way gomock does by default, §06). The fake-style suite uses a small fake gateway and an outbox, and checks the end state. Both suites actually run, in your browser, against the code shown. (To keep the demo small, this checkout is a simplified, synchronous version: it takes pay and mail directly and calls mail.send. The idea is the same as the async one in §03.) Try each change:

Refactor breaker
Pick a change to a simplified, synchronous checkout(). Two test suites really run against it, right here in the page.
checkout.js
function checkout(order, pay, mail) {
  const id = pay.charge(order.card, order.cents);
  mail.send(order.email, "Thanks! Receipt for charge " + id);
  return id;
}

The starting point. Both suites pass.

Mock-style suitestrict expected calls, exact arguments
✓ happy path: charges 1500, sends receipt
✓ declined: no receipt
Fake-style suitefake gateway + outbox, checks the end state
✓ happy path: card charged 1500, one receipt
✓ declined: no charge, no receipt
Baseline: everything green.

What you should have seen:

  • Reword the email: the mock suite fails, because it expected the exact old text. The fake suite only checks that the email went to the right person and mentions the charge ID, so it stays green.
  • Authorize + capture: same money, same email, different calls. The mock suite fails both tests. The fake gateway supports both APIs, so the fake suite doesn't care how the money moved.
  • Both real bugs were caught by both suites. The strict mocks didn't buy any extra safety here — only extra false alarms.

The mock suite was over-specified: it checked how checkout does its job, not just what the job's result is.

The common ways it goes wrong

#1

Checking every argument of every call

The fix isn't "never check calls". It's check only what the test is about. If the test is "a declined card sends no email", the only interaction that matters is zero calls to the mailer. Everything else can be loose:

✕ Don't
expect(payments.charge).toHaveBeenCalledWith("tok_visa", 1500);
expect(mailer.sendReceipt).toHaveBeenCalledWith(
  "ana@example.com",
  "ch_1",
);
expect(log.info).toHaveBeenCalledTimes(2);  // ← why?
✓ Do
// test: "sends the receipt to the customer"
expect(mailer.sendReceipt).toHaveBeenCalledWith(
  "ana@example.com",
  expect.any(String),   // ← the id's format isn't this test's job
);

In gomock, the equivalents are gomock.Any() for arguments you don't care about and AnyTimes() for calls whose count doesn't matter.

#2

Mocking what you don't own

Mocking a third-party library directly — the provider's SDK, http.Client, a database driver — means writing down your guess of how someone else's code behaves, in dozens of tests. When you guess wrong, or they change it, every test is wrong in the same way.

Instead, wrap the library in a small adapter you own (our Payments interface with one Charge method). Mock or fake your interface in the business-logic tests. Test the adapter itself against the real thing, or against a fake server (§08). Then a change in the provider's API is fixed in one adapter, not in fifty tests.

#3

Testing the mock

it("finds the user", async () => {
  const repo = { findUser: vi.fn().mockResolvedValue({ id: 1, name: "Ana" }) };
  const service = new UserService(repo);
  const user = await service.getUser(1);  // getUser just returns repo.findUser(id)
  expect(user.name).toBe("Ana");          // ← checks the value we programmed 2 lines up
});

When the code under test is a thin pass-through, the only thing left to check is the stub's own answer. The test can't fail unless Vitest itself breaks. Either delete it, or test something that has logic, or — if the real risk is the query — write an integration test against a real database.

#4

Mocks returning mocks

const db = {
  collection: vi.fn(() => ({
    find: vi.fn(() => ({
      sort: vi.fn(() => ({ limit: vi.fn().mockResolvedValue([order1, order2]) })),
    })),
  })),
};

This test is a mirror image of the implementation's chain of calls: swap .sort().limit() for .limit().sort() and it breaks. It's a sign the seam is in the wrong place. Put a method you own in front of the chain — orders.recent(userId, 10) — and fake that (a few lines over an array), then test the real query once with a real database.

#5

Unit-testing glue with nothing but mocks

Some functions are mostly wiring: read from A, call B, write to C. A unit test with three mocks just restates the wiring. These are better covered by one test that runs the real pieces together, or a fake for the one slow part. Rule of thumb: if writing the mocks takes longer than writing the code, the test is at the wrong level.

#6

A double that no longer matches reality

The quietest failure: the provider changes a rule, your fake doesn't, and every test stays green while production fails. You can't see this one from inside the test suite at all. It gets its own section next: §11.

Staff-level§11

Contract tests: keeping doubles honest

In one sentenceWrite down every promise your code relies on as one test suite, then run it against both the fake and the real thing. When they disagree, the double has drifted — and you find out in CI, not from customers.

The drift problem

Your shop sells 30¢ stickers. Every test uses FakePayments, which happily charges 30¢. One day the provider starts rejecting charges under 50¢. Nothing in your repository changed, so every test is still green — but every sticker order in production now fails. Your fake has drifted from reality.

No amount of unit testing catches this, because unit tests compare your code against your fake, and the fake is the thing that's wrong. You need a test that compares the fake against reality.

One suite, two targets

A Contract test here is simply a test suite that takes "something that implements Payments" as a parameter. It lists the behaviours checkout depends on — and nothing about how they're implemented:

paymentstest/contract.go
package paymentstest

// Contract is every promise checkout relies on. Run it against the fake AND the real thing.
func Contract(t *testing.T, newPayments func(t *testing.T) checkout.Payments) {
    ctx := context.Background()

    t.Run("a good card returns a charge id", func(t *testing.T) {
        id, err := newPayments(t).Charge(ctx, "tok_visa", 1500)
        if err != nil || id == "" {
            t.Fatalf("Charge = %q, %v; want an id, nil", id, err)
        }
    })
    t.Run("a declined card returns ErrDeclined", func(t *testing.T) {
        _, err := newPayments(t).Charge(ctx, "tok_declined", 1500)
        if !errors.Is(err, checkout.ErrDeclined) {
            t.Fatalf("err = %v, want ErrDeclined", err)
        }
    })
    t.Run("amounts under 50 cents are rejected", func(t *testing.T) {
        if _, err := newPayments(t).Charge(ctx, "tok_visa", 10); err == nil {
            t.Fatal("want an error for 10 cents")
        }
    })
}

toMatch(/^\S+$/) means "a non-empty string with no spaces" — any charge ID will do; the contract doesn't promise a format.

For the fake to pass that third case, it needs the rule too. It's three lines at the top of Charge:

paymentstest/fake.go
if cents < 50 {
    return "", fmt.Errorf("amount %d below minimum 50", cents)
}

(The third case is the provider's new 50¢ rule. Before the change, that line said the opposite — "a 30¢ charge succeeds" — because the shop relied on it. The widget further down plays out that switch.)

Then two tiny test files call it — one with the fake, one with the real client pointed at the provider's sandbox. The real one skips itself unless the sandbox address is configured, so it doesn't run (or fail) on every laptop:

contract tests
// paymentstest/fake_test.go — runs on every commit
func TestFakeKeepsTheContract(t *testing.T) {
    Contract(t, func(t *testing.T) checkout.Payments { return NewFake() })
}

// payclient/contract_test.go — runs when the sandbox is configured
func TestRealClientKeepsTheContract(t *testing.T) {
    url := os.Getenv("PAYMENTS_SANDBOX_URL")
    if url == "" {
        t.Skip("set PAYMENTS_SANDBOX_URL to run against the provider's sandbox")
    }
    paymentstest.Contract(t, func(t *testing.T) checkout.Payments {
        return New(url, &http.Client{Timeout: 10 * time.Second})
    })
}

When the fake forgets a rule, the contract says exactly which promise it broke. Real output after removing the 50¢ rule from the fake (Go 1.24):

--- FAIL: TestFakeKeepsTheContract (0.00s)
    --- FAIL: TestFakeKeepsTheContract/amounts_under_50_cents_are_rejected (0.00s)
        contract.go:29: want an error for 10 cents
FAIL

The whole loop

The contract only helps if it also runs against the real thing, regularly. Play through what happens when the provider changes a rule:

Contract drift
One shared contract suite, run against the fake and against the provider's sandbox. Flip the switches in order.
Contract suite vs fakeruns on every commit
✓ good card → charge id
✓ tok_declined → declined
✓ 30¢ sticker → charge id
Contract suite vs sandboxruns nightly
✓ good card → charge id
✓ tok_declined → declined
✓ 30¢ sticker → charge id
Unit test: sell one 30¢ stickeruses the fake
✓ order placed
Production: sell one 30¢ stickerreal provider
order placed
All green. The fake and the provider agree, and a 30¢ sticker sells fine.

The sandbox run is the alarm. It's slower, it needs credentials, and sandboxes have outages, so teams usually run it on a schedule (nightly) and before releases, not on every commit:

.github/workflows/contracts.yml
on:
  schedule:
    - cron: "17 3 * * *"         # every night at 03:17 UTC
  workflow_dispatch: {}          # plus a "Run" button
jobs:
  sandbox-contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with: { go-version: "1.26" }
      - run: go test ./payclient -run Contract -v
        env:
          PAYMENTS_SANDBOX_URL: ${{ secrets.PAYMENTS_SANDBOX_URL }}

When it goes red, the order of work is: update the contract to describe the new reality, update the fake until it passes again, then let your now-failing unit tests show you which code has to change.

Consumer-driven contracts, for services you own

The shared-suite approach works when the other side is a third party with a sandbox. When both sides are your company's services, there's a stronger version: a consumer-driven contract.

  1. In the consumer's tests (the service making calls), a tool like Pact acts as the fake server. Each test records "I send this request and expect this response" into a contract file.
  2. That file is published (to a shared "broker" service, or as a CI artifact).
  3. The provider's CI replays every recorded request against the real provider and checks the responses match. If the provider team renames a field a consumer uses, their build fails — before deploy.

The fake and the contract are the same thing, so they can't drift apart. The cost is real infrastructure and agreement between teams; it pays off with many services owned by different teams. (See Monolith vs Microservices for why those boundaries matter.)

Cheaper checks that also help

  • Validate fake responses against the provider's schema. If the provider publishes an OpenAPI description, have your fake server (httptest or MSW) check each canned response against it. A misspelled field fails immediately.
  • Record once, replay often. Save real sandbox responses as test data files and serve them from your fake server, refreshing them on a schedule. Your fake then starts from the provider's real words, not your memory of them.
  • Prefer an official fake. Many providers and projects ship their own: Go's httptest and fstest.MapFS, cloud emulators for queues and databases. A fake maintained by the people who change the real thing drifts much less.
Staff-level§12

Staff-level: doubles in a real codebase

In one sentenceAt scale, doubles are shared code with users and owners. Treat the important ones like production code: one per seam, tested against the real thing, and kept in a place everyone can find.

One good fake per seam, not fifty mocks

In a codebase with hundreds of tests, the expensive pattern is every test building its own double for the same dependency: fifty slightly different Payments mocks, each encoding a slightly different belief about the provider. When the provider changes, you fix fifty files — and you'll miss some.

The cheaper pattern:

  • One fake per seam, in a shared package (paymentstest, mailtest) next to the real adapter, owned by the team that owns the adapter.
  • That fake passes the contract suite (§11), so it's verified, not guessed.
  • Tests that need something unusual use knobs on the fake, not a new double.

Knobs for failure

The most valuable tests are often failure tests: timeouts, partial failures, retries. A fake with a failure knob covers them all:

paymentstest/fake.go
type Fake struct {
    FailNext []error // returned by the next calls, in order, before normal behaviour resumes

    mu      sync.Mutex
    charged map[string]int
    n       int
}

func (f *Fake) Charge(_ context.Context, card string, cents int) (string, error) {
    f.mu.Lock()
    defer f.mu.Unlock()
    if len(f.FailNext) > 0 {
        err := f.FailNext[0]
        f.FailNext = f.FailNext[1:] // ← use up one injected failure
        return "", err
    }
    // … normal behaviour from §11 …
}

// In a test: "the first call times out, the retry succeeds"
pay := paymentstest.NewFake()
pay.FailNext = []error{context.DeadlineExceeded}

This is where a fake earns its keep over a mock. "Time out once, then succeed" is one line, and the test then checks the state: was the card charged once, or twice? If your retry logic can double-charge a customer after a timeout, this is the test that finds it. (The real fix is an idempotency key the provider uses to ignore the repeat: Idempotency.)

Parallel tests and shared doubles

Two rules that prevent a whole class of flaky tests:

  1. Each test builds its own double. A package-level var pay = NewFake() shared by tests that call t.Parallel() means one test's charges show up in another's totals — sometimes, depending on timing.
  2. Doubles that can be called from several goroutines need a lock (the mutex in §05), and CI runs go test -race so a missing one fails the build instead of corrupting a test once a month.

In JavaScript, test files run in separate workers, but tests within a file share module state. A global swapped with vi.stubGlobal("fetch", fakeFetch) or vi.spyOn(Date, "now") stays swapped until undone. Turn on restoreMocks: true and unstubGlobals: true in the Vitest config, so cleanup doesn't depend on every author remembering it.

Code with no seams

Real codebases are full of functions that call time.Now(), http.Get or a database package directly. You don't need to redesign them before testing. Add a seam at the smallest point that works, keep the old signature for existing callers, and widen it later:

// Before: impossible to test without the real clock.
func IsExpired(t Trial) bool { return time.Now().After(t.EndsAt) }

// After: same function for every existing caller, plus a testable core.
func IsExpired(t Trial) bool               { return isExpiredAt(t, time.Now()) }
func isExpiredAt(t Trial, now time.Time) bool { return now.After(t.EndsAt) }

Michael Feathers' Working Effectively with Legacy Code is a whole book of these seam-adding moves. Clean Architecture covers moving a whole codebase in that direction.

Signs of mock debt

Watch for these in code review — each means doubles have started costing more than they save:

  • Pull requests that change more mock setup than code. The tests are checking steps, not results (§10).
  • "Fixing" a red test by editing its expectations without anyone asking whether the behaviour change was intended.
  • vi.mock of your own modules everywhere. The code has no seams; the tests are working around it.
  • No test that ever touches the real thing. Every unit test passes against fakes, and nothing checks the fakes. Add the contract suite (§11) and a few integration tests.
  • Mocks of types you don't own. Wrap them (§10, pitfall #2).
Practitioner§13

Check yourself

Predict the answer before you click. Q1 is covered in §03, Q2–Q3 in §07, Q4 in §09, Q5 in §10 and Q6 in §11.

QUESTION 1 / 6
Name that double

A test passes NewService(nil, mailer, logger) and checks that an order with a zero amount is rejected before anything is charged. What is the nil payments argument?

QUESTION 2 / 6
What does this test print?
it("one", () => {
  vi.spyOn(Math, "random").mockReturnValue(0.5);
  expect(Math.random()).toBe(0.5);
});
it("two", () => {
  console.log(Math.random()); // no restoreMocks in the config
});
QUESTION 3 / 6
The hoisting trap
import { checkout } from "./checkout-direct.js";
const fakeCharge = vi.fn();
vi.mock("./payments.js", () => ({ charge: fakeCharge }));
QUESTION 4 / 6
Fake timers, sync advance

retry(fn) awaits sleep(1000) between attempts. With fake timers on, a test calls retry(fn), then vi.advanceTimersByTime(3000) (not the Async version), then immediately checks the call count. What does it see?

QUESTION 5 / 6
Which change makes the mock-heavy suite fail but not the fake-based one?
QUESTION 6 / 6
The fake says yes, production says no

Unit tests with FakePayments are green, but production rejects 30¢ charges after the provider changed a rule. What would have caught this before customers did?

Answered 0 / 6
Foundations§14

Cheat sheet

🎭
Double the edgesReplace what's slow, random, risky or hard to trigger: network, email, clock, randomness. Let your own logic run for real.
🧰
Five kindsDummy fills a slot · stub answers · spy records · mock expects · fake works (simply).
🎯
Results over callsCheck end state by default. Check calls only when the call is the behaviour: "sent once", "never charged".
🔌
Small seamsGo: a 1–3 method interface declared by the code that uses it, plus var _ I = (*Fake)(nil).
🌐
Fake the serverHTTP clients: httptest.NewServer in Go, MSW in Node — with unknown requests set to fail.
⏱
Move time by handvi.useFakeTimers() + await vi.advanceTimersByTimeAsync(); Go 1.25+ synctest.Test, or inject the sleep.
🧹
Clean upVitest: restoreMocks: true. Every test builds its own double. Doubles get a mutex and -race.
🤝
Keep doubles honestOne contract suite, run against the fake on every commit and the real sandbox nightly.
I want to…GoJavaScript (Vitest)
Fill an unused parameternil / zero value{}
Return a canned answertiny struct or PaymentsFunc(func…)vi.fn().mockResolvedValue(x)
Record callsstruct with a slice + mutexvi.fn() → toHaveBeenCalledWith
Fail on unexpected callsgomock / mockery EXPECT()no strict built-in; assert counts yourself
Watch a real methodwrap it in a type that records, then delegatesvi.spyOn(obj, "m")
Replace a module with no seamadd a seam (§04)vi.mock("./mod.js") + vi.hoisted
Fake an HTTP APIhttptest.NewServerMSW setupServer + onUnhandledFrame: "error"
Control timesynctest.Test (1.25+) or injected now / sleepvi.useFakeTimers(), vi.setSystemTime()
Control randomnessinject rand.IntN or a seeded PRNGinject random, or vi.spyOn(Math, "random")
Check a fake against realityshared Contract(t, newImpl) suiteshared paymentsContract(name, make) suite
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in TestingIntegration Testing
Read →