Przewodnik po Vitest: Od zera do pewności na produkcji
Testy jednostkowe, integracyjne, komponentowe z Testing Library, strategie mockowania, konfiguracja pokrycia kodu i filozofia testowania, która naprawdę produkuje lepsze oprogramowanie.
Większość zespołów pisze testy tak, jak większość ludzi ćwiczy: wiedzą, że powinni, czują się winni, gdy tego nie robią, a gdy w końcu się za to biorą, przesadzają pierwszego dnia i rezygnują do piątku. Codebase kończy z garścią płytkich snapshot testów, które się psują za każdym razem, gdy ktoś zmieni klasę CSS, kilkoma testami integracyjnymi, którym nikt nie ufa, i badge'em pokrycia w README, który kłamie.
Byłem po obu stronach. Wypuszczałem projekty z zerowymi testami i pocąc się przy każdym deploy. Byłem też w zespołach, które goniły 100% pokrycia i spędzały więcej czasu na utrzymywaniu testów niż pisaniu funkcjonalności. Żadne z tych podejść nie działa. To, co działa, to pisanie właściwych testów, we właściwych miejscach, z właściwymi narzędziami.
Vitest zmienił moje myślenie o testowaniu w JavaScript. Nie dlatego, że wynalazł nowe koncepcje — fundamenty nie zmieniły się od czasu, gdy Kent Beck o nich pisał dekady temu. Ale dlatego, że usunął wystarczająco dużo tarcia, że pisanie testów przestało być obowiązkiem i zaczęło być częścią pętli deweloperskiej. Kiedy twój test runner jest tak szybki jak dev server i używa tej samej konfiguracji, wymówki się kończą.
Ten post to wszystko, co wiem o testowaniu z Vitest, od początkowej konfiguracji po filozofię, która sprawia, że to wszystko ma sens.
Dlaczego Vitest zamiast Jest#
Jeśli używałeś Jest, już znasz większość API Vitest. To celowe — Vitest jest kompatybilny z Jest na poziomie API. describe, it, expect, beforeEach, vi.fn() — wszystko działa. Więc po co zmieniać?
Natywne wsparcie ESM#
Jest został zbudowany dla CommonJS. Radzi sobie z ESM, ale wymaga konfiguracji, eksperymentalnych flag i okazjonalnej modlitwy. Jeśli używasz składni import/export (a to jest wszystko, co nowoczesne), prawdopodobnie walczyłeś z pipeline'em transformacji Jest.
Vitest działa na Vite. Vite rozumie ESM natywnie. Nie ma kroku transformacji dla twojego kodu źródłowego — po prostu działa. To ma większe znaczenie, niż się wydaje. Połowa problemów z Jest, które debugowałem przez lata, sprowadzała się do rozwiązywania modułów: SyntaxError: Cannot use import statement outside a module, albo mocki nie działające, bo moduł był już zcachowany w innym formacie.
Ta sama konfiguracja co dev server#
Jeśli twój projekt używa Vite (a jeśli budujesz aplikację React, Vue czy Svelte w 2026, prawdopodobnie tak), Vitest automatycznie czyta twój vite.config.ts. Twoje aliasy ścieżek, pluginy i zmienne środowiskowe działają w testach bez dodatkowej konfiguracji. Z Jest utrzymujesz równoległą konfigurację, która musi być zsynchronizowana z setupem bundlera. Za każdym razem, gdy dodajesz alias ścieżki w vite.config.ts, musisz pamiętać o dodaniu odpowiedniego moduleNameMapper w jest.config.ts. To drobna rzecz, ale drobne rzeczy się kumulują.
Szybkość#
Vitest jest szybki. Znacząco szybki. Nie „oszczędza ci dwie sekundy" szybki — „zmienia sposób pracy" szybki. Używa grafu modułów Vite, żeby zrozumieć, które testy są dotknięte zmianą pliku i uruchamia tylko te. Jego tryb watch używa tej samej infrastruktury HMR, która sprawia, że dev server Vite działa natychmiast.
W projekcie z 400+ testami przejście z Jest na Vitest skróciło naszą pętlę zwrotną w trybie watch z ~4 sekund do poniżej 500ms. To różnica między „poczekam na wynik testu" a „zerknę na terminal, gdy moje palce wciąż są na klawiaturze".
Wbudowany benchmarking#
Vitest zawiera bench() od razu do testowania wydajności. Żadna osobna biblioteka nie jest potrzebna:
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;
});
});Uruchom z vitest bench. To nie jest główna atrakcja, ale miło mieć testowanie wydajności w tym samym toolchainie bez instalowania benchmark.js i podłączania osobnego runnera.
Setup#
Instalacja#
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomKonfiguracja#
Utwórz vitest.config.ts w katalogu głównym projektu:
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,
},
});Albo, jeśli już masz vite.config.ts, możesz go rozszerzyć:
/// <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"],
},
});Decyzja globals: true#
Gdy globals jest true, nie musisz importować describe, it, expect, beforeEach itd. — są dostępne wszędzie, tak jak w Jest. Gdy false, importujesz je jawnie:
// globals: false
import { describe, it, expect } from "vitest";
describe("math", () => {
it("adds numbers", () => {
expect(1 + 1).toBe(2);
});
});Używam globals: true, bo redukuje szum wizualny i odpowiada temu, czego większość deweloperów się spodziewa. Jeśli jesteś w zespole, który ceni jawne importy, ustaw na false — nie ma tu złej odpowiedzi.
Jeśli używasz globals: true, dodaj typy Vitest do swojego tsconfig.json, żeby TypeScript je rozpoznał:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}Środowisko: jsdom vs happy-dom vs node#
Vitest pozwala wybrać implementację DOM per test lub globalnie:
node— Brak DOM. Dla czystej logiki, utilsów, route'ów API i wszystkiego, co nie dotyka przeglądarki.jsdom— Standard. Pełna implementacja DOM. Cięższa, ale bardziej kompletna.happy-dom— Lżejsza i szybsza niż jsdom, ale mniej kompletna. Niektóre edge case'y (jakRange,SelectionczyIntersectionObserver) mogą nie działać.
Domyślnie stosuję jsdom globalnie i nadpisuję per-plik, gdy potrzebuję node:
// 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");
});
});Pliki setup#
Plik setup uruchamia się przed każdym plikiem testowym. Tu konfigurujesz matchery Testing Library i globalne mocki:
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
// Mock IntersectionObserver — jsdom doesn't implement it
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
value: MockIntersectionObserver,
});
// Mock window.matchMedia — needed for responsive components
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 daje ci matchery takie jak toBeInTheDocument(), toHaveClass(), toBeVisible() i wiele innych. Dzięki nim asercje na elementach DOM są czytelne i ekspresywne.
Pisanie dobrych testów#
Wzorzec AAA#
Każdy test podąża za tą samą strukturą: Arrange, Act, Assert. Nawet gdy nie piszesz jawnych komentarzy, struktura powinna być widoczna:
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);
});Gdy widzę test, który miesza przygotowanie, akcję i asercję w jeden łańcuch wywołań metod, wiem, że będzie trudny do zrozumienia, gdy się nie powiedzie. Trzymaj trzy fazy wizualnie odrębne, nawet jeśli nie dodajesz komentarzy.
Nazewnictwo testów#
Są dwie szkoły: it('should calculate total with tax') i it('calculates total with tax'). Prefiks „should" jest rozwlekły bez dodawania informacji. Gdy test się nie powiedzie, zobaczysz:
FAIL ✕ calculates total with tax
To już kompletne zdanie. Dodanie „should" to tylko szum. Preferuję bezpośrednią formę: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').
Dla bloków describe używaj nazwy testowanej jednostki:
describe("calculateTotal", () => {
it("sums item prices", () => { /* ... */ });
it("applies tax rate", () => { /* ... */ });
it("returns 0 for empty array", () => { /* ... */ });
it("handles negative prices", () => { /* ... */ });
});Przeczytaj to na głos: „calculateTotal sumuje ceny pozycji." „calculateTotal stosuje stawkę podatku." Jeśli zdanie działa, nazewnictwo działa.
Jedna asercja per test vs praktyczne grupowanie#
Purystyczna reguła mówi: jedna asercja per test. Praktyczna reguła mówi: jeden koncept per test. To różne rzeczy.
// This is fine — one concept, multiple assertions
it("formats user display name", () => {
const user = { firstName: "John", lastName: "Doe", title: "Dr." };
const result = formatDisplayName(user);
expect(result).toContain("John");
expect(result).toContain("Doe");
expect(result).toStartWith("Dr.");
});
// This is not fine — multiple concepts in one test
it("handles user operations", () => {
const user = createUser("John");
expect(user.id).toBeDefined();
const updated = updateUser(user.id, { name: "Jane" });
expect(updated.name).toBe("Jane");
deleteUser(user.id);
expect(getUser(user.id)).toBeNull();
});Pierwszy test ma trzy asercje, ale wszystkie weryfikują jedną rzecz: format wyświetlanej nazwy. Jeśli jakakolwiek asercja się nie powiedzie, wiesz dokładnie, co jest zepsute. Drugi test to trzy osobne testy wciśnięte w jeden. Jeśli druga asercja się nie powiedzie, nie wiesz, czy tworzenie czy aktualizacja jest zepsuta, a trzecia asercja nigdy się nie uruchomi.
Opisy testów jako dokumentacja#
Dobre zestawy testów służą jako żywa dokumentacja. Ktoś niezaznajomiony z kodem powinien móc przeczytać opisy testów i zrozumieć zachowanie funkcjonalności:
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", () => { /* ... */ });
});
});Gdy ten zestaw testów się uruchomi, output czyta się jak specyfikacja. Taki jest cel.
Mockowanie#
Mockowanie to najpotężniejsze i najniebezpieczniejsze narzędzie w twoim zestawie testowym. Użyte dobrze izoluje testowaną jednostkę i sprawia, że testy są szybkie i deterministyczne. Użyte źle tworzy testy, które przechodzą niezależnie od tego, co robi kod.
vi.fn() — Tworzenie mock functions#
Najprostszy mock to funkcja, która rejestruje swoje wywołania:
const mockCallback = vi.fn();
// Call it
mockCallback("hello", 42);
mockCallback("world");
// Assert on calls
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");Możesz nadać jej wartość zwracaną:
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: "Test" }),
});Albo sprawić, żeby zwracała różne wartości przy kolejnych wywołaniach:
const mockRandom = vi.fn()
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.9);vi.spyOn() — Obserwowanie prawdziwych metod#
Gdy chcesz obserwować metodę bez zastępowania jej zachowania:
const consoleSpy = vi.spyOn(console, "warn");
validateInput("");
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();spyOn domyślnie zachowuje oryginalną implementację. Możesz ją nadpisać za pomocą .mockImplementation(), gdy potrzebujesz, ale potem przywrócić oryginał za pomocą .mockRestore().
vi.mock() — Mockowanie na poziomie modułu#
To jest ten duży. vi.mock() zastępuje cały moduł:
// Mock the entire module
vi.mock("@/lib/api", () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]),
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
// The import now uses the mocked version
import { fetchUsers } from "@/lib/api";
describe("UserList", () => {
it("displays users from API", async () => {
const users = await fetchUsers();
expect(users).toHaveLength(2);
});
});Vitest automatycznie podnosi wywołania vi.mock() na szczyt pliku. Oznacza to, że mock jest na miejscu, zanim jakikolwiek import się uruchomi. Nie musisz się martwić o kolejność importów.
Automatyczne mockowanie#
Jeśli chcesz po prostu, żeby każdy eksport został zastąpiony vi.fn():
vi.mock("@/lib/analytics");
import { trackEvent, trackPageView } from "@/lib/analytics";
it("tracks form submission", () => {
submitForm();
expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});Bez funkcji fabrykującej Vitest automatycznie mockuje wszystkie eksporty. Każda wyeksportowana funkcja staje się vi.fn(), który zwraca undefined. Przydatne dla modułów, które chcesz wyciszyć (jak analityka czy logowanie) bez specyfikowania każdej funkcji.
Clearing vs resetting vs restoring#
To myli każdego w pewnym momencie:
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
// mockClear — resets call history, keeps implementation
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // still returns 42
// mockReset — resets call history AND implementation
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // no longer returns 42
// mockRestore — for spies, restores original implementation
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() now works normally againW praktyce używaj vi.clearAllMocks() w beforeEach, żeby zresetować historię wywołań między testami. Używaj vi.restoreAllMocks(), jeśli korzystasz z spyOn i chcesz przywrócić oryginały:
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});Niebezpieczeństwo nadmiernego mockowania#
To najważniejsza rada dotycząca mockowania, jaką mogę dać: każdy mock to kłamstwo, które mówisz swojemu testowi. Gdy mockujesz zależność, mówisz „ufam, że to działa poprawnie, więc zastąpię to uproszczoną wersją." Jeśli twoje założenie jest błędne, test przechodzi, ale funkcjonalność jest zepsuta.
// Over-mocked — tests nothing useful
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");
});Ten test weryfikuje, że processInput wywołuje validate i format. Ale co jeśli processInput wywołuje je w złej kolejności? Co jeśli ignoruje ich wartości zwracane? Co jeśli walidacja miała powstrzymać krok formatowania? Test tego nie wie. Zamockowałeś całe interesujące zachowanie.
Zasada kciuka: mockuj na granicach, nie w środku. Mockuj żądania sieciowe, dostęp do systemu plików i usługi zewnętrzne. Nie mockuj swoich własnych funkcji pomocniczych, chyba że jest ku temu konkretny powód (np. są drogie w uruchomieniu lub mają skutki uboczne).
Testowanie komponentów React#
Podstawy z Testing Library#
Testing Library wymusza filozofię: testuj komponenty tak, jak użytkownicy z nimi interagują. Żadnego sprawdzania wewnętrznego stanu, żadnego inspekcjonowania instancji komponentów, żadnego płytkiego renderowania. Renderujesz komponent i wchodzisz z nim w interakcję przez DOM, tak jak użytkownik.
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();
});
});Zapytania: getBy vs queryBy vs findBy#
Tu początkujący się gubią. Są trzy warianty zapytań i każdy ma konkretny przypadek użycia:
getBy* — Zwraca element lub rzuca, jeśli nie znaleziono. Używaj, gdy oczekujesz, że element istnieje:
// Throws if no button found — test fails with a helpful error
const button = screen.getByRole("button", { name: "Submit" });queryBy* — Zwraca element lub null, jeśli nie znaleziono. Używaj, gdy sprawdzasz, że czegoś NIE ma:
// Returns null — doesn't throw
expect(screen.queryByText("Error message")).not.toBeInTheDocument();findBy* — Zwraca Promise. Używaj dla elementów, które pojawiają się asynchronicznie:
// Waits up to 1000ms for the element to appear
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();Zapytania zorientowane na dostępność#
Testing Library udostępnia te zapytania w celowej kolejności priorytetów:
getByRole— Najlepsze zapytanie. Używa ról ARIA. Jeśli twój komponent nie jest znajdowalny po roli, może mieć problem z dostępnością.getByLabelText— Dla elementów formularza. Jeśli twój input nie ma labela, najpierw to napraw.getByPlaceholderText— Akceptowalne, ale słabsze. Placeholder znikają, gdy użytkownik pisze.getByText— Dla elementów nieinteraktywnych. Szuka po widocznej treści tekstowej.getByTestId— Ostateczność. Używaj, gdy żadne semantyczne zapytanie nie działa.
// Prefer this
screen.getByRole("textbox", { name: "Email address" });
// Over this
screen.getByPlaceholderText("Enter your email");
// And definitely over this
screen.getByTestId("email-input");Ranking nie jest arbitralny. Odpowiada temu, jak technologie wspomagające nawigują po stronie. Jeśli możesz znaleźć element po jego roli i dostępnej nazwie, czytniki ekranu też mogą. Jeśli możesz go znaleźć tylko po test ID, możesz mieć lukę w dostępności.
User events#
Nie używaj fireEvent. Używaj @testing-library/user-event. Różnica ma znaczenie:
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 fires keydown, keypress, input, keyup for EACH character
// fireEvent.change just sets the value — skipping realistic event flow
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 symuluje pełny łańcuch zdarzeń, który prawdziwa przeglądarka by odpaliła. fireEvent.change to jedno syntetyczne zdarzenie. Jeśli twój komponent nasłuchuje na onKeyDown lub używa onInput zamiast onChange, fireEvent.change nie uruchomi tych handlerów, ale userEvent.type tak.
Zawsze wywołuj userEvent.setup() na początku i używaj zwróconej instancji user. To zapewnia prawidłową kolejność zdarzeń i śledzenie stanu.
Testowanie interakcji komponentów#
Realistyczny test komponentu wygląda tak:
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();
});
});Zauważ: żadnej inspekcji wewnętrznego stanu, żadnego component.setState(), żadnego sprawdzania szczegółów implementacji. Renderujemy, wchodzimy w interakcję, sprawdzamy to, co użytkownik by zobaczył. Jeśli komponent zrefaktoryzuje swoje wewnętrzne zarządzanie stanem z useState na useReducer, te testy nadal przejdą. O to chodzi.
Testowanie kodu asynchronicznego#
waitFor#
Gdy komponent aktualizuje się asynchronicznie, waitFor odpytuje, aż asercja przejdzie:
import { render, screen, waitFor } from "@testing-library/react";
it("loads and displays user profile", async () => {
render(<UserProfile userId="123" />);
// Initially shows loading
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Wait for content to appear
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
// Loading indicator should be gone
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});waitFor ponawia callback co 50ms (domyślnie) aż przejdzie lub przekroczy timeout (1000ms domyślnie). Możesz dostosować oba:
await waitFor(
() => expect(screen.getByText("Done")).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
);Fake timers#
Przy testowaniu kodu, który używa setTimeout, setInterval lub Date, fake timery pozwalają kontrolować czas:
describe("Debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("delays execution by the specified time", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
it("resets timer on subsequent calls", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
vi.advanceTimersByTime(200);
debounced(); // reset the timer
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
});Ważne: zawsze wywołuj vi.useRealTimers() w afterEach. Fake timery, które wyciekają między testami, powodują najbardziej mylące awarie, jakie kiedykolwiek będziesz debugować.
Testowanie z fake timerami i asynchronicznym renderowaniem#
Łączenie fake timerów z testowaniem komponentów React wymaga ostrożności. Wewnętrzne planowanie Reacta używa prawdziwych timerów, więc często trzeba przesuwać timery I flushować aktualizacje Reacta jednocześnie:
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();
// Advance timers inside act() to flush React updates
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
});
});Mockowanie API z MSW#
Do testowania pobierania danych Mock Service Worker (MSW) przechwytuje żądania sieciowe na poziomie sieci. Oznacza to, że kod fetch/axios twojego komponentu działa dokładnie tak, jak na produkcji — MSW po prostu zamienia odpowiedź sieciową:
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 () => {
// Override the default handler for this one 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 jest lepszy niż bezpośrednie mockowanie fetch czy axios, bo:
- Prawdziwy kod pobierania danych twojego komponentu się uruchamia — testujesz rzeczywistą integrację.
- Możesz testować obsługę błędów, nadpisując handlery per test.
- Te same handlery działają zarówno w testach, jak i w trybie deweloperskim przeglądarki (Storybook, na przykład).
Testy integracyjne#
Testy jednostkowe vs integracyjne#
Test jednostkowy izoluje pojedynczą funkcję lub komponent i mockuje wszystko inne. Test integracyjny pozwala wielu jednostkom pracować razem i mockuje tylko granice zewnętrzne (sieć, system plików, bazy danych).
Prawda jest taka: większość bugów, które widziałem na produkcji, dzieje się na granicach między jednostkami, nie wewnątrz nich. Funkcja działa idealnie w izolacji, ale się sypie, bo wywołujący przekazuje dane w nieco innym formacie. Komponent renderuje się dobrze z danymi mockowymi, ale się psuje, gdy rzeczywista odpowiedź API ma dodatkowy poziom zagnieżdżenia.
Testy integracyjne łapią te bugi. Są wolniejsze niż testy jednostkowe i trudniejsze do debugowania, gdy się nie powiodą, ale dają więcej pewności na test.
Testowanie wielu komponentów razem#
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
// Only mock the API layer — everything else is real
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();
});
});W tym teście ShoppingCart i CartProvider i ich wewnętrzne komponenty (wiersze pozycji, selektory ilości, wyświetlanie sum) pracują razem z prawdziwym kodem. Jedyny mock to wywołanie API, bo nie chcemy robić prawdziwych żądań sieciowych w testach.
Kiedy używać integracyjnych vs jednostkowych#
Używaj testów jednostkowych, gdy:
- Logika jest złożona i ma wiele edge case'ów (parser dat, maszyna stanów, obliczenia).
- Potrzebujesz szybkiego feedbacku na temat zachowania konkretnej funkcji.
- Jednostka jest relatywnie izolowana i nie zależy mocno od innych jednostek.
Używaj testów integracyjnych, gdy:
- Wiele komponentów musi działać razem poprawnie (formularz z walidacją i wysyłaniem).
- Dane przepływają przez kilka warstw (context -> komponent -> komponent potomny).
- Testujesz workflow użytkownika, nie wartość zwracaną przez funkcję.
W praktyce zdrowy zestaw testów jest ciężki na testy integracyjne dla funkcjonalności i ma testy jednostkowe dla złożonych utilsów. Komponenty same w sobie są testowane przez testy integracyjne — nie potrzebujesz osobnego testu jednostkowego dla każdego małego komponentu, jeśli test integracyjny go testuje.
Pokrycie#
Uruchamianie pokrycia#
vitest run --coveragePotrzebujesz providera pokrycia. Vitest obsługuje dwa:
# V8 — faster, uses V8's built-in coverage
npm install -D @vitest/coverage-v8
# Istanbul — more mature, more configuration options
npm install -D @vitest/coverage-istanbulSkonfiguruj to w konfiguracji Vitest:
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#
Pokrycie V8 używa wbudowanej instrumentacji silnika V8. Jest szybsze, bo nie ma kroku transformacji kodu. Ale może być mniej dokładne w niektórych edge case'ach, szczególnie na granicach modułów ES.
Istanbul instrumentuje twój kod źródłowy licznikami przed uruchomieniem testów. Jest wolniejszy, ale bardziej przetestowany w boju i daje dokładniejsze pokrycie gałęzi. Jeśli wymuszasz progi pokrycia w CI, dokładność Istanbul może mieć znaczenie.
Używam V8 do lokalnego developmentu (szybszy feedback) i Istanbul w CI (dokładniejsze wymuszanie). Możesz skonfigurować różnych providerów per środowisko, jeśli potrzebujesz.
Co pokrycie naprawdę oznacza#
Pokrycie mówi ci, które linie kodu zostały wykonane podczas testów. I tyle. Nie mówi ci, czy te linie zostały poprawnie przetestowane. Rozważ to:
function divide(a: number, b: number): number {
return a / b;
}
it("divides numbers", () => {
divide(10, 2);
// No assertion!
});Ten test daje 100% pokrycia funkcji divide. Jednocześnie nie testuje absolutnie niczego. Test przeszedłby, gdyby divide zwrócił null, rzucił błąd, albo wystrzelił rakiety.
Pokrycie to przydatny wskaźnik negatywny: niskie pokrycie oznacza, że na pewno są nieprzetestowane ścieżki. Ale wysokie pokrycie nie oznacza, że twój kod jest dobrze przetestowany. Oznacza tylko, że każda linia się uruchomiła podczas jakiegoś testu.
Linie vs gałęzie#
Pokrycie linii to najczęstsza metryka, ale pokrycie gałęzi jest cenniejsze:
function getDiscount(user: User): number {
if (user.isPremium) {
return user.yearsActive > 5 ? 0.2 : 0.1;
}
return 0;
}Test z getDiscount({ isPremium: true, yearsActive: 10 }) trafia w każdą linię (100% pokrycia linii), ale testuje tylko dwie z trzech gałęzi. Ścieżka isPremium: false i ścieżka yearsActive <= 5 są nieprzetestowane.
Pokrycie gałęzi to łapie. Śledzi każdą możliwą ścieżkę przez logikę warunkową. Jeśli zamierzasz wymuszać próg pokrycia, używaj pokrycia gałęzi.
Ignorowanie wygenerowanego kodu#
Pewien kod nie powinien być liczony do pokrycia. Wygenerowane pliki, definicje typów, konfiguracja — to zawyża twoje metryki bez dodawania wartości:
// vitest.config.ts
coverage: {
exclude: [
"src/**/*.d.ts",
"src/**/types.ts",
"src/**/*.stories.tsx",
"src/generated/**",
".velite/**",
],
}Możesz też ignorować konkretne linie lub bloki w kodzie źródłowym:
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
console.log("Debug info:", data);
}
/* v8 ignore stop */
// Or for Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }Używaj tego oszczędnie. Jeśli łapiesz się na ignorowaniu dużych kawałków kodu, albo te kawałki potrzebują testów, albo nie powinny być w raporcie pokrycia.
Testowanie Next.js#
Mockowanie next/navigation#
Komponenty Next.js, które używają useRouter, usePathname czy useSearchParams, potrzebują mocków:
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" }),
}));Dla testów, które muszą zweryfikować, że nawigacja została wywołana:
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("/");
});
});Mockowanie next-intl#
Dla umiędzynarodowionych komponentów używających next-intl:
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));To najprostsze podejście — tłumaczenia zwracają sam klucz, więc t("hero.title") zwraca "hero.title". W asercjach sprawdzasz klucz tłumaczenia zamiast rzeczywistego przetłumaczonego tekstu. Dzięki temu testy są niezależne od języka.
Testowanie Route Handlerów#
Route Handlery Next.js to zwykłe funkcje, które przyjmują Request i zwracają Response. Są proste do testowania:
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) }),
])
);
});
});
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);
});
});Testowanie middleware#
Middleware Next.js działa na edge i przetwarza każde żądanie. Testuj go jako funkcję:
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");
});
});Praktyczne wzorce#
Niestandardowa funkcja render#
Większość projektów potrzebuje tych samych providerów owijających każdy komponent. Utwórz niestandardowy render:
// 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-export everything from testing library
export * from "@testing-library/react";
export { renderWithProviders as render };Teraz każdy plik testowy importuje z twoich niestandardowych utilsów zamiast z @testing-library/react:
import { render, screen } from "@/test/utils";
it("renders in dark mode", () => {
render(<Header />, { theme: "dark" });
// Header and all its children have access to ThemeProvider and CartProvider
});Testowanie niestandardowych hooków#
Vitest współpracuje z renderHook z @testing-library/react:
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
describe("useCounter", () => {
it("starts at initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increments", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrements with floor", () => {
const { result } = renderHook(() => useCounter(0, { min: 0 }));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0); // doesn't go below min
});
});Testowanie error boundaries#
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
const ThrowingComponent = () => {
throw new Error("Test explosion");
};
describe("ErrorBoundary", () => {
// Suppress console.error for expected errors
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("displays fallback UI when child throws", () => {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("renders children when no error", () => {
render(
<ErrorBoundary fallback={<div>Error</div>}>
<div>All good</div>
</ErrorBoundary>
);
expect(screen.getByText("All good")).toBeInTheDocument();
expect(screen.queryByText("Error")).not.toBeInTheDocument();
});
});Snapshot testing (ostrożnie)#
Snapshot testy mają złą reputację, bo ludzie używają ich jako zamiennika prawdziwych asercji. Snapshot całego HTML output'u komponentu to ciężar utrzymaniowy — psuje się przy każdej zmianie klasy CSS i nikt nie przegląda diff'u uważnie.
Ale celowane snapshoty mogą być przydatne:
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
// Good — small, targeted snapshot of a pure function's output
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"');
});
// Bad — giant snapshot nobody will review
it("renders the dashboard", () => {
const { container } = render(<Dashboard />);
expect(container).toMatchSnapshot(); // Don't do this
});Inline snapshoty (toMatchInlineSnapshot) są lepsze niż plikowe, bo oczekiwana wartość jest widoczna bezpośrednio w teście. Na pierwszy rzut oka widzisz, co funkcja zwraca, bez otwierania osobnego pliku .snap.
Filozofia testowania#
Testuj zachowanie, nie implementację#
Ta zasada jest na tyle ważna, że zasługuje na osobną sekcję. Rozważ dwa testy tej samej funkcjonalności:
// Implementation test — brittle, breaks on refactors
it("calls setState with new count", () => {
const setStateSpy = vi.spyOn(React, "useState");
render(<Counter />);
fireEvent.click(screen.getByText("+"));
expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
// Behavior test — resilient, tests what the user sees
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();
});Pierwszy test się psuje, jeśli przejdziesz z useState na useReducer, mimo że komponent działa dokładnie tak samo. Drugi test psuje się tylko, gdy zachowanie komponentu faktycznie się zmieni. Nie obchodzi go, jak licznik jest wewnętrznie zarządzany — tylko że kliknięcie „+" sprawia, że liczba rośnie.
Test lakmusowy jest prosty: czy możesz zrefaktoryzować implementację bez zmieniania testu? Jeśli tak, testujesz zachowanie. Jeśli nie, testujesz implementację.
Trofeum testowe#
Kent C. Dodds zaproponował „trofeum testowe" jako alternatywę dla tradycyjnej piramidy testów:
╭─────────╮
│ E2E │ Few — expensive, slow, high confidence
├─────────┤
│ │
│ Integr. │ Most — good confidence-to-cost ratio
│ │
├─────────┤
│ Unit │ Some — fast, focused, low-cost
├─────────┤
│ Static │ Always — TypeScript, ESLint
╰─────────╯
Tradycyjna piramida umieszcza testy jednostkowe na dole (dużo ich) i testy integracyjne w środku (mniej). Trofeum to odwraca: testy integracyjne to sweet spot. Oto dlaczego:
- Analiza statyczna (TypeScript, ESLint) łapie literówki, złe typy i proste błędy logiczne za darmo. Nie musisz nawet niczego uruchamiać.
- Testy jednostkowe są świetne dla złożonej czystej logiki, ale nie mówią ci, czy elementy działają razem.
- Testy integracyjne weryfikują, że komponenty, hooki i contexty działają razem. Dają najwięcej pewności na napisany test.
- Testy end-to-end weryfikują cały system, ale są wolne, niestabilne i drogie w utrzymaniu. Potrzebujesz kilku dla krytycznych ścieżek, ale nie setek.
Trzymam się tego rozkładu w praktyce: TypeScript łapie większość moich błędów typów, piszę testy jednostkowe dla złożonych utilsów i algorytmów, testy integracyjne dla funkcjonalności i flow użytkownika, i garść testów E2E dla krytycznej ścieżki (rejestracja, zakup, główny workflow).
Co daje pewność vs co marnuje czas#
Testy istnieją, żeby dać ci pewność do wysyłki. Nie pewność, że każda linia kodu się uruchomi — pewność, że aplikacja działa dla użytkowników. To różne rzeczy.
Wysoka pewność, wysoka wartość:
- Test integracyjny flow checkout — obejmuje walidację formularza, wywołania API, aktualizacje stanu i UI sukcesu/błędu.
- Test jednostkowy funkcji obliczania ceny z edge case'ami — zmiennoprzecinkowe, zaokrąglanie, zniżki, wartości zerowe/ujemne.
- Test, że chronione ścieżki przekierowują niezalogowanych użytkowników.
Niska pewność, strata czasu:
- Snapshot test statycznej strony marketingowej — psuje się przy każdej zmianie copy, nie łapie nic sensownego.
- Test jednostkowy, że komponent przekazuje prop dziecku — testujesz Reacta, nie swój kod.
- Test, że
useStatejest wywoływany — testujesz framework, nie zachowanie. - 100% pokrycia pliku konfiguracyjnego — to statyczne dane, TypeScript już waliduje ich kształt.
Pytanie do zadania przed napisaniem testu: „Gdyby ten test nie istniał, jaki bug mógłby trafić na produkcję?" Jeśli odpowiedź brzmi „żaden, którego TypeScript by nie złapał" albo „żaden, którego ktokolwiek by zauważył", test prawdopodobnie nie jest wart pisania.
Testowanie jako feedback projektowy#
Trudny do testowania kod to zwykle źle zaprojektowany kod. Jeśli musisz zamockować pięć rzeczy, żeby przetestować jedną funkcję, ta funkcja ma za dużo zależności. Jeśli nie możesz wyrenderować komponentu bez konfigurowania rozbudowanych providerów, komponent jest zbyt sprzężony ze swoim środowiskiem.
Testy to użytkownik twojego kodu. Jeśli twoje testy mają trudności z używaniem twojego API, inni deweloperzy też będą mieli. Kiedy łapiesz się na walce z setupem testów, potraktuj to jako sygnał do refaktoryzacji testowanego kodu, a nie do dodawania więcej mocków.
// Hard to test — function does too much
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 };
}
// Easier to test — separated concerns
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" };
}Druga wersja to czysta funkcja. Możesz przetestować każdą kombinację wyników inwentarza i płatności bez mockowania bazy danych, dostawcy płatności czy usługi e-mail. Logika orkiestracji (pobieranie danych, wysyłanie e-maili) żyje w cienkiej warstwie, która jest testowana na poziomie integracji.
To jest prawdziwa wartość testowania: nie łapanie bugów po tym, jak zostały napisane, ale zapobieganie złym projektom, zanim zostaną zacommitowane. Dyscyplina pisania testów popycha cię w stronę mniejszych funkcji, jaśniejszych interfejsów i bardziej modularnej architektury. Testy to efekt uboczny. Poprawa projektowa to główne wydarzenie.
Pisz testy, którym ufasz#
Najgorsza rzecz, jaka może się przydarzyć zestawowi testów, to nie to, że ma luki. To to, że ludzie przestają mu ufać. Zestaw testów z kilkoma niestabilnymi testami, które losowo się sypią na CI, uczy zespół ignorować czerwone buildy. Kiedy to się stanie, zestaw testów jest gorszy niż bezużyteczny — aktywnie daje fałszywe poczucie bezpieczeństwa.
Jeśli test się nie powiedzie sporadycznie, napraw go albo usuń. Jeśli test jest wolny, przyspiesz go albo przenieś do osobnego zestawu wolnych testów. Jeśli test psuje się przy każdej niepowiązanej zmianie, przepisz go, żeby testował zachowanie zamiast implementacji.
Celem jest zestaw testów, gdzie każde niepowodzenie oznacza, że coś naprawdę jest zepsute. Kiedy deweloperzy ufają testom, uruchamiają je przed każdym commitem. Kiedy nie ufają testom, omijają je z --no-verify i deployują z trzymanymi kciukami.
Zbuduj zestaw testów, na który postawiłbyś swój weekend. Nic mniejszego nie jest warte utrzymywania.