Gå till innehåll
·30 min läsning

Vitest-testguide: Från noll till produktionsförtroende

Enhetstester, integrationstester, komponenttester med Testing Library, mockningsstrategier, coverage-konfiguration och testfilosofin som faktiskt producerar bättre mjukvara.

Dela:X / TwitterLinkedIn

De flesta team skriver tester på samma sätt som de flesta tränar: de vet att de borde, de känner skuld när de inte gör det, och när de väl gör det, kör de för hårt dag ett och ger upp redan på fredag. Kodbasen slutar med ett spridda ytliga snapshot-tester som går sönder varje gång någon ändrar en CSS-klass, några integrationstester som ingen litar på, och en coverage-badge i README:n som ljuger.

Jag har varit på båda sidor. Jag har levererat projekt med noll tester och svettas igenom varje deploy. Jag har också varit i team som jagade 100% täckning och spenderade mer tid på att underhålla tester än på att skriva funktioner. Inget av det fungerar. Det som fungerar är att skriva rätt tester, på rätt ställen, med rätt verktyg.

Vitest förändrade hur jag tänker kring testning i JavaScript. Inte för att det uppfann nya koncept — grunderna har inte förändrats sedan Kent Beck skrev om dem för decennier sedan. Men för att det tog bort tillräckligt med friktion så att testskrivning slutade kännas som ett måste och började kännas som en del av utvecklingsflödet. När din testrunner är lika snabb som din dev-server och använder samma konfiguration, försvinner ursäkterna.

Det här inlägget är allt jag vet om testning med Vitest, från initial uppsättning till filosofin som gör allt värt besväret.

Varför Vitest framför Jest#

Om du har använt Jest känner du redan till merparten av Vitests API. Det är medvetet — Vitest är Jest-kompatibelt på API-nivå. describe, it, expect, beforeEach, vi.fn() — allt fungerar. Så varför byta?

Inbyggt ESM-stöd#

Jest byggdes för CommonJS. Det kan hantera ESM, men det kräver konfiguration, experimentella flaggor och ibland ren bön. Om du använder import/export-syntax (vilket är allt modernt), har du antagligen kämpat med Jests transform-pipeline.

Vitest körs på Vite. Vite förstår ESM nativt. Det finns inget transform-steg för din källkod — det bara fungerar. Det spelar större roll än det låter. Hälften av alla Jest-problem jag har felsökt genom åren spåras tillbaka till modulupplösning: SyntaxError: Cannot use import statement outside a module, eller mockar som inte fungerar för att modulen redan cachades i ett annat format.

Samma konfiguration som din dev-server#

Om ditt projekt använder Vite (och om du bygger en React-, Vue- eller Svelte-app 2026, gör det förmodligen det), läser Vitest din vite.config.ts automatiskt. Dina sökvägsalias, plugins och miljövariabler fungerar i tester utan någon extra konfiguration. Med Jest underhåller du en parallell konfiguration som måste hållas synkroniserad med din bundler-uppsättning. Varje gång du lägger till ett sökvägsalias i vite.config.ts måste du komma ihåg att lägga till motsvarande moduleNameMapper i jest.config.ts. Det är en liten sak, men små saker ackumuleras.

Hastighet#

Vitest är snabbt. Meningsfullt snabbt. Inte "sparar dig två sekunder"-snabbt — "förändrar hur du arbetar"-snabbt. Det använder Vites modulgraf för att förstå vilka tester som påverkas av en filändring och kör bara de. Dess watch-läge använder samma HMR-infrastruktur som gör Vites dev-server ögonblicklig.

I ett projekt med 400+ tester skar bytet från Jest till Vitest ned vår watch-mode-feedbackloop från ~4 sekunder till under 500ms. Det är skillnaden mellan "jag väntar på att testet ska passera" och "jag kastar en blick på terminalen medan fingrarna fortfarande är på tangentbordet."

Inbyggd benchmarking#

Vitest inkluderar bench() direkt ur lådan för prestandatester. Inget separat bibliotek behövs:

typescript
import { bench, describe } from "vitest";
 
describe("strängkonkatenering", () => {
  bench("template literals", () => {
    const name = "world";
    const _result = `hello ${name}`;
  });
 
  bench("strängkonkatenering", () => {
    const name = "world";
    const _result = "hello " + name;
  });
});

Kör med vitest bench. Det är inte huvudnumret, men det är skönt att ha prestandatestning i samma verktygskedja utan att installera benchmark.js och koppla upp en separat runner.

Uppsättning#

Installation#

bash
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Konfiguration#

Skapa vitest.config.ts i din projektrot:

typescript
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,
  },
});

Eller, om du redan har en vite.config.ts, kan du utöka den:

typescript
/// <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"],
  },
});

Beslutet om globals: true#

När globals är true behöver du inte importera describe, it, expect, beforeEach och så vidare — de är tillgängliga överallt, precis som i Jest. När det är false importerar du dem explicit:

typescript
// globals: false
import { describe, it, expect } from "vitest";
 
describe("matematik", () => {
  it("adderar tal", () => {
    expect(1 + 1).toBe(2);
  });
});

Jag använder globals: true eftersom det minskar visuellt brus och matchar vad de flesta utvecklare förväntar sig. Om du är i ett team som värdesätter explicita importer, sätt det till false — det finns inget fel svar här.

Om du använder globals: true, lägg till Vitests typer i din tsconfig.json så att TypeScript känner igen dem:

json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

Miljö: jsdom vs happy-dom vs node#

