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.
The 30-second version
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:
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):
| Kind | What it does | Crash-test version |
|---|---|---|
| Dummy | Fills a parameter; never used | An empty passenger seat |
| Stub | Returns a canned answer | A recorded crash you replay |
| Spy | Records calls; you read them afterwards | Sensors inside the dummy |
| Mock | Knows which calls to expect; fails the test itself | An inspector with a checklist |
| Fake | A 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).
Why not just use the real thing?
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:
Slow
Network calls take hundreds of milliseconds. Multiply by hundreds of tests, and the suite stops being something you run while you work.
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.
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
checkoutcalls atotalWithTax(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
checkoutto 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.
Five kinds of double
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
// 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:
- In this test, will the code actually call this dependency?
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.
Seams: small interfaces where you use them
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:
// 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 fittingHere'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.)
// 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.
Hand-written doubles
Here is a complete Go test file for checkout using four kinds from §03. Every double is a normal type with normal methods.
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.003sAnd 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):
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:
- Give the fake knobs. Add a field like
FailWith errortoFakePayments; when it's set,Chargereturns it. One fake, many scenarios. - Generate the doubles. Tools can write a mock for any interface. That's §06 — along with why the generated kind brings its own costs.
Generated mocks: gomock and mockery
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 originalgolang/mock). Itsmockgencommand writes the mocks. - mockery — writes mocks built on the popular
testifyassertion 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.0Then put a //go:generate comment at the top of the file that holds the interfaces, then run go generate ./...:
//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -source=checkout.go -destination=mock_checkout_test.go -package=checkout
package checkoutFor each interface you get a type: Payments → MockPayments, created with NewMockPayments(ctrl).
Writing a test with expectations
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 test | Plain Go; nothing to learn | Need to know the library's API |
| Interface changes | Fix each fake by hand (the compiler tells you where) | Re-run go generate |
| Default style | Fakes and state checks | Expected calls (interaction checks) |
| Breaks on harmless refactors | Rarely | Often, if expectations are exact |
| Good for | Most dependencies, especially small interfaces | Checking that a call happened (or didn't), many-method interfaces you can't shrink |
Vitest: vi.fn, vi.spyOn and vi.mock
vi.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
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: 1vi.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 backA 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:
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:
1import { it, expect, vi } from "vitest";2import { checkout } from "./checkout-direct.js";3import { charge } from "./payments.js";45vi.mock("./payments.js", () => ({ charge: vi.fn() }));6vi.mock("./mailer.js");78it("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});
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.)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 firstVitest 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 initializationThe fix is vi.hoisted, which is moved to the top as well:
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):
| Method | Clears recorded calls | Drops programmed answers | Puts 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: truerunsmockClear()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: truerunsmockReset()on every mock before each test. Avi.fn().mockReturnValue("ch_1")created at the top of the file then returnsundefinedin every test — even the first. A leftover spy onMath.randomgoes back to real random numbers.restoreMocks: trueis narrower than its name suggests: it only undoesvi.spyOnspies, putting the real methods back. Plainvi.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:
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.
Faking HTTP: httptest.Server and MSW
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:
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.
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:
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:
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
fetchor 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.
Faking time and randomness
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.
// 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:
Here is the same thing as a real Vitest test:
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:
fn()returns a rejected promise. Thecatchblock that callssleep(1000)doesn't run right away; it's queued as a microtask. So straight afterretry(fn), no timer exists yet (vi.getTimerCount()is0— we checked).- When the sleep timer fires, it only resolves the
sleeppromise. The next attempt — the code afterawait 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 timesadvanceTimersByTimeAsync 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:
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:
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) orMath.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.
When mocking hurts
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:
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.
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
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:
expect(payments.charge).toHaveBeenCalledWith("tok_visa", 1500);
expect(mailer.sendReceipt).toHaveBeenCalledWith(
"ana@example.com",
"ch_1",
);
expect(log.info).toHaveBeenCalledTimes(2); // ← why?// 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.
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.
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.
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.
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.
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.
Contract tests: keeping doubles honest
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:
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:
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:
// 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
FAILThe 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:
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:
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.
- 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.
- That file is published (to a shared "broker" service, or as a CI artifact).
- 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
httptestandfstest.MapFS, cloud emulators for queues and databases. A fake maintained by the people who change the real thing drifts much less.
Staff-level: doubles in a real codebase
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:
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:
- Each test builds its own double. A package-level
var pay = NewFake()shared by tests that callt.Parallel()means one test's charges show up in another's totals — sometimes, depending on timing. - Doubles that can be called from several goroutines need a lock (the mutex in §05), and CI runs
go test -raceso 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.mockof 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).
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.
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?
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
});import { checkout } from "./checkout-direct.js";
const fakeCharge = vi.fn();
vi.mock("./payments.js", () => ({ charge: fakeCharge }));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?
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?
Cheat sheet
var _ I = (*Fake)(nil).httptest.NewServer in Go, MSW in Node — with unknown requests set to fail.vi.useFakeTimers() + await vi.advanceTimersByTimeAsync(); Go 1.25+ synctest.Test, or inject the sleep.restoreMocks: true. Every test builds its own double. Doubles get a mutex and -race.| I want to… | Go | JavaScript (Vitest) |
|---|---|---|
| Fill an unused parameter | nil / zero value | {} |
| Return a canned answer | tiny struct or PaymentsFunc(func…) | vi.fn().mockResolvedValue(x) |
| Record calls | struct with a slice + mutex | vi.fn() → toHaveBeenCalledWith |
| Fail on unexpected calls | gomock / mockery EXPECT() | no strict built-in; assert counts yourself |
| Watch a real method | wrap it in a type that records, then delegates | vi.spyOn(obj, "m") |
| Replace a module with no seam | add a seam (§04) | vi.mock("./mod.js") + vi.hoisted |
| Fake an HTTP API | httptest.NewServer | MSW setupServer + onUnhandledFrame: "error" |
| Control time | synctest.Test (1.25+) or injected now / sleep | vi.useFakeTimers(), vi.setSystemTime() |
| Control randomness | inject rand.IntN or a seeded PRNG | inject random, or vi.spyOn(Math, "random") |
| Check a fake against reality | shared Contract(t, newImpl) suite | shared paymentsContract(name, make) suite |