Přeskočit na obsah
·29 min čtení

Průvodce testováním s Vitest: Od nuly po produkční jistotu

Unit testy, integrační testy, testy komponent s Testing Library, strategie mockování, konfigurace coverage a filozofie testování, která skutečně produkuje lepší software.

Sdílet:X / TwitterLinkedIn

Většina týmů píše testy stejným způsobem, jakým většina lidí cvičí: vědí, že by měli, mají výčitky, když to nedělají, a když se konečně do toho pustí, první den to přeženou a do pátku to vzdají. Kódová báze skončí s hrstkou povrchních snapshot testů, které se rozbijí pokaždé, když někdo změní CSS třídu, několika integračními testy, kterým nikdo nevěří, a coverage odznakem v README, který lže.

Byl jsem na obou stranách. Dodal jsem projekty s nulovými testy a potil se u každého deploye. Byl jsem také v týmech, které honily 100% pokrytí a trávily více času údržbou testů než psaním funkcí. Ani jedno nefunguje. Co funguje, je psát správné testy, na správných místech, se správnými nástroji.

Vitest změnil způsob, jakým přemýšlím o testování v JavaScriptu. Ne proto, že by vynalezl nové koncepty — základy se nezměnily od doby, kdy o nich Kent Beck psal před desítkami let. Ale protože odstranil dostatek tření, takže psaní testů přestalo působit jako otrava a začalo se cítit jako součást vývojové smyčky. Když je váš test runner stejně rychlý jako dev server a používá stejnou konfiguraci, výmluvy se vypaří.

Tento příspěvek je vše, co vím o testování s Vitest, od počátečního nastavení po filozofii, která tomu všemu dává smysl.

Proč Vitest místo Jest#

Pokud jste používali Jest, většinu Vitest API už znáte. To je záměr — Vitest je na úrovni API kompatibilní s Jest. describe, it, expect, beforeEach, vi.fn() — vše funguje. Tak proč přecházet?

Nativní podpora ESM#

Jest byl postaven pro CommonJS. Zvládne ESM, ale vyžaduje konfiguraci, experimentální příznaky a občas modlitbu. Pokud používáte syntaxi import/export (což je dnes všechno moderní), pravděpodobně jste bojovali s transformačním pipeline Jest.

Vitest běží na Vite. Vite rozumí ESM nativně. Není žádný transformační krok pro váš zdrojový kód — prostě to funguje. To je důležitější, než to zní. Polovina problémů s Jest, které jsem za ta léta ladil, měla kořeny v rozlišování modulů: SyntaxError: Cannot use import statement outside a module, nebo mocky nefungující, protože modul byl již uložen v mezipaměti v jiném formátu.

Stejná konfigurace jako váš dev server#

Pokud váš projekt používá Vite (a pokud stavíte React, Vue nebo Svelte aplikaci v roce 2026, pravděpodobně ano), Vitest automaticky čte váš vite.config.ts. Vaše aliasy cest, pluginy a proměnné prostředí fungují v testech bez jakékoli další konfigurace. S Jest udržujete paralelní konfiguraci, která musí zůstat synchronizovaná s nastavením bundleru. Pokaždé, když přidáte alias cesty v vite.config.ts, musíte si vzpomenout přidat odpovídající moduleNameMapper v jest.config.ts. Je to maličkost, ale maličkosti se sčítají.

Rychlost#

Vitest je rychlý. Smysluplně rychlý. Ne „ušetří vám dvě sekundy" rychlý — „změní způsob, jakým pracujete" rychlý. Využívá graf modulů Vite k pochopení, které testy jsou ovlivněny změnou souboru, a spouští pouze ty. Jeho watch režim používá stejnou HMR infrastrukturu, díky které se dev server Vite cítí okamžitý.

Na projektu s 400+ testy přechod z Jest na Vitest zkrátil naši smyčku zpětné vazby ve watch režimu z ~4 sekund na méně než 500ms. To je rozdíl mezi „počkám, až test projde" a „mrknu na terminál, zatímco mám prsty stále na klávesnici."

Benchmarking zabudovaný#

Vitest obsahuje bench() přímo z krabice pro testování výkonu. Žádná samostatná knihovna není potřeba:

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