Vitest låter dig välja DOM-implementation per test eller globalt:

  • node — Ingen DOM. För ren logik, hjälpfunktioner, API-routes och allt som inte rör webbläsaren.
  • jsdom — Standarden. Full DOM-implementation. Tyngre men mer komplett.
  • happy-dom — Lättare och snabbare än jsdom men mindre komplett. Vissa specialfall (som Range, Selection eller IntersectionObserver) kanske inte fungerar.

Jag använder jsdom globalt som standard och överskrider per fil när jag behöver node:

typescript
// src/lib/utils.test.ts
// @vitest-environment node
 
import { formatDate, slugify } from "./utils";
 
describe("slugify", () => {
  it("konverterar mellanslag till bindestreck", () => {
    expect(slugify("hello world")).toBe("hello-world");
  });
});

Setup-filer#

Setup-filen körs före varje testfil. Det är här du konfigurerar Testing Library-matchers och globala mockar:

typescript
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
 
// Mocka IntersectionObserver — jsdom implementerar det inte
class MockIntersectionObserver {
  observe = vi.fn();
  unobserve = vi.fn();
  disconnect = vi.fn();
}
 
Object.defineProperty(window, "IntersectionObserver", {
  writable: true,
  value: MockIntersectionObserver,
});
 
// Mocka window.matchMedia — behövs för responsiva komponenter
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(),
  })),
});

Importen @testing-library/jest-dom/vitest ger dig matchers som toBeInTheDocument(), toHaveClass(), toBeVisible() och många fler. Dessa gör assertions på DOM-element läsbara och uttrycksfulla.

Att skriva bra tester#

AAA-mönstret#

Varje test följer samma struktur: Arrange, Act, Assert. Även när du inte skriver explicita kommentarer bör strukturen vara synlig:

typescript
it("beräknar totalen inklusive moms", () => {
  // 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);
});

När jag ser ett test som blandar förberedelse, handling och påstående i en enda kedja av metodanrop vet jag att det kommer bli svårt att förstå när det misslyckas. Håll de tre faserna visuellt åtskilda även om du inte lägger till kommentarerna.

Testnamngivning#

Det finns två skolor: it('should calculate total with tax') och it('calculates total with tax'). Prefixet "should" är ordrik utan att tillföra information. När testet misslyckas ser du:

FAIL  ✕ calculates total with tax

Det är redan en fullständig mening. Att lägga till "should" lägger bara till brus. Jag föredrar den direkta formen: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').

För describe-block, använd namnet på enheten som testas:

typescript
describe("calculateTotal", () => {
  it("summerar artikelpriser", () => { /* ... */ });
  it("tillämpar skattesats", () => { /* ... */ });
  it("returnerar 0 för tom array", () => { /* ... */ });
  it("hanterar negativa priser", () => { /* ... */ });
});

Läs det högt: "calculateTotal summerar artikelpriser." "calculateTotal tillämpar skattesats." Om meningen fungerar, fungerar namngivningen.

En assertion per test kontra praktisk gruppering#

Den puristiska regeln säger en assertion per test. Den praktiska regeln säger: ett koncept per test. Dessa är olika saker.

typescript
// Det här är okej — ett koncept, flera assertions
it("formaterar användarens visningsnamn", () => {
  const user = { firstName: "John", lastName: "Doe", title: "Dr." };
  const result = formatDisplayName(user);
 
  expect(result).toContain("John");
  expect(result).toContain("Doe");
  expect(result).toStartWith("Dr.");
});
 
// Det här är inte okej — flera koncept i ett test
it("hanterar användaroperationer", () => {
  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();
});

Det första testet har tre assertions men de verifierar alla en sak: formatet på ett visningsnamn. Om någon assertion misslyckas vet du exakt vad som är trasigt. Det andra testet är tre separata tester hopklämda. Om den andra assertionen misslyckas vet du inte om det är skapande eller uppdatering som är trasigt, och den tredje assertionen körs aldrig.

Testbeskrivningar som dokumentation#

Bra testsviter fungerar som levande dokumentation. Någon som inte känner till koden bör kunna läsa testbeskrivningarna och förstå funktionens beteende:

typescript
describe("PasswordValidator", () => {
  describe("minimilängd", () => {
    it("avvisar lösenord kortare än 8 tecken", () => { /* ... */ });
    it("accepterar lösenord med exakt 8 tecken", () => { /* ... */ });
  });
 
  describe("teckenkrav", () => {
    it("kräver minst en versal", () => { /* ... */ });
    it("kräver minst en siffra", () => { /* ... */ });
    it("kräver minst ett specialtecken", () => { /* ... */ });
  });
 
  describe("kontroll av vanliga lösenord", () => {
    it("avvisar lösenord som finns i listan över vanliga lösenord", () => { /* ... */ });
    it("utför skiftlägesokänslig jämförelse", () => { /* ... */ });
  });
});

När denna testsvit körs läser utmatningen som en specifikation. Det är målet.

Mockning#

Mockning är det mest kraftfulla och mest farliga verktyget i din testverktygslåda. Använd den väl så isolerar den enheten som testas och gör tester snabba och deterministiska. Använd den illa så skapar den tester som passerar oavsett vad koden gör.

vi.fn() — Skapa mock-funktioner#

Den enklaste mocken är en funktion som registrerar sina anrop:

typescript
const mockCallback = vi.fn();
 
// Anropa den
mockCallback("hello", 42);
mockCallback("world");
 
// Assertera på anrop
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");

Du kan ge den ett returvärde:

typescript
const mockFetch = vi.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve({ id: 1, name: "Test" }),
});

Eller låta den returnera olika värden vid successiva anrop:

