Vitest Testgids: Van Nul tot Productievertrouwen
Unit tests, integratietests, componenttests met Testing Library, mock-strategieën, coverage-configuratie en de testfilosofie die daadwerkelijk betere software oplevert.
De meeste teams schrijven tests zoals de meeste mensen sporten: ze weten dat het moet, ze voelen zich schuldig als ze het niet doen, en wanneer ze er eindelijk aan beginnen, gaan ze op dag één te hard en stoppen ze op vrijdag alweer. De codebase eindigt met een handvol oppervlakkige snapshot-tests die breken bij elke CSS-class wijziging, een paar integratietests die niemand vertrouwt, en een coverage-badge in de README die liegt.
Ik heb aan beide kanten gestaan. Ik heb projecten opgeleverd zonder enige test en bij elke deploy het zweet op mijn voorhoofd gehad. Ik heb ook in teams gewerkt die 100% coverage najoegen en meer tijd besteedden aan het onderhouden van tests dan aan het schrijven van features. Geen van beide werkt. Wat wel werkt is de juiste tests schrijven, op de juiste plekken, met de juiste tools.
Vitest heeft veranderd hoe ik over testen in JavaScript denk. Niet omdat het nieuwe concepten uitvond — de fundamenten zijn niet veranderd sinds Kent Beck er decennia geleden over schreef. Maar omdat het genoeg wrijving wegnam waardoor tests schrijven niet meer voelde als een karwei, maar als onderdeel van de development-loop. Wanneer je test runner net zo snel is als je dev server en dezelfde configuratie gebruikt, verdwijnen de excuses.
Dit artikel is alles wat ik weet over testen met Vitest, van de eerste setup tot de filosofie die het allemaal de moeite waard maakt.
Waarom Vitest Boven Jest#
Als je Jest hebt gebruikt, ken je al het grootste deel van de API van Vitest. Dat is bewust zo — Vitest is Jest-compatibel op API-niveau. describe, it, expect, beforeEach, vi.fn() — het werkt allemaal. Waarom dan overstappen?
Native ESM Support#
Jest is gebouwd voor CommonJS. Het kan ESM aan, maar dat vereist configuratie, experimentele flags en af en toe een gebedje. Als je import/export syntax gebruikt (en dat is tegenwoordig alles), heb je waarschijnlijk gevochten met Jests transform-pipeline.
Vitest draait op Vite. Vite begrijpt ESM native. Er is geen transform-stap voor je broncode — het werkt gewoon. Dit is belangrijker dan het klinkt. De helft van de Jest-problemen die ik door de jaren heen heb gedebugd, komen neer op module-resolutie: SyntaxError: Cannot use import statement outside a module, of mocks die niet werken omdat de module al in een ander formaat gecached was.
Dezelfde Config als Je Dev Server#
Als je project Vite gebruikt (en als je in 2026 een React-, Vue- of Svelte-app bouwt, is dat waarschijnlijk zo), leest Vitest automatisch je vite.config.ts. Je path-aliassen, plugins en environment variables werken in tests zonder extra configuratie. Met Jest onderhoud je een parallelle configuratie die synchroon moet lopen met je bundler-setup. Elke keer dat je een path-alias toevoegt in vite.config.ts, moet je onthouden om de bijbehorende moduleNameMapper in jest.config.ts toe te voegen. Het is een klein ding, maar kleine dingen stapelen zich op.
Snelheid#
Vitest is snel. Merkbaar snel. Niet "bespaart je twee seconden" snel — "verandert hoe je werkt" snel. Het gebruikt Vite's module graph om te begrijpen welke tests geraakt worden door een bestandswijziging en draait alleen die. De watch-modus gebruikt dezelfde HMR-infrastructuur die Vites dev server instant laat aanvoelen.
Bij een project met 400+ tests verminderde de overstap van Jest naar Vitest onze watch-mode feedback-loop van ~4 seconden naar minder dan 500ms. Dat is het verschil tussen "ik wacht tot de test slaagt" en "ik kijk even naar de terminal terwijl mijn vingers nog op het toetsenbord liggen."
Benchmarking Ingebouwd#
Vitest bevat bench() standaard voor performance testing. Geen aparte library nodig:
import { bench, describe } from "vitest";
describe("string concatenation", () => {
bench("template literals", () => {
const name = "world";
const _result = `hello ${name}`;
});
bench("string concat", () => {
const name = "world";
const _result = "hello " + name;
});
});Draai het met vitest bench. Het is niet de hoofdact, maar het is fijn om performance testing in dezelfde toolchain te hebben zonder benchmark.js te installeren en een aparte runner op te zetten.
Setup#
Installatie#
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomConfiguratie#
Maak vitest.config.ts aan in de root van je project:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
alias: {
"@": path.resolve(__dirname, "./src"),
},
css: false,
},
});Of, als je al een vite.config.ts hebt, kun je die uitbreiden:
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": "/src",
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});De globals: true Beslissing#
Wanneer globals op true staat, hoef je describe, it, expect, beforeEach, etc. niet te importeren — ze zijn overal beschikbaar, net als in Jest. Wanneer het op false staat, importeer je ze expliciet:
// globals: false
import { describe, it, expect } from "vitest";
describe("math", () => {
it("adds numbers", () => {
expect(1 + 1).toBe(2);
});
});Ik gebruik globals: true omdat het visuele ruis vermindert en overeenkomt met wat de meeste developers verwachten. Als je in een team werkt dat expliciete imports waardeert, zet het dan op false — er is hier geen verkeerd antwoord.
Als je globals: true gebruikt, voeg dan Vitests types toe aan je tsconfig.json zodat TypeScript ze herkent:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}Environment: jsdom vs happy-dom vs node#
Vitest laat je de DOM-implementatie per test of globaal kiezen:
node— Geen DOM. Voor pure logica, utilities, API-routes en alles dat niet met de browser te maken heeft.jsdom— De standaard. Volledige DOM-implementatie. Zwaarder maar completer.happy-dom— Lichter en sneller dan jsdom maar minder compleet. Sommige edge-cases (zoalsRange,SelectionofIntersectionObserver) werken mogelijk niet.
Ik gebruik standaard jsdom globaal en overschrijf per bestand wanneer ik node nodig heb:
// src/lib/utils.test.ts
// @vitest-environment node
import { formatDate, slugify } from "./utils";
describe("slugify", () => {
it("converts spaces to hyphens", () => {
expect(slugify("hello world")).toBe("hello-world");
});
});Setup-bestanden#
Het setup-bestand wordt voor elk testbestand uitgevoerd. Hier configureer je Testing Library matchers en globale mocks:
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
// Mock IntersectionObserver — jsdom implementeert het niet
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
value: MockIntersectionObserver,
});
// Mock window.matchMedia — nodig voor responsive componenten
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});De @testing-library/jest-dom/vitest import geeft je matchers zoals toBeInTheDocument(), toHaveClass(), toBeVisible() en vele andere. Deze maken assertions op DOM-elementen leesbaar en expressief.
Goede Tests Schrijven#
Het AAA-patroon#
Elke test volgt dezelfde structuur: Arrange, Act, Assert. Zelfs als je geen expliciete commentaren schrijft, moet de structuur zichtbaar zijn:
it("calculates total with tax", () => {
// Arrange
const items = [
{ name: "Widget", price: 10 },
{ name: "Gadget", price: 20 },
];
const taxRate = 0.08;
// Act
const total = calculateTotal(items, taxRate);
// Assert
expect(total).toBe(32.4);
});Wanneer ik een test zie die arrangement, actie en assertion in één keten van method-calls door elkaar gooit, weet ik dat het moeilijk te begrijpen zal zijn wanneer het faalt. Houd de drie fases visueel gescheiden, zelfs als je de commentaren niet toevoegt.
Testnamen#
Er zijn twee stromingen: it('should calculate total with tax') en it('calculates total with tax'). Het "should"-prefix is omslachtig zonder informatie toe te voegen. Wanneer de test faalt, zie je:
FAIL ✕ calculates total with tax
Dat is al een complete zin. "Should" toevoegen voegt alleen ruis toe. Ik geef de voorkeur aan de directe vorm: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').
Gebruik voor describe-blokken de naam van de unit die je test:
describe("calculateTotal", () => {
it("sums item prices", () => { /* ... */ });
it("applies tax rate", () => { /* ... */ });
it("returns 0 for empty array", () => { /* ... */ });
it("handles negative prices", () => { /* ... */ });
});Lees het hardop: "calculateTotal sums item prices." "calculateTotal applies tax rate." Als de zin werkt, werkt de naamgeving.
Eén Assertion Per Test vs Praktische Groepering#
De puristische regel zegt één assertion per test. De praktische regel zegt: één concept per test. Dat zijn twee verschillende dingen.
// Dit is prima — één concept, meerdere assertions
it("formats user display name", () => {
const user = { firstName: "John", lastName: "Doe", title: "Dr." };
const result = formatDisplayName(user);
expect(result).toContain("John");
expect(result).toContain("Doe");
expect(result).toStartWith("Dr.");
});
// Dit is niet prima — meerdere concepten in één test
it("handles user operations", () => {
const user = createUser("John");
expect(user.id).toBeDefined();
const updated = updateUser(user.id, { name: "Jane" });
expect(updated.name).toBe("Jane");
deleteUser(user.id);
expect(getUser(user.id)).toBeNull();
});De eerste test heeft drie assertions maar ze verifiëren allemaal één ding: het formaat van een display-name. Als een assertion faalt, weet je precies wat er stuk is. De tweede test is drie aparte tests in één gepropt. Als de tweede assertion faalt, weet je niet of het aanmaken of bijwerken niet werkt, en de derde assertion wordt nooit uitgevoerd.
Testbeschrijvingen als Documentatie#
Goede testsuites dienen als levende documentatie. Iemand die de code niet kent, zou de testbeschrijvingen moeten kunnen lezen en het gedrag van de feature begrijpen:
describe("PasswordValidator", () => {
describe("minimum length", () => {
it("rejects passwords shorter than 8 characters", () => { /* ... */ });
it("accepts passwords with exactly 8 characters", () => { /* ... */ });
});
describe("character requirements", () => {
it("requires at least one uppercase letter", () => { /* ... */ });
it("requires at least one number", () => { /* ... */ });
it("requires at least one special character", () => { /* ... */ });
});
describe("common password check", () => {
it("rejects passwords in the common passwords list", () => { /* ... */ });
it("performs case-insensitive comparison", () => { /* ... */ });
});
});Wanneer deze testsuite draait, leest de output als een specificatie. Dat is het doel.
Mocking#
Mocking is het krachtigste en tegelijk gevaarlijkste gereedschap in je test-toolkit. Goed ingezet isoleert het de unit die je test en maakt het tests snel en deterministisch. Slecht ingezet creëert het tests die slagen ongeacht wat de code doet.
vi.fn() — Mock Functions Aanmaken#
De simpelste mock is een functie die haar aanroepen bijhoudt:
const mockCallback = vi.fn();
// Roep het aan
mockCallback("hello", 42);
mockCallback("world");
// Assert op aanroepen
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");Je kunt het een returnwaarde geven:
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: "Test" }),
});Of het bij opeenvolgende aanroepen verschillende waarden laten teruggeven:
const mockRandom = vi.fn()
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.9);vi.spyOn() — Echte Methodes Observeren#
Wanneer je een methode wilt observeren zonder het gedrag te vervangen:
const consoleSpy = vi.spyOn(console, "warn");
validateInput("");
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();spyOn behoudt standaard de originele implementatie. Je kunt het overschrijven met .mockImplementation() wanneer nodig, maar herstel daarna het origineel met .mockRestore().
vi.mock() — Mocking op Module-niveau#
Dit is de grote. vi.mock() vervangt een complete module:
// Mock de hele module
vi.mock("@/lib/api", () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]),
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
// De import gebruikt nu de gemockte versie
import { fetchUsers } from "@/lib/api";
describe("UserList", () => {
it("displays users from API", async () => {
const users = await fetchUsers();
expect(users).toHaveLength(2);
});
});Vitest hoist vi.mock()-aanroepen automatisch naar de top van het bestand. Dat betekent dat de mock actief is voordat imports worden uitgevoerd. Je hoeft je geen zorgen te maken over de volgorde van imports.
Automatisch Mocken#
Als je gewoon wilt dat elke export vervangen wordt door een vi.fn():
vi.mock("@/lib/analytics");
import { trackEvent, trackPageView } from "@/lib/analytics";
it("tracks form submission", () => {
submitForm();
expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});Zonder de factory-functie mockt Vitest automatisch alle exports. Elke geëxporteerde functie wordt een vi.fn() die undefined retourneert. Dit is handig voor modules die je wilt stilleggen (zoals analytics of logging) zonder elke functie te specificeren.
Clearing vs Resetting vs Restoring#
Hier raakt iedereen wel eens in de war:
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
// mockClear — reset aanroepgeschiedenis, behoudt implementatie
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // geeft nog steeds 42 terug
// mockReset — reset aanroepgeschiedenis EN implementatie
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // geeft niet langer 42 terug
// mockRestore — voor spies, herstelt originele implementatie
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() werkt nu weer normaalIn de praktijk gebruik je vi.clearAllMocks() in beforeEach om aanroepgeschiedenis tussen tests te resetten. Gebruik vi.restoreAllMocks() als je spyOn gebruikt en de originelen terug wilt:
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});Het Gevaar van Over-Mocken#
Dit is het belangrijkste mock-advies dat ik kan geven: elke mock is een leugen die je aan je test vertelt. Wanneer je een dependency mockt, zeg je "ik vertrouw erop dat dit ding correct werkt, dus ik vervang het door een vereenvoudigde versie." Als je aanname fout is, slaagt de test maar is de feature stuk.
// Over-gemockt — test niets nuttigs
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
it("processes user input", () => {
processInput("hello");
expect(validator.validate).toHaveBeenCalledWith("hello");
expect(formatter.format).toHaveBeenCalledWith("hello");
});Deze test verifieert dat processInput validate en format aanroept. Maar wat als processInput ze in de verkeerde volgorde aanroept? Wat als het hun returnwaarden negeert? Wat als de validatie zou moeten voorkomen dat de format-stap wordt uitgevoerd? De test weet het niet. Je hebt al het interessante gedrag weggemockt.
De vuistregel: mock aan de grenzen, niet in het midden. Mock netwerkverzoeken, bestandssysteemtoegang en services van derden. Mock niet je eigen utility-functies tenzij er een overtuigende reden is (zoals dat ze duur zijn om uit te voeren of side effects hebben).
React Componenten Testen#
De Basis met Testing Library#
Testing Library hanteert een filosofie: test componenten zoals gebruikers ermee omgaan. Geen interne state checken, geen component-instances inspecteren, geen shallow rendering. Je rendert een component en communiceert ermee via de DOM, precies zoals een gebruiker dat zou doen.
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
describe("Button", () => {
it("renders with label text", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("applies variant classes", () => {
render(<Button variant="primary">Submit</Button>);
const button = screen.getByRole("button");
expect(button).toHaveClass("bg-primary");
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
});Queries: getBy vs queryBy vs findBy#
Hier raken beginners in de war. Er zijn drie query-varianten en elk heeft een specifiek gebruik:
getBy* — Geeft het element terug of gooit een fout als het niet gevonden wordt. Gebruik wanneer je verwacht dat het element bestaat:
// Gooit een fout als er geen button gevonden wordt — test faalt met een duidelijke foutmelding
const button = screen.getByRole("button", { name: "Submit" });queryBy* — Geeft het element terug of null als het niet gevonden wordt. Gebruik wanneer je wilt bevestigen dat iets NIET aanwezig is:
// Geeft null terug — gooit geen fout
expect(screen.queryByText("Error message")).not.toBeInTheDocument();findBy* — Geeft een Promise terug. Gebruik voor elementen die asynchroon verschijnen:
// Wacht tot 1000ms tot het element verschijnt
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();Accessibility-First Queries#
Testing Library biedt deze queries in een bewuste prioriteitsvolgorde:
getByRole— De beste query. Gebruikt ARIA-rollen. Als je component niet vindbaar is op rol, heeft het misschien een toegankelijkheidsprobleem.getByLabelText— Voor formulierelementen. Als je input geen label heeft, los dat dan eerst op.getByPlaceholderText— Acceptabel maar zwakker. Placeholders verdwijnen wanneer de gebruiker typt.getByText— Voor niet-interactieve elementen. Vindt op basis van zichtbare tekstinhoud.getByTestId— Laatste redmiddel. Gebruik wanneer geen enkele semantische query werkt.
// Geef hier de voorkeur aan
screen.getByRole("textbox", { name: "Email address" });
// Boven dit
screen.getByPlaceholderText("Enter your email");
// En zeker boven dit
screen.getByTestId("email-input");De rangschikking is niet willekeurig. Het komt overeen met hoe hulptechnologie de pagina navigeert. Als je een element kunt vinden op zijn rol en toegankelijke naam, kunnen schermlezers dat ook. Als je het alleen kunt vinden met een test-ID, heb je misschien een toegankelijkheidsgat.
User Events#
Gebruik niet fireEvent. Gebruik @testing-library/user-event. Het verschil is belangrijk:
import userEvent from "@testing-library/user-event";
describe("SearchInput", () => {
it("filters results as user types", async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
const input = screen.getByRole("searchbox");
await user.type(input, "vitest");
// user.type vuurt keydown, keypress, input, keyup af voor ELK karakter
// fireEvent.change stelt alleen de waarde in — slaat de realistische event-flow over
expect(onSearch).toHaveBeenLastCalledWith("vitest");
});
it("clears input on escape key", async () => {
const user = userEvent.setup();
render(<SearchInput onSearch={vi.fn()} />);
const input = screen.getByRole("searchbox");
await user.type(input, "hello");
await user.keyboard("{Escape}");
expect(input).toHaveValue("");
});
});userEvent simuleert de volledige event-keten die een echte browser zou afvuren. fireEvent.change is een enkel synthetisch event. Als je component luistert naar onKeyDown of onInput gebruikt in plaats van onChange, triggert fireEvent.change die handlers niet, maar userEvent.type wel.
Roep altijd userEvent.setup() aan het begin aan en gebruik de teruggegeven user-instance. Dit zorgt voor correcte event-volgorde en state-tracking.
Component-interacties Testen#
Een realistische componenttest ziet er zo uit:
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
describe("TodoList", () => {
it("adds a new todo item", async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByRole("textbox", { name: /new todo/i });
const addButton = screen.getByRole("button", { name: /add/i });
await user.type(input, "Write tests");
await user.click(addButton);
expect(screen.getByText("Write tests")).toBeInTheDocument();
expect(input).toHaveValue("");
});
it("marks a todo as completed", async () => {
const user = userEvent.setup();
render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
it("removes completed items when clear button is clicked", async () => {
const user = userEvent.setup();
render(
<TodoList
initialItems={[
{ id: "1", text: "Done task", done: true },
{ id: "2", text: "Pending task", done: false },
]}
/>
);
await user.click(screen.getByRole("button", { name: /clear completed/i }));
expect(screen.queryByText("Done task")).not.toBeInTheDocument();
expect(screen.getByText("Pending task")).toBeInTheDocument();
});
});Let op: geen interne state-inspectie, geen component.setState(), geen implementatiedetails checken. We renderen, we interageren, we asserteren op wat de gebruiker zou zien. Als het component zijn interne state management van useState naar useReducer refactort, slagen deze tests nog steeds. Dat is het punt.
Asynchrone Code Testen#
waitFor#
Wanneer een component asynchroon bijwerkt, pollt waitFor totdat de assertion slaagt:
import { render, screen, waitFor } from "@testing-library/react";
it("loads and displays user profile", async () => {
render(<UserProfile userId="123" />);
// Toont initieel de loading-state
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Wacht tot de content verschijnt
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
// Loading-indicator zou weg moeten zijn
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});waitFor herhaalt de callback standaard elke 50ms totdat het slaagt of de timeout bereikt is (standaard 1000ms). Je kunt beide aanpassen:
await waitFor(
() => expect(screen.getByText("Done")).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
);Fake Timers#
Bij het testen van code die setTimeout, setInterval of Date gebruikt, laten fake timers je de tijd besturen:
describe("Debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("delays execution by the specified time", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
it("resets timer on subsequent calls", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
vi.advanceTimersByTime(200);
debounced(); // reset de timer
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
});Belangrijk: roep altijd vi.useRealTimers() aan in afterEach. Fake timers die lekken tussen tests veroorzaken de meest verwarrende failures die je ooit zult debuggen.
Testen met Fake Timers en Asynchrone Rendering#
Het combineren van fake timers met React-componenttests vereist zorgvuldigheid. Reacts interne scheduling gebruikt echte timers, dus je moet vaak timers vooruit zetten EN React-updates flushen tegelijk:
import { render, screen, act } from "@testing-library/react";
describe("Notification", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-dismisses after 5 seconds", async () => {
render(<Notification message="Saved!" autoDismiss={5000} />);
expect(screen.getByText("Saved!")).toBeInTheDocument();
// Zet timers vooruit binnen act() om React-updates te flushen
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
});
});API Mocking met MSW#
Voor het testen van data fetching onderschept Mock Service Worker (MSW) netwerkverzoeken op netwerkniveau. Dit betekent dat je component's fetch/axios-code precies zo draait als in productie — MSW vervangt alleen het netwerkantwoord:
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
const server = setupServer(
http.get("/api/users", () => {
return HttpResponse.json([
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
]);
}),
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: "Alice",
email: "alice@example.com",
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("UserList", () => {
it("displays users from API", async () => {
render(<UserList />);
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(await screen.findByText("Bob")).toBeInTheDocument();
});
it("shows error state when API fails", async () => {
// Overschrijf de standaard handler voor deze ene test
server.use(
http.get("/api/users", () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserList />);
expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
});
});MSW is beter dan fetch of axios direct mocken omdat:
- De daadwerkelijke data fetching-code van je component draait — je test de echte integratie.
- Je kunt error handling testen door handlers per test te overschrijven.
- Dezelfde handlers werken in zowel tests als de browser dev-modus (Storybook, bijvoorbeeld).
Integratietests#
Unit Tests vs Integratietests#
Een unit test isoleert een enkele functie of component en mockt al het andere. Een integratietest laat meerdere units samenwerken en mockt alleen externe grenzen (netwerk, bestandssysteem, databases).
De waarheid is: de meeste bugs die ik in productie heb gezien, ontstaan op de grenzen tussen units, niet erbinnen. Een functie werkt perfect in isolatie maar faalt omdat de aanroeper data in een net iets ander formaat doorgeeft. Een component rendert prima met mock-data maar breekt wanneer de daadwerkelijke API-response een extra nesting-niveau heeft.
Integratietests vangen deze bugs. Ze zijn langzamer dan unit tests en moeilijker te debuggen wanneer ze falen, maar ze geven meer vertrouwen per test.
Meerdere Componenten Samen Testen#
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
// Mock alleen de API-laag — al het andere is echt
vi.mock("@/lib/api", () => ({
checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
describe("Shopping Cart Flow", () => {
const renderCart = (initialItems = []) => {
return render(
<CartProvider initialItems={initialItems}>
<ShoppingCart />
</CartProvider>
);
};
it("displays item count and total", () => {
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
{ id: "2", name: "Mouse", price: 49.99, quantity: 2 },
]);
expect(screen.getByText("3 items")).toBeInTheDocument();
expect(screen.getByText("$179.97")).toBeInTheDocument();
});
it("updates quantity and recalculates total", async () => {
const user = userEvent.setup();
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
const incrementButton = screen.getByRole("button", { name: /increase quantity/i });
await user.click(incrementButton);
expect(screen.getByText("$159.98")).toBeInTheDocument();
});
it("completes checkout flow", async () => {
const user = userEvent.setup();
const { checkout } = await import("@/lib/api");
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
await user.click(screen.getByRole("button", { name: /checkout/i }));
expect(checkout).toHaveBeenCalledWith({
items: [{ id: "1", quantity: 1 }],
});
expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
expect(screen.getByText("ORD-123")).toBeInTheDocument();
});
});In deze test werken ShoppingCart en CartProvider en hun interne componenten (item-rijen, quantity-selectors, totalen-weergave) allemaal samen met echte code. De enige mock is de API-aanroep, omdat we geen echte netwerkverzoeken willen doen in tests.
Wanneer Integratie- vs Unit Tests Gebruiken#
Gebruik unit tests wanneer:
- De logica complex is en veel edge-cases heeft (een date parser, een state machine, een berekening).
- Je snelle feedback nodig hebt over het gedrag van een specifieke functie.
- De unit relatief geïsoleerd is en niet sterk afhankelijk is van andere units.
Gebruik integratietests wanneer:
- Meerdere componenten correct moeten samenwerken (een formulier met validatie en verzending).
- Data door meerdere lagen stroomt (context -> component -> child component).
- Je een gebruikersworkflow test, niet de returnwaarde van een functie.
In de praktijk heeft een gezonde testsuite veel integratietests voor features en unit tests voor complexe utilities. De componenten zelf worden getest via integratietests — je hebt geen aparte unit test nodig voor elk klein component als de integratietest het gebruikt.
Coverage#
Coverage Draaien#
vitest run --coverageJe hebt een coverage-provider nodig. Vitest ondersteunt er twee:
# V8 — sneller, gebruikt V8's ingebouwde coverage
npm install -D @vitest/coverage-v8
# Istanbul — volwassener, meer configuratieopties
npm install -D @vitest/coverage-istanbulConfigureer het in je Vitest-config:
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/test/**",
"src/**/*.d.ts",
"src/**/types.ts",
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});Istanbul vs V8#
V8 coverage gebruikt de ingebouwde instrumentatie van de V8-engine. Het is sneller omdat er geen code-transformatiestap is. Maar het kan minder nauwkeurig zijn voor sommige edge-cases, vooral rond ES module-grenzen.
Istanbul instrumenteert je broncode met tellers voordat tests worden uitgevoerd. Het is langzamer maar meer beproefd en geeft nauwkeurigere branch coverage. Als je coverage-drempels afdwingt in CI, kan de nauwkeurigheid van Istanbul ertoe doen.
Ik gebruik V8 voor lokale development (snellere feedback) en Istanbul in CI (nauwkeurigere handhaving). Je kunt indien nodig verschillende providers per omgeving configureren.
Wat Coverage Eigenlijk Betekent#
Coverage vertelt je welke regels code werden uitgevoerd tijdens tests. Dat is het. Het vertelt je niet of die regels correct werden getest. Kijk hier eens naar:
function divide(a: number, b: number): number {
return a / b;
}
it("divides numbers", () => {
divide(10, 2);
// Geen assertion!
});Deze test geeft 100% coverage van de divide-functie. Het test ook absoluut niets. De test zou slagen als divide null teruggaf, een error gooide, of raketten lanceerde.
Coverage is een nuttige negatieve indicator: lage coverage betekent dat er zeker ongeteste paden zijn. Maar hoge coverage betekent niet dat je code goed getest is. Het betekent alleen dat elke regel gedraaid is tijdens een of andere test.
Lines vs Branches#
Line coverage is de meest voorkomende metric, maar branch coverage is waardevoller:
function getDiscount(user: User): number {
if (user.isPremium) {
return user.yearsActive > 5 ? 0.2 : 0.1;
}
return 0;
}Een test met getDiscount({ isPremium: true, yearsActive: 10 }) raakt elke regel (100% line coverage) maar test slechts twee van de drie branches. Het isPremium: false-pad en het yearsActive <= 5-pad zijn ongetest.
Branch coverage vangt dit op. Het volgt elk mogelijk pad door conditionele logica. Als je een coverage-drempel gaat afdwingen, gebruik dan branch coverage.
Gegenereerde Code Negeren#
Sommige code hoort niet meegeteld te worden in coverage. Gegenereerde bestanden, type-definities, configuratie — deze blazen je metrics op zonder waarde toe te voegen:
// vitest.config.ts
coverage: {
exclude: [
"src/**/*.d.ts",
"src/**/types.ts",
"src/**/*.stories.tsx",
"src/generated/**",
".velite/**",
],
}Je kunt ook specifieke regels of blokken in je broncode negeren:
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
console.log("Debug info:", data);
}
/* v8 ignore stop */
// Of voor Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }Gebruik dit spaarzaam. Als je grote stukken code negeert, hebben die stukken ofwel tests nodig, ofwel horen ze überhaupt niet in het coverage-rapport.
Next.js Testen#
Mocking van next/navigation#
Next.js-componenten die useRouter, usePathname of useSearchParams gebruiken, hebben mocks nodig:
import { vi } from "vitest";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/en/blog",
useSearchParams: () => new URLSearchParams("?page=1"),
useParams: () => ({ locale: "en" }),
}));Voor tests die moeten verifiëren dat navigatie is aangeroepen:
import { useRouter } from "next/navigation";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
describe("LogoutButton", () => {
it("redirects to home after logout", async () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
forward: vi.fn(),
});
const user = userEvent.setup();
render(<LogoutButton />);
await user.click(screen.getByRole("button", { name: /log out/i }));
expect(mockPush).toHaveBeenCalledWith("/");
});
});Mocking van next-intl#
Voor geïnternationaliseerde componenten die next-intl gebruiken:
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));Dit is de simpelste aanpak — vertalingen geven de key zelf terug, dus t("hero.title") geeft "hero.title" terug. In assertions check je op de vertaalkey in plaats van de daadwerkelijk vertaalde string. Dit maakt tests taalonafhankelijk.
Als je daadwerkelijke vertalingen nodig hebt in een specifieke test:
vi.mock("next-intl", () => ({
useTranslations: () => {
const translations: Record<string, string> = {
"hero.title": "Welcome to My Site",
"hero.subtitle": "Building things for the web",
};
return (key: string) => translations[key] ?? key;
},
}));Route Handlers Testen#
Next.js Route Handlers zijn gewone functies die een Request nemen en een Response teruggeven. Ze zijn eenvoudig te testen:
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
describe("GET /api/users", () => {
it("returns users list", async () => {
const request = new NextRequest("http://localhost:3000/api/users");
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
])
);
});
it("supports pagination via search params", async () => {
const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
const response = await GET(request);
const data = await response.json();
expect(data.page).toBe(2);
expect(data.items).toHaveLength(10);
});
});
describe("POST /api/users", () => {
it("creates a new user", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.name).toBe("Alice");
});
it("returns 400 for invalid body", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "" }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});Middleware Testen#
Next.js middleware draait op de edge en verwerkt elk verzoek. Test het als een functie:
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
const url = new URL(path, "http://localhost:3000");
return new NextRequest(url, { headers });
}
describe("middleware", () => {
it("redirects unauthenticated users from protected routes", async () => {
const request = createRequest("/dashboard");
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/login");
});
it("allows authenticated users through", async () => {
const request = createRequest("/dashboard", {
cookie: "session=valid-token",
});
const response = await middleware(request);
expect(response.status).toBe(200);
});
it("adds security headers", async () => {
const request = createRequest("/");
const response = await middleware(request);
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("handles locale detection", async () => {
const request = createRequest("/", {
"accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
});
const response = await middleware(request);
expect(response.headers.get("location")).toContain("/tr");
});
});Server Actions Testen#
Server Actions zijn async functies die op de server draaien. Omdat het gewoon functies zijn, kun je ze direct testen — maar je moet mogelijk server-only dependencies mocken:
vi.mock("@/lib/db", () => ({
db: {
user: {
update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
},
},
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
describe("updateProfile", () => {
it("updates user and revalidates profile page", async () => {
const formData = new FormData();
formData.set("name", "New Name");
formData.set("bio", "New bio text");
const result = await updateProfile(formData);
expect(result.success).toBe(true);
expect(revalidatePath).toHaveBeenCalledWith("/profile");
});
it("returns error for invalid data", async () => {
const formData = new FormData();
// Verplichte velden ontbreken
const result = await updateProfile(formData);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});Praktische Patronen#
Custom Render-functie#
De meeste projecten hebben dezelfde providers nodig die om elk component gewrapped worden. Maak een custom render aan:
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
interface CustomRenderOptions extends RenderOptions {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
children: React.ReactNode;
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}) {
return (
<ThemeProvider defaultTheme={theme}>
<CartProvider initialItems={initialCartItems}>
{children}
</CartProvider>
</ThemeProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
const { theme, initialCartItems, ...renderOptions } = options;
return render(ui, {
wrapper: ({ children }) => (
<AllProviders theme={theme} initialCartItems={initialCartItems}>
{children}
</AllProviders>
),
...renderOptions,
});
}
// Herexporteer alles van testing library
export * from "@testing-library/react";
export { renderWithProviders as render };Nu importeert elk testbestand vanuit je custom utils in plaats van @testing-library/react:
import { render, screen } from "@/test/utils";
it("renders in dark mode", () => {
render(<Header />, { theme: "dark" });
// Header en al zijn children hebben toegang tot ThemeProvider en CartProvider
});Custom Hooks Testen#
Vitest werkt met renderHook van @testing-library/react:
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
describe("useCounter", () => {
it("starts at initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increments", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrements with floor", () => {
const { result } = renderHook(() => useCounter(0, { min: 0 }));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0); // gaat niet onder het minimum
});
});Error Boundaries Testen#
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
const ThrowingComponent = () => {
throw new Error("Test explosion");
};
describe("ErrorBoundary", () => {
// Onderdruk console.error voor verwachte fouten
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("displays fallback UI when child throws", () => {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("renders children when no error", () => {
render(
<ErrorBoundary fallback={<div>Error</div>}>
<div>All good</div>
</ErrorBoundary>
);
expect(screen.getByText("All good")).toBeInTheDocument();
expect(screen.queryByText("Error")).not.toBeInTheDocument();
});
});Snapshot Testing (Met Voorzichtigheid)#
Snapshot-tests hebben een slechte reputatie omdat mensen ze gebruiken als vervanging voor echte assertions. Een snapshot van de volledige HTML-output van een component is een onderhoudslast — het breekt bij elke CSS-class wijziging en niemand bekijkt de diff zorgvuldig.
Maar gerichte snapshots kunnen nuttig zijn:
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
// Goed — kleine, gerichte snapshot van de output van een pure functie
it("formats various currency values consistently", () => {
expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
// Slecht — gigantische snapshot die niemand gaat reviewen
it("renders the dashboard", () => {
const { container } = render(<Dashboard />);
expect(container).toMatchSnapshot(); // Doe dit niet
});Inline snapshots (toMatchInlineSnapshot) zijn beter dan bestandssnapshots omdat de verwachte waarde direct in de test zichtbaar is. Je kunt in één oogopslag zien wat de functie teruggeeft zonder een apart .snap-bestand te openen.
De Testfilosofie#
Test Gedrag, Niet Implementatie#
Dit principe is zo belangrijk dat het een eigen sectie verdient. Bekijk twee tests voor dezelfde feature:
// Implementatietest — kwetsbaar, breekt bij refactors
it("calls setState with new count", () => {
const setStateSpy = vi.spyOn(React, "useState");
render(<Counter />);
fireEvent.click(screen.getByText("+"));
expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
// Gedragstest — veerkrachtig, test wat de gebruiker ziet
it("increments the displayed count when plus button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "+" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});De eerste test breekt als je overschakelt van useState naar useReducer, ook al werkt het component precies hetzelfde. De tweede test breekt alleen als het gedrag van het component daadwerkelijk verandert. Het maakt niet uit hoe de counter intern wordt beheerd — alleen dat klikken op "+" het getal omhoog laat gaan.
De lakmoesproef is simpel: kun je de implementatie refactoren zonder de test aan te passen? Zo ja, dan test je gedrag. Zo nee, dan test je implementatie.
De Testing Trophy#
Kent C. Dodds stelde de "Testing Trophy" voor als alternatief voor de traditionele testpiramide:
╭─────────╮
│ E2E │ Weinig — duur, traag, hoog vertrouwen
├─────────┤
│ │
│ Integr. │ Meeste — goede vertrouwen-kosten verhouding
│ │
├─────────┤
│ Unit │ Sommige — snel, gefocust, lage kosten
├─────────┤
│ Static │ Altijd — TypeScript, ESLint
╰─────────╯
De traditionele piramide plaatst unit tests onderaan (veel ervan) en integratietests in het midden (minder). De trophy keert dit om: integratietests zijn de sweet spot. Dit is waarom:
- Statische analyse (TypeScript, ESLint) vangt typefouten, verkeerde types en simpele logische fouten gratis op. Je hoeft niet eens iets te draaien.
- Unit tests zijn geweldig voor complexe pure logica maar vertellen je niet of de stukken samenwerken.
- Integratietests verifiëren dat componenten, hooks en contexts samenwerken. Ze geven het meeste vertrouwen per geschreven test.
- End-to-end tests verifiëren het hele systeem maar zijn traag, onbetrouwbaar en duur om te onderhouden. Je hebt er een paar nodig voor kritieke paden maar geen honderden.
In de praktijk volg ik deze verdeling: TypeScript vangt de meeste typefouten op, ik schrijf unit tests voor complexe utilities en algoritmen, integratietests voor features en gebruikersflows, en een handvol E2E-tests voor het kritieke pad (registreren, aankoop, kernworkflow).
Wat Vertrouwen Geeft vs Wat Tijd Verspilt#
Tests bestaan om je het vertrouwen te geven om te shippen. Niet het vertrouwen dat elke regel code draait — het vertrouwen dat de applicatie werkt voor gebruikers. Dat zijn twee verschillende dingen.
Hoog vertrouwen, hoge waarde:
- Integratietest van een checkout-flow — dekt formuliervalidatie, API-aanroepen, state-updates en succes-/fout-UI.
- Unit test van een prijsberekeningsfunctie met edge-cases — floating point, afronding, kortingen, nul/negatieve waarden.
- Test dat beschermde routes niet-geauthenticeerde gebruikers doorverwijzen.
Laag vertrouwen, tijdverspillers:
- Snapshot-test van een statische marketingpagina — breekt elke keer dat tekst verandert, vangt niets zinvols op.
- Unit test dat een component een prop doorgeeft aan een child — test React zelf, niet jouw code.
- Test dat
useStatewordt aangeroepen — test het framework, niet gedrag. - 100% coverage van een configuratiebestand — het is statische data, TypeScript valideert de structuur al.
De vraag om te stellen voordat je een test schrijft: "Als deze test niet bestond, welke bug zou dan in productie kunnen slippen?" Als het antwoord is "geen die TypeScript niet zou opvangen" of "geen die iemand zou opmerken," is de test waarschijnlijk niet de moeite waard.
Testen als Ontwerpfeedback#
Code die moeilijk te testen is, is meestal slecht ontworpen code. Als je vijf dingen moet mocken om één functie te testen, heeft die functie te veel dependencies. Als je een component niet kunt renderen zonder uitgebreide context providers op te zetten, is het component te sterk gekoppeld aan zijn omgeving.
Tests zijn een gebruiker van je code. Als je tests moeite hebben om je API te gebruiken, zullen andere developers dat ook hebben. Wanneer je merkt dat je worstelt met de test-setup, neem het als signaal om de code onder test te refactoren, niet om meer mocks toe te voegen.
// Moeilijk te testen — functie doet te veel
async function processOrder(orderId: string) {
const order = await db.orders.findById(orderId);
const user = await db.users.findById(order.userId);
const inventory = await checkInventory(order.items);
if (!inventory.available) {
await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
return { success: false, reason: "out-of-stock" };
}
const payment = await chargeCard(user.paymentMethod, order.total);
if (!payment.success) {
await sendEmail(user.email, "payment-failed", { error: payment.error });
return { success: false, reason: "payment-failed" };
}
await db.orders.update(orderId, { status: "confirmed" });
await sendEmail(user.email, "order-confirmed", { orderId });
return { success: true };
}
// Makkelijker te testen — gescheiden verantwoordelijkheden
function determineOrderAction(
inventory: InventoryResult,
payment: PaymentResult
): OrderAction {
if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
if (!payment.success) return { type: "payment-failed", error: payment.error };
return { type: "confirmed" };
}De tweede versie is een pure functie. Je kunt elke combinatie van voorraad- en betalingsresultaten testen zonder een database, betaalprovider of e-mailservice te mocken. De orchestratielogica (data ophalen, e-mails versturen) leeft in een dunne laag die op integratieniveau getest wordt.
Dit is de echte waarde van testen: niet bugs opvangen nadat ze geschreven zijn, maar slechte ontwerpen voorkomen voordat ze gecommit worden. De discipline van tests schrijven duwt je richting kleinere functies, duidelijkere interfaces en meer modulaire architectuur. De tests zijn het bijeffect. De ontwerpverbetering is de hoofdact.
Schrijf Tests Die Je Vertrouwt#
Het ergste dat een testsuite kan overkomen is niet dat het hiaten heeft. Het is dat mensen het niet meer vertrouwen. Een testsuite met een paar flaky tests die willekeurig falen op CI leert het team om rode builds te negeren. Zodra dat gebeurt, is de testsuite erger dan nutteloos — het biedt actief vals gevoel van veiligheid.
Als een test af en toe faalt, repareer het of verwijder het. Als een test traag is, versnel het of verplaats het naar een aparte slow-test suite. Als een test breekt bij elke ongerelateerde wijziging, herschrijf het om gedrag te testen in plaats van implementatie.
Het doel is een testsuite waarbij elke failure betekent dat er echt iets stuk is. Wanneer developers de tests vertrouwen, draaien ze die voor elke commit. Wanneer ze de tests niet vertrouwen, omzeilen ze die met --no-verify en deployen met gekruiste vingers.
Bouw een testsuite waar je je weekend op zou verwedden. Niets minder is het onderhouden waard.