Spusťte pomocí vitest bench. Není to hlavní událost, ale je příjemné mít testování výkonu ve stejném toolchainu bez instalace benchmark.js a nastavování samostatného runneru.

Nastavení#

Instalace#

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

Konfigurace#

Vytvořte vitest.config.ts v kořenu projektu:

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

Nebo pokud už máte vite.config.ts, můžete jej rozšířit:

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

Rozhodnutí o globals: true#

Když je globals nastaveno na true, nemusíte importovat describe, it, expect, beforeEach atd. — jsou dostupné všude, stejně jako v Jest. Když je false, importujete je explicitně:

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

Já používám globals: true, protože to snižuje vizuální šum a odpovídá tomu, co většina vývojářů očekává. Pokud jste v týmu, který si cení explicitních importů, nastavte to na false — žádná odpověď tu není špatná.

Pokud používáte globals: true, přidejte typy Vitest do vašeho tsconfig.json, aby je TypeScript rozpoznal:

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

Prostředí: jsdom vs happy-dom vs node#

Vitest vám umožňuje vybrat implementaci DOM na test nebo globálně:

  • node — Žádný DOM. Pro čistou logiku, utility, API routy a cokoli, co nepracuje s prohlížečem.
  • jsdom — Standard. Plná implementace DOM. Těžší, ale kompletnější.
  • happy-dom — Lehčí a rychlejší než jsdom, ale méně kompletní. Některé okrajové případy (jako Range, Selection nebo IntersectionObserver) nemusí fungovat.

Já nastavuji výchozí jsdom globálně a přepisuji per-file, když potřebuji node:

typescript
// 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 soubory#

Setup soubor se spouští před každým testovacím souborem. Zde konfigurujete matchery Testing Library a globální mocky:

typescript
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
 
// Mock IntersectionObserver — jsdom jej neimplementuje
class MockIntersectionObserver {
  observe = vi.fn();
  unobserve = vi.fn();
  disconnect = vi.fn();
}
 
Object.defineProperty(window, "IntersectionObserver", {
  writable: true,
  value: MockIntersectionObserver,
});
 
// Mock window.matchMedia — potřeba pro responzivní komponenty
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(),
  })),
});

Import @testing-library/jest-dom/vitest vám dává matchery jako toBeInTheDocument(), toHaveClass(), toBeVisible() a mnoho dalších. Díky nim jsou aserce na DOM elementech čitelné a expresivní.

Psaní dobrých testů#

Vzor AAA#

Každý test následuje stejnou strukturu: Arrange (Připravit), Act (Vykonat), Assert (Ověřit). I když nepíšete explicitní komentáře, struktura by měla být viditelná:

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

Když vidím test, který míchá přípravu, akci a aserce do jednoho řetězce volání metod, vím, že bude těžké pochopit, když selže. Udržujte tři fáze vizuálně oddělené, i když nepřidáváte komentáře.

Pojmenování testů#

Existují dvě školy: it('should calculate total with tax') a it('calculates total with tax'). Předpona "should" je upovídaná bez přidání informace. Když test selže, uvidíte:

FAIL  ✕ calculates total with tax

To je už celá věta. Přidání "should" jen přidává šum. Preferuji přímou formu: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').

Pro bloky describe použijte název testované jednotky:

typescript
describe("calculateTotal", () => {
  it("sums item prices", () => { /* ... */ });
  it("applies tax rate", () => { /* ... */ });
  it("returns 0 for empty array", () => { /* ... */ });
  it("handles negative prices", () => { /* ... */ });
});

Přečtěte si to nahlas: "calculateTotal sums item prices." "calculateTotal applies tax rate." Pokud věta funguje, pojmenování funguje.

Jedna aserce na test vs praktické seskupení#

Puristické pravidlo říká jednu aserci na test. Praktické pravidlo říká: jeden koncept na test. To je rozdíl.

typescript
// Toto je v pořádku — jeden koncept, více asercí
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.");
});
 
// Toto v pořádku není — více konceptů v jednom testu
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();
});

První test má tři aserce, ale všechny ověřují jednu věc: formát zobrazovaného jména. Pokud jakákoli aserce selže, přesně víte, co je rozbitého. Druhý test jsou tři samostatné testy nacpané do jednoho. Pokud selže druhá aserce, nevíte, zda je rozbité vytvoření nebo aktualizace, a třetí aserce se nikdy nespustí.