typescript
const mockRandom = vi.fn()
  .mockReturnValueOnce(0.1)
  .mockReturnValueOnce(0.5)
  .mockReturnValueOnce(0.9);

vi.spyOn() — Observera riktiga metoder#

När du vill observera en metod utan att ersätta dess beteende:

typescript
const consoleSpy = vi.spyOn(console, "warn");
 
validateInput("");
 
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();

spyOn behåller den ursprungliga implementationen som standard. Du kan överskrida den med .mockImplementation() vid behov men återställ originalet efteråt med .mockRestore().

vi.mock() — Mockning på modulnivå#

Det här är den stora. vi.mock() ersätter en hel modul:

typescript
// Mocka hela modulen
vi.mock("@/lib/api", () => ({
  fetchUsers: vi.fn().mockResolvedValue([
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" },
  ]),
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
 
// Importen använder nu den mockade versionen
import { fetchUsers } from "@/lib/api";
 
describe("UserList", () => {
  it("visar användare från API", async () => {
    const users = await fetchUsers();
    expect(users).toHaveLength(2);
  });
});

Vitest hissar vi.mock()-anrop till toppen av filen automatiskt. Det betyder att mocken är på plats innan några importer körs. Du behöver inte oroa dig för importordning.

Automatisk mockning#

Om du bara vill att varje export ersätts med en vi.fn():

typescript
vi.mock("@/lib/analytics");
 
import { trackEvent, trackPageView } from "@/lib/analytics";
 
it("spårar formulärinskickning", () => {
  submitForm();
  expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});

Utan factory-funktionen auto-mockar Vitest alla exporter. Varje exporterad funktion blir en vi.fn() som returnerar undefined. Det är användbart för moduler du vill tysta (som analys eller loggning) utan att specificera varje funktion.

Clearing kontra Resetting kontra Restoring#

Det här förvirrar alla någon gång:

typescript
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
 
// mockClear — återställer anropshistorik, behåller implementation
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // returnerar fortfarande 42
 
// mockReset — återställer anropshistorik OCH implementation
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // returnerar inte längre 42
 
// mockRestore — för spioner, återställer ursprunglig implementation
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() fungerar normalt igen

I praktiken, använd vi.clearAllMocks() i beforeEach för att återställa anropshistorik mellan tester. Använd vi.restoreAllMocks() om du använder spyOn och vill ha tillbaka originalen:

typescript
beforeEach(() => {
  vi.clearAllMocks();
});
 
afterEach(() => {
  vi.restoreAllMocks();
});

Faran med övermockning#

Det här är det viktigaste mockning-rådet jag kan ge: varje mock är en lögn du berättar för ditt test. När du mockar ett beroende säger du "jag litar på att det här fungerar korrekt, så jag ersätter det med en förenklad version." Om ditt antagande är fel passerar testet men funktionen är trasig.

typescript
// Övermockat — testar inget användbart
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
 
it("bearbetar användarinmatning", () => {
  processInput("hello");
  expect(validator.validate).toHaveBeenCalledWith("hello");
  expect(formatter.format).toHaveBeenCalledWith("hello");
});

Det här testet verifierar att processInput anropar validate och format. Men vad händer om processInput anropar dem i fel ordning? Vad händer om den ignorerar deras returvärden? Vad händer om valideringen ska förhindra att formatsteget körs? Testet vet inte. Du har mockat bort allt intressant beteende.

Tumregeln: mocka vid gränserna, inte i mitten. Mocka nätverksanrop, filsystemåtkomst och tredjepartstjänster. Mocka inte dina egna hjälpfunktioner om det inte finns en tvingande anledning (som att de är dyra att köra eller har sidoeffekter).

Testning av React-komponenter#

Grunderna med Testing Library#

Testing Library upprätthåller en filosofi: testa komponenter på det sätt användare interagerar med dem. Ingen kontroll av internt tillstånd, ingen inspektion av komponentinstanser, ingen ytlig rendering. Du renderar en komponent och interagerar med den genom DOM:en, precis som en användare skulle göra.

typescript
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
 
describe("Button", () => {
  it("renderar med labeltext", () => {
    render(<Button>Klicka mig</Button>);
    expect(screen.getByRole("button", { name: "Klicka mig" })).toBeInTheDocument();
  });
 
  it("applicerar variant-klasser", () => {
    render(<Button variant="primary">Skicka</Button>);
    const button = screen.getByRole("button");
    expect(button).toHaveClass("bg-primary");
  });
 
  it("är inaktiverad när disabled-propen är true", () => {
    render(<Button disabled>Skicka</Button>);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

Frågor: getBy kontra queryBy kontra findBy#

Det är här nybörjare blir förvirrade. Det finns tre frågevarianter och var och en har ett specifikt användningsområde:

getBy* — Returnerar elementet eller kastar om det inte hittas. Använd när du förväntar dig att elementet existerar:

typescript
// Kastar om ingen knapp hittas — testet misslyckas med ett hjälpsamt felmeddelande
const button = screen.getByRole("button", { name: "Skicka" });

queryBy* — Returnerar elementet eller null om det inte hittas. Använd när du asserterar att något INTE finns:

typescript
// Returnerar null — kastar inte
expect(screen.queryByText("Felmeddelande")).not.toBeInTheDocument();

findBy* — Returnerar ett Promise. Använd för element som dyker upp asynkront:

typescript
// Väntar upp till 1000ms på att elementet ska dyka upp
const successMessage = await screen.findByText("Sparades framgångsrikt");
expect(successMessage).toBeInTheDocument();

Tillgänglighetsbaserade frågor#

Testing Library tillhandahåller dessa frågor i en medveten prioritetsordning:

  1. getByRole — Den bästa frågan. Använder ARIA-roller. Om din komponent inte kan hittas via roll kan den ha ett tillgänglighetsproblem.
  2. getByLabelText — För formulärelement. Om din input inte har en etikett, åtgärda det först.
  3. getByPlaceholderText — Acceptabelt men svagare. Platshållare försvinner när användaren skriver.
  4. getByText — För icke-interaktiva element. Hittar via synligt textinnehåll.
  5. getByTestId — Sista utväg. Använd när ingen semantisk fråga fungerar.
typescript
// Föredra detta
screen.getByRole("textbox", { name: "E-postadress" });
 
// Framför detta
screen.getByPlaceholderText("Ange din e-post");
 
// Och definitivt framför detta
screen.getByTestId("email-input");

Rankningen är inte godtycklig. Den matchar hur hjälpmedel navigerar på sidan. Om du kan hitta ett element via dess roll och tillgängliga namn, kan skärmläsare det också. Om du bara kan hitta det via ett test-ID kan du ha en tillgänglighetslucka.

Användarhändelser#

Använd inte fireEvent. Använd @testing-library/user-event. Skillnaden spelar roll:

typescript
import userEvent from "@testing-library/user-event";
 
describe("SearchInput", () => {
  it("filtrerar resultat medan användaren skriver", 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 avfyrar keydown, keypress, input, keyup för VARJE tecken
    // fireEvent.change sätter bara värdet — hoppar över realistiskt händelseflöde
    expect(onSearch).toHaveBeenLastCalledWith("vitest");
  });
 
  it("rensar input vid escape-tangenten", async () => {
    const user = userEvent.setup();
 
    render(<SearchInput onSearch={vi.fn()} />);
 
    const input = screen.getByRole("searchbox");
    await user.type(input, "hej");
    await user.keyboard("{Escape}");
 
    expect(input).toHaveValue("");
  });
});

userEvent simulerar hela händelsekedjan som en riktig webbläsare skulle avfyra. fireEvent.change är en enda syntetisk händelse. Om din komponent lyssnar på onKeyDown eller använder onInput istället för onChange, kommer fireEvent.change inte utlösa dessa hanterare men userEvent.type gör det.

Anropa alltid userEvent.setup() i början och använd den returnerade user-instansen. Det säkerställer korrekt händelseordning och tillståndsuppföljning.

Testning av komponentinteraktioner#

Ett realistiskt komponenttest ser ut så här:

typescript
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
 
describe("TodoList", () => {
  it("lägger till en ny todo-post", async () => {
    const user = userEvent.setup();
    render(<TodoList />);
 
    const input = screen.getByRole("textbox", { name: /ny todo/i });
    const addButton = screen.getByRole("button", { name: /lägg till/i });
 
    await user.type(input, "Skriv tester");
    await user.click(addButton);
 
    expect(screen.getByText("Skriv tester")).toBeInTheDocument();
    expect(input).toHaveValue("");
  });
 
  it("markerar en todo som slutförd", async () => {
    const user = userEvent.setup();
    render(<TodoList initialItems={[{ id: "1", text: "Köp mat", done: false }]} />);
 
    const checkbox = screen.getByRole("checkbox", { name: /köp mat/i });
    await user.click(checkbox);
 
    expect(checkbox).toBeChecked();
  });
 
  it("tar bort slutförda poster när rensa-knappen klickas", async () => {
    const user = userEvent.setup();
    render(
      <TodoList
        initialItems={[
          { id: "1", text: "Klar uppgift", done: true },
          { id: "2", text: "Väntande uppgift", done: false },
        ]}
      />
    );
 
    await user.click(screen.getByRole("button", { name: /rensa slutförda/i }));
 
    expect(screen.queryByText("Klar uppgift")).not.toBeInTheDocument();
    expect(screen.getByText("Väntande uppgift")).toBeInTheDocument();
  });
});

Observera: ingen intern tillståndsinspektion, inget component.setState(), ingen kontroll av implementationsdetaljer. Vi renderar, vi interagerar, vi asserterar på vad användaren skulle se. Om komponenten refaktorerar sin interna tillståndshantering från useState till useReducer, passerar dessa tester fortfarande. Det är poängen.

Testning av asynkron kod#

waitFor#

När en komponent uppdateras asynkront, pollar waitFor tills assertionen passerar:

typescript
import { render, screen, waitFor } from "@testing-library/react";
 
it("laddar och visar användarprofil", async () => {
  render(<UserProfile userId="123" />);
 
  // Visar först laddning
  expect(screen.getByText("Laddar...")).toBeInTheDocument();
 
  // Vänta på att innehåll ska dyka upp
  await waitFor(() => {
    expect(screen.getByText("John Doe")).toBeInTheDocument();
  });
 
  // Laddningsindikatorn bör vara borta
  expect(screen.queryByText("Laddar...")).not.toBeInTheDocument();
});

waitFor försöker igen med callback:en var 50:e ms (som standard) tills den passerar eller tar timeout (1000ms som standard). Du kan anpassa båda:

typescript
await waitFor(
  () => expect(screen.getByText("Klar")).toBeInTheDocument(),
  { timeout: 3000, interval: 100 }
);

Falska timers#

När du testar kod som använder setTimeout, setInterval eller Date, låter falska timers dig kontrollera tiden:

typescript
describe("Debounce", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it("fördröjer exekvering med angiven tid", () => {
    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("återställer timer vid efterföljande anrop", () => {
    const callback = vi.fn();
    const debounced = debounce(callback, 300);
 
    debounced();
    vi.advanceTimersByTime(200);
 
    debounced(); // återställ timern
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(callback).toHaveBeenCalledOnce();
  });
});

Viktigt: anropa alltid vi.useRealTimers() i afterEach. Falska timers som läcker mellan tester orsakar de mest förvirrande fel du någonsin kommer felsöka.

Testning med falska timers och asynkron rendering#

Att kombinera falska timers med React-komponenttestning kräver omsorg. Reacts interna schemaläggning använder riktiga timers, så du behöver ofta avancera timers OCH tömma React-uppdateringar tillsammans:

typescript
import { render, screen, act } from "@testing-library/react";
 
describe("Notification", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it("stängs automatiskt efter 5 sekunder", async () => {
    render(<Notification message="Sparat!" autoDismiss={5000} />);
 
    expect(screen.getByText("Sparat!")).toBeInTheDocument();
 
    // Avancera timers inuti act() för att tömma React-uppdateringar
    await act(async () => {
      vi.advanceTimersByTime(5000);
    });
 
    expect(screen.queryByText("Sparat!")).not.toBeInTheDocument();
  });
});

API-mockning med MSW#

För testning av datahämtning fångar Mock Service Worker (MSW) nätverksanrop på nätverksnivå. Det betyder att din komponents fetch/axios-kod körs exakt som den skulle göra i produktion — MSW ersätter bara nätverkssvaret:

typescript
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("visar användare från API", async () => {
    render(<UserList />);
 
    expect(await screen.findByText("Alice")).toBeInTheDocument();
    expect(await screen.findByText("Bob")).toBeInTheDocument();
  });
 
  it("visar feltillstånd när API misslyckas", async () => {
    // Överskriv standardhanteraren för just detta test
    server.use(
      http.get("/api/users", () => {
        return new HttpResponse(null, { status: 500 });
      })
    );
 
    render(<UserList />);
 
    expect(await screen.findByText(/kunde inte ladda/i)).toBeInTheDocument();
  });
});

MSW är bättre än att mocka fetch eller axios direkt eftersom:

  1. Din komponents faktiska datahämtningskod körs — du testar den riktiga integrationen.
  2. Du kan testa felhantering genom att överskrida hanterare per test.
  3. Samma hanterare fungerar i både tester och webbläsarens utvecklingsläge (Storybook, till exempel).

Integrationstester#

Enhetstester kontra integrationstester#

Ett enhetstest isolerar en enskild funktion eller komponent och mockar allt annat. Ett integrationstest låter flera enheter arbeta tillsammans och mockar bara externa gränser (nätverk, filsystem, databaser).

Sanningen är: de flesta buggar jag har sett i produktion uppstår vid gränserna mellan enheter, inte inuti dem. En funktion fungerar perfekt i isolering men misslyckas för att anroparen skickar data i ett något annorlunda format. En komponent renderar fint med mockdata men går sönder när det faktiska API-svaret har en extra nästningsnivå.

Integrationstester fångar dessa buggar. De är långsammare än enhetstester och svårare att felsöka när de misslyckas, men de ger mer förtroende per test.

Testning av flera komponenter tillsammans#

typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
 
// Mocka bara API-lagret — allt annat är riktigt
vi.mock("@/lib/api", () => ({
  checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
 
describe("Kundvagnsflöde", () => {
  const renderCart = (initialItems = []) => {
    return render(
      <CartProvider initialItems={initialItems}>
        <ShoppingCart />
      </CartProvider>
    );
  };
 
  it("visar antal artiklar och total", () => {
    renderCart([
      { id: "1", name: "Tangentbord", price: 79.99, quantity: 1 },
      { id: "2", name: "Mus", price: 49.99, quantity: 2 },
    ]);
 
    expect(screen.getByText("3 artiklar")).toBeInTheDocument();
    expect(screen.getByText("$179.97")).toBeInTheDocument();
  });
 
  it("uppdaterar kvantitet och räknar om totalen", async () => {
    const user = userEvent.setup();
 
    renderCart([
      { id: "1", name: "Tangentbord", price: 79.99, quantity: 1 },
    ]);
 
    const incrementButton = screen.getByRole("button", { name: /öka kvantitet/i });
    await user.click(incrementButton);
 
    expect(screen.getByText("$159.98")).toBeInTheDocument();
  });
 
  it("genomför kassaflöde", async () => {
    const user = userEvent.setup();
    const { checkout } = await import("@/lib/api");
 
    renderCart([
      { id: "1", name: "Tangentbord", price: 79.99, quantity: 1 },
    ]);
 
    await user.click(screen.getByRole("button", { name: /kassa/i }));
 
    expect(checkout).toHaveBeenCalledWith({
      items: [{ id: "1", quantity: 1 }],
    });
 
    expect(await screen.findByText(/order bekräftad/i)).toBeInTheDocument();
    expect(screen.getByText("ORD-123")).toBeInTheDocument();
  });
});

I det här testet arbetar ShoppingCart och CartProvider och deras interna komponenter (artikelrader, kvantitetsväljare, totalvisning) alla tillsammans med riktig kod. Den enda mocken är API-anropet, eftersom vi inte vill göra riktiga nätverksanrop i tester.

När ska integrationstester kontra enhetstester användas#

Använd enhetstester när:

  • Logiken är komplex och har många specialfall (en datumparser, en tillståndsmaskin, en beräkning).
  • Du behöver snabb feedback på en specifik funktions beteende.
  • Enheten är relativt isolerad och beror inte starkt på andra enheter.

Använd integrationstester när:

  • Flera komponenter behöver fungera korrekt tillsammans (ett formulär med validering och inskickning).
  • Data flödar genom flera lager (kontext → komponent → barnkomponent).
  • Du testar ett användararbetsflöde, inte en funktions returvärde.

I praktiken har en frisk testsvit tungt med integrationstester för funktioner och enhetstester för komplexa hjälpfunktioner. Komponenterna själva testas genom integrationstester — du behöver inte ett separat enhetstest för varje liten komponent om integrationstestet utövar den.

Täckning#

Köra täckning#

bash
vitest run --coverage

Du behöver en coverage-provider. Vitest stöder två:

bash
# V8 — snabbare, använder V8:s inbyggda täckning
npm install -D @vitest/coverage-v8
 
# Istanbul — mer moget, fler konfigurationsmöjligheter
npm install -D @vitest/coverage-istanbul

Konfigurera det i din Vitest-konfiguration:

typescript
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 kontra V8#

V8-täckning använder V8-motorns inbyggda instrumentering. Det är snabbare eftersom det inte finns något kodtransformationssteg. Men det kan vara mindre exakt i vissa specialfall, särskilt kring ES-modulgränser.

Istanbul instrumenterar din källkod med räknare innan tester körs. Det är långsammare men mer beprövat och ger mer exakt grentäckning. Om du upprätthåller täckningströsklar i CI kan Istanbuls exakthet spela roll.

Jag använder V8 för lokal utveckling (snabbare feedback) och Istanbul i CI (mer exakt upprätthållande). Du kan konfigurera olika providers per miljö vid behov.

Vad täckning faktiskt innebär#

Täckning talar om vilka kodrader som exekverades under tester. Det är allt. Den talar inte om huruvida dessa rader testades korrekt. Tänk på detta:

typescript
function divide(a: number, b: number): number {
  return a / b;
}
 
it("dividerar tal", () => {
  divide(10, 2);
  // Ingen assertion!
});

Det här testet ger 100% täckning av divide-funktionen. Det testar också absolut ingenting. Testet skulle passera om divide returnerade null, kastade ett fel eller avfyrade missiler.

Täckning är en användbar negativ indikator: låg täckning betyder att det definitivt finns otestade kodstigar. Men hög täckning betyder inte att din kod är väl testad. Det betyder bara att varje rad kördes under något test.

Rader kontra grenar#

Radtäckning är det vanligaste måttet men grentäckning är mer värdefullt:

typescript
function getDiscount(user: User): number {
  if (user.isPremium) {
    return user.yearsActive > 5 ? 0.2 : 0.1;
  }
  return 0;
}

Ett test med getDiscount({ isPremium: true, yearsActive: 10 }) träffar varje rad (100% radtäckning) men testar bara två av de tre grenarna. Stigen isPremium: false och stigen yearsActive <= 5 är otestade.

Grentäckning fångar detta. Den spårar varje möjlig väg genom villkorslogik. Om du ska upprätthålla ett täckningströskelvärde, använd grentäckning.

Ignorera genererad kod#

Viss kod bör inte räknas i täckning. Genererade filer, typdefinitioner, konfiguration — dessa blåser upp dina mätvärden utan att tillföra värde:

typescript
// vitest.config.ts
coverage: {
  exclude: [
    "src/**/*.d.ts",
    "src/**/types.ts",
    "src/**/*.stories.tsx",
    "src/generated/**",
    ".velite/**",
  ],
}

Du kan också ignorera specifika rader eller block i din källkod:

typescript
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
  console.log("Debug info:", data);
}
/* v8 ignore stop */
 
// Eller för Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }

Använd detta sparsamt. Om du märker att du ignorerar stora bitar av kod, behöver antingen de bitarna tester eller så borde de inte vara med i täckningsrapporten från början.

Testning av Next.js#

Mockning av next/navigation#

Next.js-komponenter som använder useRouter, usePathname eller useSearchParams behöver mockar:

typescript
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" }),
}));

För tester som behöver verifiera att navigation anropades:

typescript
import { useRouter } from "next/navigation";
 
vi.mock("next/navigation", () => ({
  useRouter: vi.fn(),
}));
 
describe("LogoutButton", () => {
  it("omdirigerar till startsidan efter utloggning", 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: /logga ut/i }));
 
    expect(mockPush).toHaveBeenCalledWith("/");
  });
});