Popisky testů jako dokumentace#

Dobré testovací sady slouží jako živá dokumentace. Někdo neznalý kódu by měl být schopen přečíst popisky testů a pochopit chování funkce:

typescript
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", () => { /* ... */ });
  });
});

Když tato testovací sada běží, výstup čte jako specifikace. To je cíl.

Mockování#

Mockování je nejsilnější a nejnebezpečnější nástroj ve vašem testovacím arzenálu. Dobře použité izoluje testovanou jednotku a činí testy rychlé a deterministické. Špatně použité vytváří testy, které projdou bez ohledu na to, co kód dělá.

vi.fn() — Vytváření mock funkcí#

Nejjednodušší mock je funkce, která zaznamenává svá volání:

typescript
const mockCallback = vi.fn();
 
// Zavolej ji
mockCallback("hello", 42);
mockCallback("world");
 
// Ověř volání
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");

Můžete jí dát návratovou hodnotu:

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

Nebo ji nechat vracet různé hodnoty při následných voláních:

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

vi.spyOn() — Sledování skutečných metod#

Když chcete pozorovat metodu bez nahrazení jejího chování:

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

spyOn ve výchozím stavu zachovává původní implementaci. Můžete ji přepsat pomocí .mockImplementation(), když je potřeba, ale poté obnovte originál pomocí .mockRestore().

vi.mock() — Mockování na úrovni modulu#

Tohle je ta hlavní věc. vi.mock() nahradí celý modul:

typescript
// Mockování celého modulu
vi.mock("@/lib/api", () => ({
  fetchUsers: vi.fn().mockResolvedValue([
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" },
  ]),
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
 
// Import nyní používá mockovanou verzi
import { fetchUsers } from "@/lib/api";
 
describe("UserList", () => {
  it("displays users from API", async () => {
    const users = await fetchUsers();
    expect(users).toHaveLength(2);
  });
});

Vitest automaticky přesouvá volání vi.mock() na začátek souboru. To znamená, že mock je na místě dříve, než se spustí jakékoli importy. Nemusíte se starat o pořadí importů.

Automatické mockování#

Pokud chcete jednoduše nahradit každý export za vi.fn():

typescript
vi.mock("@/lib/analytics");
 
import { trackEvent, trackPageView } from "@/lib/analytics";
 
it("tracks form submission", () => {
  submitForm();
  expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});

Bez factory funkce Vitest automaticky mockuje všechny exporty. Každá exportovaná funkce se stane vi.fn(), které vrací undefined. To je užitečné pro moduly, které chcete umlčet (jako analytika nebo logování), aniž byste specifikovali každou funkci.

Čištění vs resetování vs obnovení#

Tohle zmást každého dříve nebo později:

typescript
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
 
// mockClear — resetuje historii volání, zachová implementaci
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // stále vrací 42
 
// mockReset — resetuje historii volání A implementaci
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // už nevrací 42
 
// mockRestore — pro spy, obnoví původní implementaci
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() nyní funguje normálně

V praxi použijte vi.clearAllMocks() v beforeEach k resetování historie volání mezi testy. Použijte vi.restoreAllMocks(), pokud používáte spyOn a chcete zpět originály:

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

Nebezpečí nadměrného mockování#

Toto je nejdůležitější rada ohledně mockování, kterou vám mohu dát: každý mock je lež, kterou říkáte svému testu. Když mockujete závislost, říkáte „věřím, že tato věc funguje správně, takže ji nahradím zjednodušenou verzí." Pokud je váš předpoklad špatný, test projde, ale funkce je rozbitá.

typescript
// Přemockováno — netestuje nic užitečného
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");
});

Tento test ověřuje, že processInput volá validate a format. Ale co když je processInput volá ve špatném pořadí? Co když ignoruje jejich návratové hodnoty? Co když validace má zabránit spuštění formátovacího kroku? Test to neví. Zamockovali jste veškeré zajímavé chování.

Pravidlo palce: mockujte na hranicích, ne uprostřed. Mockujte síťové požadavky, přístup k souborovému systému a služby třetích stran. Nemockujte své vlastní utility funkce, pokud k tomu není pádný důvod (jako že jsou drahé na spuštění nebo mají vedlejší efekty).

Testování React komponent#

Základy s Testing Library#

Testing Library prosazuje filozofii: testujte komponenty tak, jak s nimi uživatelé interagují. Žádná kontrola vnitřního stavu, žádné inspektování instancí komponent, žádné shallow renderování. Vykreslíte komponentu a interagujete s ní přes DOM, přesně jako uživatel.

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

Dotazy: getBy vs queryBy vs findBy#

Tady se začátečníci pletou. Existují tři varianty dotazů a každá má specifický případ použití:

getBy* — Vrátí element nebo vyhodí chybu, pokud není nalezen. Použijte, když očekáváte, že element existuje:

typescript
// Vyhodí chybu, pokud nenajde tlačítko — test selže s užitečnou chybou
const button = screen.getByRole("button", { name: "Submit" });

queryBy* — Vrátí element nebo null, pokud není nalezen. Použijte, když ověřujete, že něco NENÍ přítomno:

typescript
// Vrátí null — nevyhodí chybu
expect(screen.queryByText("Error message")).not.toBeInTheDocument();

findBy* — Vrátí Promise. Použijte pro elementy, které se objeví asynchronně:

typescript
// Čeká až 1000ms na zobrazení elementu
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();

Dotazy zaměřené na přístupnost#

Testing Library poskytuje tyto dotazy v záměrném pořadí priority:

  1. getByRole — Nejlepší dotaz. Používá ARIA role. Pokud vaši komponentu nelze najít podle role, může mít problém s přístupností.
  2. getByLabelText — Pro formulářové prvky. Pokud váš input nemá label, opravte to nejdřív.
  3. getByPlaceholderText — Přijatelný, ale slabší. Placeholdery zmizí, když uživatel píše.
  4. getByText — Pro neinteraktivní elementy. Hledá podle viditelného textového obsahu.
  5. getByTestId — Poslední východisko. Použijte, když žádný sémantický dotaz nefunguje.
typescript
// Preferujte toto
screen.getByRole("textbox", { name: "Email address" });
 
// Před tímto
screen.getByPlaceholderText("Enter your email");
 
// A rozhodně před tímto
screen.getByTestId("email-input");

Pořadí není náhodné. Odpovídá tomu, jak asistenční technologie navigují stránku. Pokud můžete najít element podle jeho role a přístupného názvu, čtečky obrazovky to zvládnou také. Pokud jej najdete pouze podle test ID, možná máte mezeru v přístupnosti.

Uživatelské události#

Nepoužívejte fireEvent. Použijte @testing-library/user-event. Rozdíl je důležitý:

typescript
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 spustí keydown, keypress, input, keyup pro KAŽDÝ znak
    // fireEvent.change jen nastaví hodnotu — přeskočí realistický tok událostí
    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 simuluje kompletní řetězec událostí, který by spustil skutečný prohlížeč. fireEvent.change je jedna syntetická událost. Pokud vaše komponenta naslouchá na onKeyDown nebo používá onInput místo onChange, fireEvent.change tyto handlery nespustí, ale userEvent.type ano.

Vždy zavolejte userEvent.setup() na začátku a použijte vrácenou instanci user. To zajistí správné řazení událostí a sledování stavu.

Testování interakcí komponent#

Realistický test komponenty vypadá takto:

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

Všimněte si: žádná inspekce vnitřního stavu, žádné component.setState(), žádná kontrola implementačních detailů. Vykreslíme, interagujeme, ověříme to, co by viděl uživatel. Pokud komponenta refaktoruje své interní řízení stavu z useState na useReducer, tyto testy stále projdou. To je pointa.

Testování asynchronního kódu#

waitFor#

Když se komponenta aktualizuje asynchronně, waitFor opakovaně kontroluje, dokud aserce neprojde:

typescript
import { render, screen, waitFor } from "@testing-library/react";
 
it("loads and displays user profile", async () => {
  render(<UserProfile userId="123" />);
 
  // Zpočátku zobrazuje načítání
  expect(screen.getByText("Loading...")).toBeInTheDocument();
 
  // Počkejte, než se zobrazí obsah
  await waitFor(() => {
    expect(screen.getByText("John Doe")).toBeInTheDocument();
  });
 
  // Indikátor načítání by měl být pryč
  expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});

waitFor opakuje callback každých 50ms (výchozí) dokud neprojde nebo nevyprší timeout (výchozí 1000ms). Obojí můžete přizpůsobit:

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

Falešné časovače#

Při testování kódu, který používá setTimeout, setInterval nebo Date, vám falešné časovače umožňují řídit čas:

typescript
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(); // resetuje časovač
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(callback).toHaveBeenCalledOnce();
  });
});