Mockning av next-intl#

För internationaliserade komponenter som använder next-intl:

typescript
vi.mock("next-intl", () => ({
  useTranslations: () => (key: string) => key,
  useLocale: () => "en",
}));

Det här är det enklaste tillvägagångssättet — översättningar returnerar nyckeln själv, så t("hero.title") returnerar "hero.title". I assertions kontrollerar du efter översättningsnyckeln istället för den faktiska översatta strängen. Det gör tester språkoberoende.

Om du behöver faktiska översättningar i ett specifikt test:

typescript
vi.mock("next-intl", () => ({
  useTranslations: () => {
    const translations: Record<string, string> = {
      "hero.title": "Välkommen till min sida",
      "hero.subtitle": "Bygger saker för webben",
    };
    return (key: string) => translations[key] ?? key;
  },
}));

Testning av route-hanterare#

Next.js Route Handlers är vanliga funktioner som tar en Request och returnerar en Response. De är enkla att testa:

typescript
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
 
describe("GET /api/users", () => {
  it("returnerar användarlista", 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("stöder paginering via sökparametrar", 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("skapar en ny användare", 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("returnerar 400 för ogiltig 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);
  });
});

Testning av middleware#

Next.js middleware körs vid edge och bearbetar varje begäran. Testa det som en funktion:

typescript
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("omdirigerar oautentiserade användare från skyddade rutter", async () => {
    const request = createRequest("/dashboard");
    const response = await middleware(request);
 
    expect(response.status).toBe(307);
    expect(response.headers.get("location")).toContain("/login");
  });
 
  it("tillåter autentiserade användare att passera", async () => {
    const request = createRequest("/dashboard", {
      cookie: "session=valid-token",
    });
    const response = await middleware(request);
 
    expect(response.status).toBe(200);
  });
 
  it("lägger till säkerhetsrubriker", 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("hanterar locale-detektering", 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");
  });
});