Důležité: vždy zavolejte vi.useRealTimers() v afterEach. Falešné časovače, které uniknou mezi testy, způsobují ty nejmatoucí selhání, jaké kdy budete ladit.

Testování s falešnými časovači a asynchronním renderováním#

Kombinace falešných časovačů s testováním React komponent vyžaduje péči. Interní plánování Reactu používá skutečné časovače, takže často potřebujete posunout časovače A zároveň vyprázdnit aktualizace Reactu:

typescript
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();
 
    // Posuňte časovače uvnitř act() pro vyprázdnění aktualizací Reactu
    await act(async () => {
      vi.advanceTimersByTime(5000);
    });
 
    expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
  });
});

Mockování API s MSW#

Pro testování načítání dat Mock Service Worker (MSW) zachytává síťové požadavky na úrovni sítě. To znamená, že fetch/axios kód vaší komponenty běží přesně tak, jako by běžel v produkci — MSW jen nahrazuje síťovou odpověď:

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("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 () => {
    // Přepsat výchozí handler pro tento jeden 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 je lepší než přímé mockování fetch nebo axios, protože:

  1. Skutečný kód pro načítání dat vaší komponenty běží — testujete reálnou integraci.
  2. Můžete testovat zpracování chyb přepsáním handlerů per-test.
  3. Stejné handlery fungují jak v testech, tak v prohlížečovém dev režimu (například Storybook).

Integrační testy#

Unit testy vs integrační testy#

Unit test izoluje jednu funkci nebo komponentu a mockuje vše ostatní. Integrační test nechá více jednotek spolupracovat a mockuje pouze vnější hranice (síť, souborový systém, databáze).

Pravda je: většina bugů, které jsem viděl v produkci, se děje na hranicích mezi jednotkami, ne uvnitř nich. Funkce funguje perfektně izolovaně, ale selže, protože volající předá data v mírně odlišném formátu. Komponenta se vykreslí v pořádku s mock daty, ale rozbije se, když skutečná API odpověď má extra úroveň vnoření.

Integrační testy tyto bugy chytí. Jsou pomalejší než unit testy a těžší na ladění, když selžou, ale dávají více jistoty na test.

Testování více komponent dohromady#

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";
 
// Mockujeme pouze API vrstvu — vše ostatní je skutečné
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();
  });
});

V tomto testu ShoppingCart a CartProvider a jejich interní komponenty (řádky položek, selektory množství, zobrazení celkem) spolupracují se skutečným kódem. Jediný mock je API volání, protože nechceme v testech provádět skutečné síťové požadavky.

Kdy použít integrační vs unit testy#

Použijte unit testy, když:

  • Logika je složitá a má mnoho okrajových případů (parser datumů, stavový automat, výpočet).
  • Potřebujete rychlou zpětnou vazbu na chování konkrétní funkce.
  • Jednotka je relativně izolovaná a nezávisí silně na jiných jednotkách.

Použijte integrační testy, když:

  • Více komponent musí správně spolupracovat (formulář s validací a odesláním).
  • Data protékají několika vrstvami (kontext → komponenta → podřízená komponenta).
  • Testujete uživatelský workflow, ne návratovou hodnotu funkce.

V praxi má zdravá testovací sada těžiště v integračních testech pro funkce a unit testy pro složité utility. Samotné komponenty jsou testovány přes integrační testy — nepotřebujete samostatný unit test pro každou drobnou komponentu, pokud ji integrační test procvičí.

Pokrytí#

Spuštění pokrytí#

bash
vitest run --coverage

Budete potřebovat poskytovatele pokrytí. Vitest podporuje dva:

bash
# V8 — rychlejší, využívá vestavěné pokrytí V8
npm install -D @vitest/coverage-v8
 
# Istanbul — vyspělejší, více konfiguračních možností
npm install -D @vitest/coverage-istanbul

Nakonfigurujte to v konfiguraci Vitest:

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 vs V8#

Pokrytí V8 využívá vestavěnou instrumentaci enginu V8. Je rychlejší, protože neexistuje krok transformace kódu. Ale může být méně přesné v některých okrajových případech, zejména kolem hranic ES modulů.

Istanbul instrumentuje váš zdrojový kód počítadly před spuštěním testů. Je pomalejší, ale více prověřený a dává přesnější pokrytí větví. Pokud vynucujete prahy pokrytí v CI, přesnost Istanbulu může být důležitá.

Já používám V8 pro lokální vývoj (rychlejší zpětná vazba) a Istanbul v CI (přesnější vynucování). Můžete nakonfigurovat různé poskytovatele pro různá prostředí, pokud je to potřeba.

Co pokrytí skutečně znamená#

Pokrytí vám říká, které řádky kódu byly během testů spuštěny. To je vše. Neříká vám, zda byly tyto řádky testovány správně. Zvažte toto:

typescript
function divide(a: number, b: number): number {
  return a / b;
}
 
it("divides numbers", () => {
  divide(10, 2);
  // Žádná aserce!
});

Tento test dává 100% pokrytí funkce divide. Také netestuje absolutně nic. Test by prošel, i kdyby divide vrátilo null, vyhodilo chybu nebo odpálilo rakety.

Pokrytí je užitečný negativní indikátor: nízké pokrytí znamená, že určitě existují netestované cesty. Ale vysoké pokrytí neznamená, že je váš kód dobře otestovaný. Jen to znamená, že každý řádek běžel během nějakého testu.

Řádky vs větve#

Pokrytí řádků je nejběžnější metrika, ale pokrytí větví je hodnotnější:

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

Test s getDiscount({ isPremium: true, yearsActive: 10 }) zasáhne každý řádek (100% pokrytí řádků), ale testuje pouze dvě ze tří větví. Cesta isPremium: false a cesta yearsActive <= 5 jsou netestované.

Pokrytí větví to odhalí. Sleduje každou možnou cestu přes podmínkovou logiku. Pokud budete vynucovat práh pokrytí, použijte pokrytí větví.

Ignorování generovaného kódu#

Některý kód by neměl být počítán do pokrytí. Generované soubory, definice typů, konfigurace — tyto nafukují vaše metriky bez přidání hodnoty:

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

Můžete také ignorovat konkrétní řádky nebo bloky ve zdrojovém kódu:

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

Používejte to střídmě. Pokud zjistíte, že ignorujete velké kusy kódu, buď ty kusy potřebují testy, nebo by neměly být ve zprávě o pokrytí vůbec.

Testování Next.js#

Mockování next/navigation#

Komponenty Next.js, které používají useRouter, usePathname nebo useSearchParams, potřebují mocky:

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

Pro testy, které potřebují ověřit, že navigace byla zavolána:

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

Mockování next-intl#

Pro internacionalizované komponenty používající next-intl:

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

Toto je nejjednodušší přístup — překlady vrací samotný klíč, takže t("hero.title") vrátí "hero.title". V asercích kontrolujete překladový klíč místo skutečného přeloženého řetězce. Díky tomu jsou testy nezávislé na jazyku.

Pokud potřebujete skutečné překlady v konkrétním testu:

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

Testování route handlerů#

Route Handlery Next.js jsou běžné funkce, které přijímají Request a vrací Response. Testují se přímo:

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

Testování middleware#

Middleware Next.js běží na edge a zpracovává každý požadavek. Testujte jej jako funkci:

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

Testování Server Actions#

Server Actions jsou asynchronní funkce, které běží na serveru. Protože to jsou jen funkce, můžete je testovat přímo — ale možná budete muset mockovat závislosti pouze pro server:

typescript
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();
    // Chybějící povinná pole
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(false);
    expect(result.error).toBeDefined();
  });
});

Praktické vzory#

Vlastní render funkce#