Testning av Server Actions#

Server Actions är asynkrona funktioner som körs på servern. Eftersom de bara är funktioner kan du testa dem direkt — men du kan behöva mocka server-only-beroenden:

typescript
vi.mock("@/lib/db", () => ({
  db: {
    user: {
      update: vi.fn().mockResolvedValue({ id: "1", name: "Uppdaterad" }),
      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("uppdaterar användare och revaliderar profilsidan", async () => {
    const formData = new FormData();
    formData.set("name", "Nytt namn");
    formData.set("bio", "Ny biotext");
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(true);
    expect(revalidatePath).toHaveBeenCalledWith("/profile");
  });
 
  it("returnerar fel vid ogiltig data", async () => {
    const formData = new FormData();
    // Saknar obligatoriska fält
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(false);
    expect(result.error).toBeDefined();
  });
});

Praktiska mönster#

Anpassad render-funktion#

De flesta projekt behöver samma providers runt varje komponent. Skapa en anpassad render:

typescript
// 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,
  });
}
 
// Exportera om allt från testing library
export * from "@testing-library/react";
export { renderWithProviders as render };

Nu importerar varje testfil från dina anpassade utils istället för @testing-library/react:

typescript
import { render, screen } from "@/test/utils";
 
it("renderar i mörkt läge", () => {
  render(<Header />, { theme: "dark" });
  // Header och alla dess barn har tillgång till ThemeProvider och CartProvider
});

Testning av anpassade hooks#

Vitest fungerar med @testing-library/reacts renderHook:

typescript
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
 
describe("useCounter", () => {
  it("startar vid initialvärdet", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
 
  it("inkrementerar", () => {
    const { result } = renderHook(() => useCounter(0));
 
    act(() => {
      result.current.increment();
    });
 
    expect(result.current.count).toBe(1);
  });
 
  it("dekrementerar med golv", () => {
    const { result } = renderHook(() => useCounter(0, { min: 0 }));
 
    act(() => {
      result.current.decrement();
    });
 
    expect(result.current.count).toBe(0); // går inte under min
  });
});

Testning av Error Boundaries#

typescript
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
 
const ThrowingComponent = () => {
  throw new Error("Testexplosion");
};
 
describe("ErrorBoundary", () => {
  // Undertryck console.error för förväntade fel
  beforeEach(() => {
    vi.spyOn(console, "error").mockImplementation(() => {});
  });
 
  afterEach(() => {
    vi.restoreAllMocks();
  });
 
  it("visar fallback-gränssnitt när barn kastar", () => {
    render(
      <ErrorBoundary fallback={<div>Något gick fel</div>}>
        <ThrowingComponent />
      </ErrorBoundary>
    );
 
    expect(screen.getByText("Något gick fel")).toBeInTheDocument();
  });
 
  it("renderar barn när inget fel uppstår", () => {
    render(
      <ErrorBoundary fallback={<div>Fel</div>}>
        <div>Allt bra</div>
      </ErrorBoundary>
    );
 
    expect(screen.getByText("Allt bra")).toBeInTheDocument();
    expect(screen.queryByText("Fel")).not.toBeInTheDocument();
  });
});

Snapshot-testning (med försiktighet)#

Snapshot-tester har dåligt rykte eftersom folk använder dem som ersättning för riktiga assertions. En snapshot av en hel komponents HTML-utmatning är en underhållsbörda — den går sönder vid varje CSS-klassändring och ingen granskar diffen noggrant.

Men riktade snapshots kan vara användbara:

typescript
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
 
// Bra — liten, riktad snapshot av en ren funktions utmatning
it("formaterar olika valutavärden konsekvent", () => {
  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"');
});
 
// Dåligt — jätte-snapshot som ingen kommer granska
it("renderar dashboarden", () => {
  const { container } = render(<Dashboard />);
  expect(container).toMatchSnapshot(); // Gör inte detta
});

Inline-snapshots (toMatchInlineSnapshot) är bättre än filsnapshots eftersom det förväntade värdet syns direkt i testet. Du kan se med en blick vad funktionen returnerar utan att öppna en separat .snap-fil.

Testfilosofin#

Testa beteende, inte implementation#

Denna princip är så viktig att den förtjänar en dedikerad sektion. Tänk på två tester för samma funktion:

typescript
// Implementationstest — sprött, går sönder vid refaktorering
it("anropar setState med nytt count", () => {
  const setStateSpy = vi.spyOn(React, "useState");
  render(<Counter />);
  fireEvent.click(screen.getByText("+"));
  expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
 
// Beteendetest — motståndskraftigt, testar vad användaren ser
it("inkrementerar det visade räknevärdet när plusknappen klickas", async () => {
  const user = userEvent.setup();
  render(<Counter />);
  expect(screen.getByText("Räknare: 0")).toBeInTheDocument();
 
  await user.click(screen.getByRole("button", { name: "+" }));
 
  expect(screen.getByText("Räknare: 1")).toBeInTheDocument();
});

Det första testet går sönder om du byter från useState till useReducer, även om komponenten fungerar exakt likadant. Det andra testet går bara sönder om komponentens beteende faktiskt ändras. Det bryr sig inte om hur räknaren hanteras internt — bara att klick på "+" gör att siffran ökar.

Lakmusprovet är enkelt: kan du refaktorera implementationen utan att ändra testet? Om ja, testar du beteende. Om nej, testar du implementation.

Testtrofén#

Kent C. Dodds föreslog "Testtrofén" som ett alternativ till den traditionella testpyramiden:

    ╭─────────╮
    │  E2E    │   Få — dyra, långsamma, högt förtroende
    ├─────────┤
    │         │
    │ Integr. │   Flest — bra förtroende-till-kostnad-förhållande
    │         │
    ├─────────┤
    │  Enhet  │   Några — snabba, fokuserade, låg kostnad
    ├─────────┤
    │ Statisk │   Alltid — TypeScript, ESLint
    ╰─────────╯

Den traditionella pyramiden placerar enhetstester i botten (massor av dem) och integrationstester i mitten (färre). Trofén inverterar detta: integrationstester är sweet spot:en. Här är varför:

  • Statisk analys (TypeScript, ESLint) fångar stavfel, felaktiga typer och enkla logiska fel gratis. Du behöver inte ens köra något.
  • Enhetstester är bra för komplex ren logik men berättar inte om delarna fungerar tillsammans.
  • Integrationstester verifierar att komponenter, hooks och kontexter fungerar tillsammans. De ger mest förtroende per skrivet test.
  • End-to-end-tester verifierar hela systemet men är långsamma, opålitliga och dyra att underhålla. Du behöver ett fåtal för kritiska vägar men inte hundratals.

Jag följer den här fördelningen i praktiken: TypeScript fångar de flesta av mina typfel, jag skriver enhetstester för komplexa hjälpfunktioner och algoritmer, integrationstester för funktioner och användarflöden, och en handfull E2E-tester för den kritiska vägen (registrering, köp, kärnarbetsflöde).

Vad som ger förtroende kontra vad som slösar tid#

Tester existerar för att ge dig förtroende att leverera. Inte förtroende för att varje kodrad körs — förtroende för att applikationen fungerar för användare. Det är olika saker.

Högt förtroende, högt värde:

  • Integrationstest av ett kassaflöde — täcker formulärvalidering, API-anrop, tillståndsuppdateringar och framgångs-/felgränssnitt.
  • Enhetstest av en prisberäkningsfunktion med specialfall — flyttal, avrundning, rabatter, noll/negativa värden.
  • Test att skyddade rutter omdirigerar oautentiserade användare.

Lågt förtroende, tidstjuvar:

  • Snapshot-test av en statisk marknadsföringssida — går sönder varje gång copy ändras, fångar inget meningsfullt.
  • Enhetstest att en komponent skickar en prop till ett barn — testar React i sig, inte din kod.
  • Test att useState anropas — testar ramverket, inte beteende.
  • 100% täckning av en konfigurationsfil — det är statisk data, TypeScript validerar redan dess form.

Frågan att ställa innan du skriver ett test: "Om det här testet inte fanns, vilken bugg kunde smita in i produktion?" Om svaret är "ingen som TypeScript inte skulle fånga" eller "ingen som någon skulle märka", är testet förmodligen inte värt att skriva.

Testning som designfeedback#

Svårtestad kod är vanligtvis dåligt designad kod. Om du behöver mocka fem saker för att testa en funktion har den funktionen för många beroenden. Om du inte kan rendera en komponent utan att sätta upp avancerade context-providers är komponenten för tätt kopplad till sin miljö.

Tester är en användare av din kod. Om dina tester kämpar med att använda ditt API, kommer andra utvecklare också göra det. När du märker att du kämpar med test-uppsättningen, ta det som en signal att refaktorera koden som testas, inte att lägga till fler mockar.

typescript
// Svårt att testa — funktionen gör för mycket
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 };
}
 
// Lättare att testa — separerade ansvarsområden
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" };
}