Většina projektů potřebuje stejné providery obalené kolem každé komponenty. Vytvořte si vlastní 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,
  });
}
 
// Re-exportovat vše z testing library
export * from "@testing-library/react";
export { renderWithProviders as render };

Nyní každý testovací soubor importuje z vašich vlastních utilit místo z @testing-library/react:

typescript
import { render, screen } from "@/test/utils";
 
it("renders in dark mode", () => {
  render(<Header />, { theme: "dark" });
  // Header a všechny jeho potomky mají přístup k ThemeProvider a CartProvider
});

Testování vlastních hooků#

Vitest funguje s renderHook z @testing-library/react:

typescript
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); // neklesne pod minimum
  });
});

Testování Error Boundaries#

typescript
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
 
const ThrowingComponent = () => {
  throw new Error("Test explosion");
};
 
describe("ErrorBoundary", () => {
  // Potlačit console.error pro očekávané chyby
  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 testování (opatrně)#

Snapshot testy mají špatnou pověst, protože je lidé používají jako náhradu skutečných asercí. Snapshot celého HTML výstupu komponenty je zátěž pro údržbu — rozbije se při každé změně CSS třídy a nikdo pořádně nezkontroluje diff.

Ale cílené snapshoty mohou být užitečné:

typescript
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
 
// Dobré — malý, cílený snapshot výstupu čisté funkce
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"');
});
 
// Špatné — obrovský snapshot, který nikdo nezkontroluje
it("renders the dashboard", () => {
  const { container } = render(<Dashboard />);
  expect(container).toMatchSnapshot(); // Nedělejte to
});

Inline snapshoty (toMatchInlineSnapshot) jsou lepší než souborové snapshoty, protože očekávaná hodnota je viditelná přímo v testu. Na první pohled vidíte, co funkce vrací, aniž byste otevírali samostatný .snap soubor.

Filozofie testování#

Testujte chování, ne implementaci#

Tento princip je natolik důležitý, že si zaslouží vlastní sekci. Zvažte dva testy pro stejnou funkcionalitu:

typescript
// Test implementace — křehký, rozbije se při refaktorech
it("calls setState with new count", () => {
  const setStateSpy = vi.spyOn(React, "useState");
  render(<Counter />);
  fireEvent.click(screen.getByText("+"));
  expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
 
// Test chování — odolný, testuje to, co vidí uživatel
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();
});

První test se rozbije, pokud přepnete z useState na useReducer, i když komponenta funguje úplně stejně. Druhý test se rozbije, jen pokud se skutečně změní chování komponenty. Nezajímá ho, jak je počítadlo interně řízeno — jen to, že kliknutí na "+" zvýší číslo.

Lakmusový test je jednoduchý: můžete refaktorovat implementaci, aniž byste měnili test? Pokud ano, testujete chování. Pokud ne, testujete implementaci.

Testovací trofej#

Kent C. Dodds navrhl „Testovací trofej" jako alternativu k tradiční testovací pyramidě:

    ╭─────────╮
    │  E2E    │   Málo — drahé, pomalé, vysoká jistota
    ├─────────┤
    │         │
    │ Integr. │   Nejvíce — dobrý poměr jistoty k nákladům
    │         │
    ├─────────┤
    │  Unit   │   Něco — rychlé, zaměřené, nízké náklady
    ├─────────┤
    │ Static  │   Vždy — TypeScript, ESLint
    ╰─────────╯

Tradiční pyramida umisťuje unit testy dole (hodně jich) a integrační testy uprostřed (méně). Trofej to obrací: integrační testy jsou zlatý střed. Důvod:

  • Statická analýza (TypeScript, ESLint) zachytí překlepy, špatné typy a jednoduché logické chyby zadarmo. Nemusíte ani nic spouštět.
  • Unit testy jsou skvělé pro složitou čistou logiku, ale neřeknou vám, zda jednotlivé díly fungují dohromady.
  • Integrační testy ověřují, že komponenty, hooky a kontexty fungují společně. Dávají nejvíce jistoty na napsaný test.
  • End-to-end testy ověřují celý systém, ale jsou pomalé, nestabilní a drahé na údržbu. Potřebujete jich pár pro kritické cesty, ale ne stovky.

Tento poměr dodržuji v praxi: TypeScript zachytí většinu mých typových chyb, píšu unit testy pro složité utility a algoritmy, integrační testy pro funkce a uživatelské toky a hrstku E2E testů pro kritickou cestu (registrace, nákup, hlavní workflow).

Co dává jistotu vs co plýtvá časem#

Testy existují, aby vám daly jistotu k nasazení. Ne jistotu, že každý řádek kódu běží — jistotu, že aplikace funguje pro uživatele. To jsou různé věci.

Vysoká jistota, vysoká hodnota:

  • Integrační test pokladního procesu — pokrývá validaci formuláře, API volání, aktualizace stavu a UI úspěchu/chyby.
  • Unit test funkce pro výpočet ceny s okrajovými případy — plovoucí čísla, zaokrouhlování, slevy, nulové/záporné hodnoty.
  • Test, že chráněné routy přesměrovávají neautentizované uživatele.

Nízká jistota, ztráta času:

  • Snapshot test statické marketingové stránky — rozbije se pokaždé, když se změní text, nic smysluplného nezachytí.
  • Unit test, že komponenta předá prop potomkovi — testování samotného Reactu, ne vašeho kódu.
  • Test, že se zavolá useState — testování frameworku, ne chování.
  • 100% pokrytí konfiguračního souboru — jsou to statická data, TypeScript už ověřuje jejich tvar.

Otázka, kterou si položit před psaním testu: „Kdyby tento test neexistoval, jaký bug by se mohl dostat do produkce?" Pokud je odpověď „žádný, který by TypeScript nezachytil" nebo „žádný, kterého by si kdokoli všiml," test pravděpodobně nestojí za napsání.

Testování jako zpětná vazba na design#

Těžko testovatelný kód je obvykle špatně navržený kód. Pokud potřebujete mockovat pět věcí, abyste otestovali jednu funkci, ta funkce má příliš mnoho závislostí. Pokud nemůžete vykreslit komponentu bez nastavení propracovaných context providerů, komponenta je příliš provázaná se svým prostředím.

Testy jsou uživatelem vašeho kódu. Pokud vaše testy bojují s použitím vašeho API, ostatní vývojáři budou také. Když zjistíte, že bojujete s nastavením testů, vezměte to jako signál k refaktorování testovaného kódu, ne k přidání dalších mocků.

typescript
// Těžko testovatelné — funkce dělá příliš mnoho
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 };
}
 
// Snadněji testovatelné — oddělené zodpovědnosti
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" };
}

Druhá verze je čistá funkce. Můžete otestovat každou kombinaci výsledků inventáře a platby bez mockování databáze, platebního poskytovatele nebo e-mailové služby. Orchestrační logika (načítání dat, odesílání e-mailů) žije v tenké vrstvě, která se testuje na úrovni integrace.

To je skutečná hodnota testování: ne zachytávání bugů poté, co jsou napsány, ale prevence špatných návrhů předtím, než jsou commitnuty. Disciplína psaní testů vás tlačí k menším funkcím, jasnějším rozhraním a modulárnější architektuře. Testy jsou vedlejší efekt. Zlepšení designu je hlavní událost.

Pište testy, kterým věříte#

Nejhorší věc, která se může testovací sadě stát, není to, že má mezery. Je to, že jí lidé přestanou věřit. Testovací sada s několika nestabilními testy, které náhodně selhávají na CI, naučí tým ignorovat červené buildy. Jakmile se to stane, testovací sada je horší než zbytečná — aktivně poskytuje falešný pocit bezpečí.

Pokud test selhává přerušovaně, opravte ho nebo ho smažte. Pokud je test pomalý, zrychlete ho nebo ho přesuňte do samostatné sady pomalých testů. Pokud se test rozbije při každé nesouvisející změně, přepište ho tak, aby testoval chování místo implementace.

Cílem je testovací sada, kde každé selhání znamená, že je skutečně něco rozbité. Když vývojáři testům věří, spouštějí je před každým commitem. Když testům nevěří, obcházejí je pomocí --no-verify a nasazují se zkříženými prsty.

Vybudujte testovací sadu, na kterou byste vsadili svůj víkend. Nic menšího nestojí za údržbu.

Související články