Den andra versionen är en ren funktion. Du kan testa varje kombination av lager- och betalningsresultat utan att mocka en databas, betalningsleverantör eller e-posttjänst. Orkestreringslogiken (hämta data, skicka e-post) lever i ett tunt lager som testas på integrationsnivå.

Det här är det verkliga värdet av testning: inte att fånga buggar efter att de skrivits, utan att förhindra dålig design innan den committas. Disciplinen att skriva tester driver dig mot mindre funktioner, tydligare gränssnitt och mer modulär arkitektur. Testerna är bieffekten. Designförbättringen är huvudnumret.

Skriv tester du litar på#

Det värsta som kan hända en testsvit är inte att den har luckor. Det är att folk slutar lita på den. En testsvit med några opålitliga tester som slumpmässigt misslyckas i CI lär teamet att ignorera röda byggen. När det väl händer är testsviten värre än värdelös — den ger aktivt falsk säkerhet.

Om ett test misslyckas intermittent, fixa det eller ta bort det. Om ett test är långsamt, snabba upp det eller flytta det till en separat svit för långsamma tester. Om ett test går sönder vid varje orelaterad ändring, skriv om det för att testa beteende istället för implementation.

Målet är en testsvit där varje misslyckande betyder att något verkligt är trasigt. När utvecklare litar på testerna kör de dem före varje commit. När de inte litar på testerna kringgår de dem med --no-verify och deployar med korsade fingrar.

Bygg en testsvit du skulle satsa din helg på. Inget mindre är värt att underhålla.

Relaterade inlägg