Paweł Łukasiewicz
2026-02-03
Paweł Łukasiewicz
2026-02-03
Wprowadzenie
Aplikacja działa, funkcje są zaimplementowane, użytkownicy są zadowoleni. Ale nagle po drobnej zmianie w kodzie... coś przestaje działać. Bug na produkcji. Użytkownicy narzekają. Rollback. Stres. To mogło być uniknięte z jedną rzeczą: testami .
🔍 Dlaczego testy są ważne?
Wyobraź sobie że piszesz kod jak budujesz dom. Bez testów:
❌ Dodajesz nowy pokój (feature) → ściana w salonie pęka (stary kod się psuje)
❌ Nie wiesz co się zepsuło dopóki użytkownicy nie zaczną narzekać
❌ Boisz się zmieniać cokolwiek (co jeśli coś zepsuje?)
Z testami:
✅ Każda zmiana jest sprawdzana automatycznie
✅ Dowiadujesz się o problemach OD RAZU (nie na produkcji!)
✅ Możesz refaktoryzować bez strachu - testy Cię chronią
Testy to nie opcja – to konieczność w profesjonalnych projektach. Dają pewność, że kod działa poprawnie, pozwalają na bezpieczny refactoring, dokumentują jak komponenty powinny działać, i zwiększają szybkość developmentu (tak, naprawdę!). Bez testów żyjesz w strachu przed każdym deployem.
🔍 "Testy zwiększają szybkość?" - Jak to?
Początkowo wydaje się że testy spowalniają - musisz je napisać! Ale:
Bez testów: Zmieniasz kod → odpalasz całą aplikację → klikasz przez 10 ekranów → sprawdzasz czy działa → znajdujesz bug → naprawiasz → powtarzasz. 20 minut.
Z testami: Zmieniasz kod → npm test → widzisz wyniki w 2 sekundy. 2 sekundy.
Po kilku miesiącach projektu oszczędzasz SETKI godzin! 🚀
W tym wpisie nauczymy się testować aplikacje React + TypeScript . Poznamy Testing Library , Jest , Vitest , nauczymy się testować komponenty, hooki, integracje, async code, mocks. Zbudujemy kompletną strategię testowania. To będzie praktyczny wpis pełen przykładów – po nim będziesz pisał testy jak profesjonalista!
Rodzaje testów
🔍 Co to jest piramida testów?
To strategia mówiąca ile testów jakiego typu powinieneś mieć. Im wyżej w piramidzie, tym:
Wolniejsze wykonanie
Droższe w utrzymaniu (łatwiej się psują)
Testują więcej kodu naraz
Zasada: Dużo szybkich testów u podstawy, mało wolnych na górze!
/\
/ \ E2E (End-to-End)
/----\ - Wolne, kosztowne
/ \ - Testują całą aplikację
/--------\ - Przykład: Cypress, Playwright
/ \
/------------\ Integration
| | - Średnie tempo
| | - Testują współpracę komponentów
|____________| - Przykład: Testing Library
| |
| | Unit
| | - Szybkie, tanie
| | - Testują pojedyncze funkcje/komponenty
|____________| - Przykład: Jest, Vitest
Zasada: Najwięcej unit testów, średnio integration, najmniej E2E.
🌟 Przykłady każdego typu testu:
1. Unit Test - testuje jedną funkcję/komponent:
// Testuje tylko funkcję formatPrice
test('formatPrice formatuje cenę poprawnie', () => {
expect(formatPrice(1234.56)).toBe('1 234,56 zł');
});
2. Integration Test - testuje współpracę kilku komponentów:
// Testuje TodoList + TodoItem + AddTodo razem
test('dodanie todo pokazuje je na liście', async () => {
render( );
await user.type(screen.getByRole('textbox'), 'Kupić mleko');
await user.click(screen.getByText('Dodaj'));
expect(screen.getByText('Kupić mleko')).toBeInTheDocument();
});
3. E2E Test - testuje całą aplikację jak prawdziwy użytkownik:
// Testuje cały flow: login → dodaj produkt → checkout
test('użytkownik może kupić produkt', async () => {
await page.goto('http://localhost:3000');
await page.click('text=Zaloguj się');
await page.fill('input[name="email"]', 'user@test.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.click('text=Produkty');
await page.click('text=Dodaj do koszyka');
await page.click('text=Koszyk');
await page.click('text=Zamów i zapłać');
await expect(page.locator('text=Zamówienie złożone')).toBeVisible();
});
Unit tests – pojedyncze funkcje, utility, hooki
Component tests – komponenty w izolacji
Integration tests – współpraca komponentów
E2E tests – cała aplikacja (user flow)
W tym wpisie skupimy się na 1-3.
🔍 Co konkretnie testować?
✅ TESTUJ:
Czy komponent się renderuje?
Czy przycisk wywołuje funkcję po kliknięciu?
Czy formularz waliduje dane poprawnie?
Czy dane z API się wyświetlają?
Czy komunikaty błędów się pokazują?
❌ NIE TESTUJ:
Szczegółów implementacji (np. czy komponent używa useState czy useReducer)
Bibliotek zewnętrznych (React, Zustand - one mają swoje testy!)
Stylów CSS (czy przycisk jest niebieski - to nie test, to wizualna inspekcja)
💪 Ćwiczenie 1: Zaplanuj testy
Masz komponent LoginForm z polami email, hasło i przyciskiem "Zaloguj".
Wymyśl 5 testów które powinieneś napisać. Przykład pierwszego:
"Formularz renderuje się z dwoma polami input i przyciskiem"
...twoje pomysły...
Setup testowego środowiska
🔍 Co musimy zainstalować?
Vitest - runner testów (uruchamia testy, sprawdza asercje)
@testing-library/react - renderuje komponenty React w testach
@testing-library/jest-dom - dodatkowe matchery (toBeInTheDocument, toBeDisabled)
@testing-library/user-event - symuluje interakcje użytkownika (klikanie, pisanie)
jsdom - symuluje przeglądarkę w Node.js (żeby testy mogły "renderować" HTML)
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';
// package.json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
🔍 Co robią te komendy?
npm test - uruchamia testy w watch mode (automatycznie po zmianach)
npm run test:ui - otwiera wizualny interfejs w przeglądarce (bardzo fajny!)
npm run test:coverage - pokazuje % pokrycia kodu testami
CRA ma już skonfigurowany Jest + Testing Library:
npm test
Podstawy testowania
🔍 Anatomia testu:
describe - grupuje powiązane testy (opcjonalny)
test (lub it) - pojedynczy test
expect - sprawdza czy coś jest prawdą (asercja)
// utils/sum.test.ts
import { describe, test, expect } from 'vitest';
function sum(a: number, b: number): number {
return a + b;
}
describe('sum', () => {
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
test('adds negative numbers', () => {
expect(sum(-1, -2)).toBe(-3);
});
});
Uruchom: npm test
🌟 Przykład: Testowanie funkcji pomocniczych
// utils/format.ts
export function formatPrice(price: number): string {
return new Intl.NumberFormat('pl-PL', {
style: 'currency',
currency: 'PLN'
}).format(price);
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '...';
}
// utils/format.test.ts
import { describe, test, expect } from 'vitest';
import { formatPrice, truncateText } from './format';
describe('formatPrice', () => {
test('formatuje cenę w PLN', () => {
expect(formatPrice(1234.56)).toBe('1 234,56 zł');
});
test('formatuje 0 jako 0,00 zł', () => {
expect(formatPrice(0)).toBe('0,00 zł');
});
test('formatuje liczby ujemne', () => {
expect(formatPrice(-100)).toBe('-100,00 zł');
});
});
describe('truncateText', () => {
test('nie skraca tekstu krótszego niż limit', () => {
expect(truncateText('Hello', 10)).toBe('Hello');
});
test('skraca długi tekst i dodaje ...', () => {
expect(truncateText('Hello World', 5)).toBe('Hello...');
});
test('obsługuje pusty string', () => {
expect(truncateText('', 5)).toBe('');
});
});
Proste funkcje = proste testy! To najłatwiejszy typ testów do napisania. 🎯
🔍 Co to są matchers?
Matchers (dopasowywacze) to funkcje które sprawdzają czy wartość jest taka jak oczekujesz:
expect(value).toBe(5) - sprawdza czy value === 5
expect(value).not.toBe(5) - sprawdza czy value !== 5
Jest ich dziesiątki - dla każdego typu danych!
// Equality
expect(value).toBe(5); // ===
expect(value).toEqual({ a: 1 }); // deep equality
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeLessThan(5);
expect(value).toBeCloseTo(0.3); // dla floatów
// Strings
expect(value).toMatch(/pattern/);
expect(value).toContain('substring');
// Arrays
expect(array).toContain(item);
expect(array).toHaveLength(3);
// Objects
expect(obj).toHaveProperty('key');
expect(obj).toMatchObject({ a: 1 });
🔍 toBe vs toEqual - jaka różnica?
toBe - sprawdza czy to TEN SAM obiekt w pamięci (===):
const obj1 = { a: 1 };
const obj2 = { a: 1 };
expect(obj1).toBe(obj2); // ❌ FAIL - to różne obiekty!
expect(obj1).toBe(obj1); // ✅ PASS - ten sam obiekt
toEqual - sprawdza czy mają TĘ SAMĄ zawartość (deep equality):
const obj1 = { a: 1 };
const obj2 = { a: 1 };
expect(obj1).toEqual(obj2); // ✅ PASS - ta sama zawartość!
Reguła: Dla prymitywów (number, string, boolean) - toBe. Dla obiektów/tablic - toEqual.
💪 Ćwiczenie 2: Napisz testy dla funkcji
Napisz testy dla tej funkcji:
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
Napisz przynajmniej 5 testów sprawdzających:
Poprawny email (test@example.com) → true
Email bez @ → false
Email bez domeny → false
Pusty string → false
Email ze spacjami → false
Testowanie komponentów
🔍 Jak testować komponenty React?
Testowanie komponentów to 3 kroki:
render() - renderuj komponent w testowym środowisku
screen.getBy...() - znajdź element na ekranie
expect() - sprawdź czy element jest taki jak powinien
// Button.tsx
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
}
function Button({ label, onClick, disabled }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
);
}
export default Button;
// Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect, vi } from 'vitest';
import Button from './Button';
describe('Button', () => {
test('renders with label', () => {
render(<Button label="Click me" onClick={() => {}} />);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('calls onClick when clicked', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button label="Click me" onClick={handleClick} />);
await user.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(<Button label="Click me" onClick={() => {}} disabled />);
expect(screen.getByText('Click me')).toBeDisabled();
});
});
🔍 Co to jest vi.fn()?
vi.fn() tworzy mock function - fałszywą funkcję która:
✅ Pamięta ile razy została wywołana
✅ Pamięta z jakimi argumentami
✅ Może zwrócić dowolną wartość
Po co? Żeby sprawdzić czy komponent wywołuje callback'i poprawnie!
const mockFn = vi.fn();
mockFn('hello', 123);
mockFn('world');
expect(mockFn).toHaveBeenCalledTimes(2); // Wywołano 2 razy
expect(mockFn).toHaveBeenCalledWith('hello', 123); // Pierwszy call
expect(mockFn).toHaveBeenLastCalledWith('world'); // Ostatni call
🔍 Jak znaleźć element w teście?
Jest wiele sposobów - wybieraj od najbardziej dostępnego (dla screen readerów):
// Priorytet queries (od najlepszego):
// 1. getByRole (najlepszy - accessibility)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
// 2. getByLabelText (dla formularzy)
screen.getByLabelText('Email');
// 3. getByPlaceholderText
screen.getByPlaceholderText('Enter email');
// 4. getByText
screen.getByText('Hello World');
screen.getByText(/hello/i); // regex, case insensitive
// 5. getByDisplayValue (wartość input)
screen.getByDisplayValue('Current value');
// 6. getByAltText (dla obrazków)
screen.getByAltText('Profile picture');
// 7. getByTitle
screen.getByTitle('Close');
// 8. getByTestId (ostateczność!)
screen.getByTestId('custom-element');
🌟 Przykład: Dlaczego getByRole jest najlepszy?
getByRole szuka elementów tak jak screen reader - jeśli Twój test działa, to aplikacja jest dostępna dla niepełnosprawnych!
// ✅ Dobry test - dostępny dla screen readerów
render(<button>Submit</button>);
screen.getByRole('button', { name: 'Submit' });
// ❌ Zły test - nie sprawdza dostępności
render(<div onClick={handleClick}>Submit</div>);
screen.getByText('Submit'); // Działa, ale to NIE jest przycisk dla screen readera!
Role dla różnych elementów:
<button> → role="button"
<input type="text"> → role="textbox"
<input type="checkbox"> → role="checkbox"
<select> → role="combobox"
<a> → role="link"
<h1> → role="heading"
🔍 getBy vs queryBy vs findBy - jaka różnica?
getBy - rzuca błąd jeśli nie znajdzie (dla elementów które MUSZĄ być)
queryBy - zwraca null jeśli nie znajdzie (dla sprawdzenia że czegoś NIE MA)
findBy - async, czeka na element (dla elementów które pojawią się po chwili)
// getBy - rzuca błąd jeśli nie znajdzie (1 element)
screen.getByText('Hello');
// queryBy - zwraca null jeśli nie znajdzie (dla sprawdzenia braku elementu)
expect(screen.queryByText('Hello')).not.toBeInTheDocument();
// findBy - async, czeka na element (dla async rendering)
const element = await screen.findByText('Hello');
// getAllBy, queryAllBy, findAllBy - dla wielu elementów
const buttons = screen.getAllByRole('button');
🌟 Kiedy użyć którego?
// ✅ getBy - element JEST i nie zmienia się
test('button is visible', () => {
render(<Button label="Click" />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
// ✅ queryBy - sprawdzam że elementu NIE MA
test('error is not shown initially', () => {
render(<Form />);
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
});
// ✅ findBy - element pojawi się po chwili (async)
test('user data loads', async () => {
render(<UserProfile userId={1} />);
expect(await screen.findByText('John Doe')).toBeInTheDocument();
});
💪 Ćwiczenie 3: Testowanie komponentu Counter
Masz komponent:
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
Napisz 5 testów:
Początkowa wartość to 0
Increment zwiększa o 1
Decrement zmniejsza o 1
Reset ustawia na 0
Wielokrotne kliknięcia działają poprawnie (np. 3x increment = 3)
User interactions
🔍 userEvent vs fireEvent - jaka różnica?
userEvent - symuluje PRAWDZIWEGO użytkownika:
Wpisywanie tekstu → każda litera osobno, z opóźnieniem
Kliknięcie → hover + mousedown + mouseup + click
Tab → focus na następny element
fireEvent - wywołuje event BEZPOŚREDNIO (szybciej, ale mniej realistycznie)
Polecam userEvent! Bliższy prawdziwym interakcjom.
import userEvent from '@testing-library/user-event';
test('user interactions', async () => {
const user = userEvent.setup();
render(<MyComponent />);
// Click
await user.click(screen.getByRole('button'));
// Type
await user.type(screen.getByRole('textbox'), 'Hello');
// Clear
await user.clear(screen.getByRole('textbox'));
// Select option
await user.selectOptions(screen.getByRole('combobox'), 'option1');
// Check checkbox
await user.click(screen.getByRole('checkbox'));
// Keyboard
await user.keyboard('{Enter}');
await user.keyboard('{Escape}');
// Tab navigation
await user.tab();
});
import { fireEvent } from '@testing-library/react';
test('simple events', () => {
render(<MyComponent />);
fireEvent.click(screen.getByRole('button'));
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'Hello' }
});
fireEvent.submit(screen.getByRole('form'));
});
Testowanie formularzy
🔍 Co testować w formularzach?
✅ Czy można wpisać dane?
✅ Czy walidacja działa?
✅ Czy submit wysyła poprawne dane?
✅ Czy błędy się pokazują?
// LoginForm.tsx
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
}
function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit(email, password);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Login</button>
</form>
);
}
// LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect, vi } from 'vitest';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
test('submits form with email and password', async () => {
const handleSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
// Wpisz email
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
// Wpisz hasło
await user.type(screen.getByLabelText(/password/i), 'password123');
// Kliknij submit
await user.click(screen.getByRole('button', { name: /login/i }));
// Sprawdź czy onSubmit został wywołany z poprawnymi danymi
expect(handleSubmit).toHaveBeenCalledWith('test@example.com', 'password123');
});
test('inputs are initially empty', () => {
render(<LoginForm onSubmit={() => {}} />);
expect(screen.getByLabelText(/email/i)).toHaveValue('');
expect(screen.getByLabelText(/password/i)).toHaveValue('');
});
});
🌟 Przykład: Formularz z walidacją
function SignupForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!email.includes('@')) {
setError('Invalid email');
return;
}
setError('');
onSubmit(email);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-label="Email"
/>
{error && <p role="alert">{error}</p>}
<button type="submit">Sign up</button>
</form>
);
}
// Test
test('shows error for invalid email', async () => {
const user = userEvent.setup();
render(<SignupForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText('Email'), 'invalid');
await user.click(screen.getByRole('button', { name: 'Sign up' }));
expect(screen.getByRole('alert')).toHaveTextContent('Invalid email');
});
test('submits for valid email', async () => {
const handleSubmit = vi.fn();
const user = userEvent.setup();
render(<SignupForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.click(screen.getByRole('button', { name: 'Sign up' }));
expect(handleSubmit).toHaveBeenCalledWith('test@example.com');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
💪 Ćwiczenie 4: Test formularza kontaktowego
Formularz ma: imię, email, wiadomość (textarea), checkbox "Zgadzam się".
Napisz testy dla:
Wszystkie pola są puste na początku
Można wpisać tekst w każde pole
Checkbox musi być zaznaczony żeby wysłać
Submit wywołuje callback z poprawnymi danymi
Błąd gdy email nie ma @
Testowanie async code
🔍 Dlaczego async jest trudniejszy?
Kod asynchroniczny (API calls, timeouty) dzieje się "w tle". Test musi POCZEKAĆ na wynik!
Narzędzia:
await screen.findBy...() - czeka na element (max 1s)
waitFor(() => {...}) - czeka aż warunek będzie spełniony
vi.fn() - mock fetch/API
// UserProfile.tsx
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchUser() {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (err) {
setError('Failed to load user');
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import UserProfile from './UserProfile';
// Mock global fetch
global.fetch = vi.fn();
describe('UserProfile', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('shows loading state initially', () => {
(global.fetch as any).mockResolvedValue({
json: async () => ({ id: 1, name: 'John', email: 'john@example.com' })
});
render(<UserProfile userId={1} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
test('displays user data after loading', async () => {
(global.fetch as any).mockResolvedValue({
json: async () => ({ id: 1, name: 'John Doe', email: 'john@example.com' })
});
render(<UserProfile userId={1} />);
// Czekaj aż załaduje się user
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
test('displays error when fetch fails', async () => {
(global.fetch as any).mockRejectedValue(new Error('Network error'));
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
test('fetches new user when userId changes', async () => {
(global.fetch as any)
.mockResolvedValueOnce({
json: async () => ({ id: 1, name: 'User 1', email: 'user1@example.com' })
})
.mockResolvedValueOnce({
json: async () => ({ id: 2, name: 'User 2', email: 'user2@example.com' })
});
const { rerender } = render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('User 1')).toBeInTheDocument();
});
// Zmień userId
rerender(<UserProfile userId={2} />);
await waitFor(() => {
expect(screen.getByText('User 2')).toBeInTheDocument();
});
expect(global.fetch).toHaveBeenCalledTimes(2);
});
});
🔍 Mock fetch - krok po kroku:
global.fetch = vi.fn() - zastępujemy prawdziwy fetch mockiem
.mockResolvedValue({...}) - mówimy co fetch ma zwrócić (sukces)
.mockRejectedValue(...) - mówimy że fetch ma zwrócić błąd
vi.clearAllMocks() - czyścimy historię między testami
Po co mockować? Żeby NIE wysyłać prawdziwych requestów! Testy muszą być:
✅ Szybkie (prawdziwe API = wolne)
✅ Niezawodne (API może być offline)
✅ Powtarzalne (zawsze ten sam wynik)
🌟 Przykład: findBy vs waitFor
// ✅ findBy - prostsza składnia dla pojedynczego elementu
test('user loads', async () => {
render(<UserProfile userId={1} />);
expect(await screen.findByText('John Doe')).toBeInTheDocument();
});
// ✅ waitFor - gdy musisz sprawdzić więcej rzeczy
test('user loads with email', async () => {
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
});
Testowanie custom hooks
🔍 Dlaczego hooki testujemy inaczej?
Hooki NIE MOGĄ być wywołane poza komponentem. Nie możesz zrobić:
// ❌ To nie zadziała!
const result = useCounter();
Rozwiązanie: renderHook - "udaje" że hook jest w komponencie!
// useCounter.ts
import { useState } from 'react';
export function useCounter(initialValue: number = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, test, expect } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
🔍 Co to jest act()?
act() mówi React: "wykonaj teraz wszystkie aktualizacje stanu". Bez tego:
// ❌ Bez act - test może nie zadziałać
result.current.increment();
expect(result.current.count).toBe(1); // Może być jeszcze 0!
// ✅ Z act - czekamy na aktualizację
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1); // Na pewno 1!
Reguła: Zawsze owijaj wywołania funkcji zmieniających stan w act()!
🌟 Przykład: Hook z async logiką
// useFetch.ts
export function useFetch(url: string) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchData() {
try {
const response = await fetch(url);
const json = await response.json();
setData(json);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchData();
}, [url]);
return { data, loading, error };
}
// useFetch.test.ts
global.fetch = vi.fn();
test('fetches data successfully', async () => {
global.fetch.mockResolvedValue({
json: async () => ({ name: 'John' })
});
const { result } = renderHook(() => useFetch('/api/user'));
// Początkowo loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
// Czekaj na dane
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ name: 'John' });
expect(result.current.error).toBe(null);
});
test('handles fetch error', async () => {
global.fetch.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useFetch('/api/user'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe('Network error');
expect(result.current.data).toBe(null);
});
💪 Ćwiczenie 5: Test custom hook useToggle
Hook useToggle:
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = () => setValue(v => !v);
const setTrue = () => setValue(true);
const setFalse = () => setValue(false);
return { value, toggle, setTrue, setFalse };
}
Napisz testy dla:
Wartość początkowa to false
Wartość początkowa to true gdy przekażesz true
toggle() zmienia false → true
toggle() zmienia true → false
setTrue() ustawia na true
setFalse() ustawia na false
Mocking
🔍 Po co mockować funkcje?
Mock = fałszywa funkcja która udaje prawdziwą. Używamy gdy:
Chcemy sprawdzić czy funkcja została wywołana
Chcemy sprawdzić z jakimi argumentami
Chcemy kontrolować co funkcja zwraca
Prawdziwa funkcja jest "droga" (API call, zapis do bazy)
import { vi } from 'vitest';
test('mocking functions', () => {
const mockFn = vi.fn();
mockFn('arg1', 'arg2');
mockFn('arg3');
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('arg3');
});
test('mock return value', () => {
const mockFn = vi.fn(() => 'mocked value');
expect(mockFn()).toBe('mocked value');
// Lub
mockFn.mockReturnValue('another value');
expect(mockFn()).toBe('another value');
// Raz
mockFn.mockReturnValueOnce('once').mockReturnValue('always');
expect(mockFn()).toBe('once');
expect(mockFn()).toBe('always');
});
🌟 Przykład: Mock callback w komponencie
function SearchBar({ onSearch }) {
const [query, setQuery] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSearch(query);
};
return (
<form onSubmit={handleSubmit}>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<button type="submit">Search</button>
</form>
);
}
// Test
test('calls onSearch with query', async () => {
const mockOnSearch = vi.fn();
const user = userEvent.setup();
render(<SearchBar onSearch={mockOnSearch} />);
await user.type(screen.getByRole('textbox'), 'React hooks');
await user.click(screen.getByRole('button', { name: 'Search' }));
expect(mockOnSearch).toHaveBeenCalledWith('React hooks');
expect(mockOnSearch).toHaveBeenCalledTimes(1);
});
🔍 Mockowanie całych modułów
Czasami musisz zamockować cały plik (np. api.ts z funkcjami API). vi.mock() robi to automatycznie!
// api.ts
export async function fetchUsers() {
const response = await fetch('/api/users');
return response.json();
}
// Component.test.tsx
import { vi } from 'vitest';
import * as api from './api';
vi.mock('./api', () => ({
fetchUsers: vi.fn()
}));
test('uses mocked API', async () => {
const mockUsers = [{ id: 1, name: 'John' }];
vi.mocked(api.fetchUsers).mockResolvedValue(mockUsers);
// Test komponentu używającego fetchUsers
});
🔍 Testowanie z React Router
Komponenty używające useNavigate, useParams potrzebują Router'a. Używamy MemoryRouter do testów!
import { vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ id: '123' })
};
});
// Lub użyj MemoryRouter w testach
test('navigation', () => {
render(
<MemoryRouter initialEntries={['/users/123']}>
<UserProfile />
</MemoryRouter>
);
});
🌟 Przykład: Test z MemoryRouter
function UserProfile() {
const { id } = useParams();
const navigate = useNavigate();
return (
<div>
<h1>User {id}</h1>
<button onClick={() => navigate('/users')}>Back</button>
</div>
);
}
// Test
test('displays user ID from params', () => {
render(
<MemoryRouter initialEntries={['/users/42']}>
<Routes>
<Route path="/users/:id" element={<UserProfile />} />
</Routes>
</MemoryRouter>
);
expect(screen.getByText('User 42')).toBeInTheDocument();
});
Testowanie Context
🔍 Jak testować Context?
Context musi mieć Provider! W testach:
Owijamy testowy komponent w Provider
Testujemy czy Context dostarcza poprawne wartości
Testujemy czy funkcje z Context działają
// ThemeContext.tsx
const ThemeContext = createContext<{ theme: string; toggleTheme: () => void } | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light');
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
}
// ThemeContext.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, useTheme } from './ThemeContext';
function TestComponent() {
const { theme, toggleTheme } = useTheme();
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={toggleTheme}>Toggle</button>
</div>
);
}
describe('ThemeContext', () => {
test('provides default theme', () => {
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);
expect(screen.getByText(/current theme: light/i)).toBeInTheDocument();
});
test('toggles theme', async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);
expect(screen.getByText(/light/i)).toBeInTheDocument();
await user.click(screen.getByRole('button'));
expect(screen.getByText(/dark/i)).toBeInTheDocument();
});
});
🌟 Przykład: Test Auth Context
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = async (email, password) => {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
const data = await response.json();
setUser(data.user);
};
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// Test
test('login sets user', async () => {
global.fetch = vi.fn().mockResolvedValue({
json: async () => ({ user: { id: 1, name: 'John' } })
});
function TestComponent() {
const { user, login } = useAuth();
return (
<div>
{user ? <p>Logged in as {user.name}</p> : <p>Not logged in</p>}
<button onClick={() => login('test@test.com', 'pass')}>Login</button>
</div>
);
}
const user = userEvent.setup();
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
expect(screen.getByText('Not logged in')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Login' }));
await waitFor(() => {
expect(screen.getByText('Logged in as John')).toBeInTheDocument();
});
});
Test helpers i utilities
🔍 Po co custom render?
Gdy każdy test potrzebuje tych samych providerów (Theme, Auth, Router), męczące jest owijanie za każdym razem:
// ❌ Powtarzalny kod w każdym teście
render(
<ThemeProvider>
<AuthProvider>
<RouterProvider>
<MyComponent />
</RouterProvider>
</AuthProvider>
</ThemeProvider>
);
Rozwiązanie: Stwórz własny renderWithProviders!
// test/utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { ThemeProvider } from '../contexts/ThemeContext';
import { AuthProvider } from '../contexts/AuthContext';
function AllTheProviders({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
return render(ui, { wrapper: AllTheProviders, ...options });
}
export * from '@testing-library/react';
Użycie:
import { renderWithProviders } from './test/utils';
test('component with providers', () => {
renderWithProviders(<MyComponent />);
// ...
});
🌟 Przykład: Custom render z opcjami
// Możesz dodać opcje do custom render!
export function renderWithProviders(
ui: ReactElement,
{
initialTheme = 'light',
initialUser = null,
...options
} = {}
) {
function Wrapper({ children }) {
return (
<ThemeProvider initialTheme={initialTheme}>
<AuthProvider initialUser={initialUser}>
{children}
</AuthProvider>
</ThemeProvider>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Użycie z opcjami
test('component with dark theme', () => {
renderWithProviders(<MyComponent />, {
initialTheme: 'dark'
});
});
Coverage (pokrycie testami)
🔍 Co to jest code coverage?
Coverage = procent kodu który jest "dotknięty" przez testy. Pokazuje:
% Stmts - procent linii kodu
% Branch - procent if/else branchy
% Funcs - procent funkcji
% Lines - procent linii
80%+ = dobry coverage! Nie musisz mieć 100% - to często niemożliwe i niepraktyczne.
npm run test:coverage
----------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
----------------------|---------|----------|---------|---------|
All files | 85.71 | 83.33 | 88.88 | 85.71 |
Button.tsx | 100 | 100 | 100 | 100 |
LoginForm.tsx | 90.91 | 75 | 100 | 90.91 |
UserProfile.tsx | 75 | 80 | 75 | 75 |
----------------------|---------|----------|---------|---------|
Cel: 80%+ coverage, ale nie 100% za wszelką cenę!
🔍 Co NIE testować (zmarnowany czas):
Biblioteki zewnętrzne (React, Zustand - mają swoje testy)
Proste gettery/settery
Autogenerowany kod
Kod który "nigdy nie może się wykonać" (obsługa niemożliwych błędów)
Lepiej: 80% coverage dobrych testów niż 100% z bezsensownymi testami!
Best Practices
🔍 Testuj ZACHOWANIE, nie IMPLEMENTACJĘ (Test behavior, not implementation)
Zachowanie = co użytkownik widzi i robi
Implementacja = jak to działa w środku (useState, useEffect)
Dlaczego? Implementacja może się zmienić (useState → Zustand), ale zachowanie pozostaje!
// ❌ Źle - testuje implementację
test('bad test', () => {
const { result } = renderHook(() => useState(0));
expect(result.current[0]).toBe(0); // Testuje useState!
});
// ✅ Dobrze - testuje zachowanie
test('good test', () => {
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
// Testuje co użytkownik widzi i robi
});
test('button is accessible', () => {
render(<Button label="Submit" onClick={() => {}} />);
const button = screen.getByRole('button', { name: /submit/i });
expect(button).toBeInTheDocument();
expect(button).toBeEnabled();
});
describe('TodoList', () => {
describe('rendering', () => {
test('displays empty state when no todos', () => {});
test('displays todos when present', () => {});
});
describe('interactions', () => {
test('adds new todo', () => {});
test('toggles todo', () => {});
test('deletes todo', () => {});
});
describe('filtering', () => {
test('shows all todos by default', () => {});
test('filters active todos', () => {});
test('filters completed todos', () => {});
});
});
🔍 Kiedy używać data-testid?
Rzadko. data-testid powinien być używany tylko wtedy,
gdy nie da się sensownie odwołać do elementu w sposób zbliżony do tego,
jak robi to użytkownik.
Testy powinny odzwierciedlać realne użycie aplikacji — klikanie przycisków,
wypełnianie pól, czytanie tekstów. Dlatego zawsze należy w pierwszej kolejności
korzystać z query opartych o semantykę, dostępność i widoczny interfejs.
Zalecana kolejność:
getByRole – najlepsze rozwiązanie, testuje semantykę HTML
i zgodność z accessibility (np. przyciski, linki, checkboxy).
getByLabelText – idealne dla formularzy, odzwierciedla sposób,
w jaki użytkownik identyfikuje pola.
getByText – dobre, gdy element jest rozpoznawalny po treści,
którą widzi użytkownik.
...inne queries zależne od kontekstu...
getByTestId – ostateczność , gdy element nie ma
sensownej semantyki lub nie jest dostępny z perspektywy użytkownika.
Nadużywanie data-testid prowadzi do testów zależnych od implementacji,
które mogą przechodzić mimo realnych problemów w interfejsie użytkownika.
// ❌ Nadużycie
<button data-testid="submit-button">Submit</button>
screen.getByTestId('submit-button');
// ✅ Lepiej - semantic queries
<button>Submit</button>
screen.getByRole('button', { name: /submit/i });
// ✅ OK dla złożonych przypadków
<div data-testid="complex-widget">...</div>
// ❌ Test robi za dużo
test('complete user flow', async () => {
// 50 linii testu testującego wszystko naraz
});
// ✅ Małe, fokusowane testy
test('user can login', async () => {
// Tylko login
});
test('logged in user can create post', async () => {
// Tylko tworzenie posta
});
💪 Ćwiczenie 6: Oceń testy
Które testy są dobre, a które złe?
// Test A
test('counter', () => {
const { result } = renderHook(() => useState(0));
expect(typeof result.current[1]).toBe('function');
});
// Test B
test('counter displays initial value', () => {
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
// Test C
test('complete flow', async () => {
render(<App />);
await user.click(screen.getByTestId('login-btn'));
await user.type(screen.getByTestId('email'), 'test@test.com');
await user.type(screen.getByTestId('password'), 'pass');
await user.click(screen.getByTestId('submit'));
await user.click(screen.getByTestId('dashboard'));
// ... 20 linii więcej
});
Odpowiedzi: A = zły (testuje implementację), B = dobry (testuje zachowanie), C = zły (za długi, za dużo testid)
Praktyczny przykład: TodoList
// TodoList.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect } from 'vitest';
import TodoList from './TodoList';
describe('TodoList', () => {
test('adds new todo', async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByPlaceholderText(/add todo/i);
await user.type(input, 'Buy milk');
await user.keyboard('{Enter}');
expect(screen.getByText('Buy milk')).toBeInTheDocument();
expect(input).toHaveValue(''); // Input wyczyszczony
});
test('toggles todo completion', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Dodaj todo
await user.type(screen.getByPlaceholderText(/add todo/i), 'Buy milk{Enter}');
// Toggle
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
});
test('deletes todo', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Dodaj todo
await user.type(screen.getByPlaceholderText(/add todo/i), 'Buy milk{Enter}');
expect(screen.getByText('Buy milk')).toBeInTheDocument();
// Usuń
await user.click(screen.getByRole('button', { name: /delete/i }));
expect(screen.queryByText('Buy milk')).not.toBeInTheDocument();
});
test('filters todos', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Dodaj todos
await user.type(screen.getByPlaceholderText(/add todo/i), 'Task 1{Enter}');
await user.type(screen.getByPlaceholderText(/add todo/i), 'Task 2{Enter}');
// Toggle pierwszy
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[0]);
// Filtruj active
await user.click(screen.getByRole('button', { name: /active/i }));
expect(screen.getByText('Task 2')).toBeInTheDocument();
expect(screen.queryByText('Task 1')).not.toBeInTheDocument();
// Filtruj completed
await user.click(screen.getByRole('button', { name: /completed/i }));
expect(screen.getByText('Task 1')).toBeInTheDocument();
expect(screen.queryByText('Task 2')).not.toBeInTheDocument();
});
});
🌟 Analiza testów TodoList:
✅ Fokusowane - każdy test sprawdza jedną funkcję
✅ Czytelne - nazwy testów mówią co testują
✅ Realistyczne - używają userEvent (jak prawdziwy użytkownik)
✅ Kompletne - pokrywają wszystkie główne funkcje (CRUD + filtry)
✅ Niezależne - każdy test zaczyna od zera (render)
Podsumowanie
To był techniczny, ale bardzo ważny wpis! Nauczyliśmy się:
✅ Rodzaje testów – unit, integration, E2E
✅ Setup – Vitest + Testing Library
✅ Podstawy – matchers, queries, assertions
✅ Testowanie komponentów – render, queries, user interactions
✅ User events – userEvent vs fireEvent
✅ Formularze – testowanie inputs, submit
✅ Async code – waitFor, findBy, mocking fetch
✅ Custom hooks – renderHook, act
✅ Mocking – functions, modules, Router
✅ Context – testowanie z providers
✅ Test utilities – custom render
✅ Coverage – jak mierzyć pokrycie
✅ Best Practices – 5 złotych zasad
✅ TodoList – kompletny przykład
Testy to inwestycja – na początku zabierają czas, ale zwracają się wielokrotnie. Dają pewność, przyspieszają development, umożliwiają refactoring. Po tym wpisie jesteś gotowy pisać testy jak profesjonalista!
W kolejnym wpisie poznamy Error Boundaries i obsługę błędów – jak poprawnie obsługiwać błędy w React, logowanie, monitrowanie!
Napisz testy dla swojej aplikacji:
Przetestuj główne komponenty (min 3)
Przetestuj custom hook
Przetestuj formularz z walidacją
Przetestuj async operację
Osiągnij min 80% coverage
(Bonus) Dodaj CI/CD z automatycznymi testami
🎯 BONUS: Wielki projekt końcowy - Test Suite dla Shopping Cart
Stwórz kompletny zestaw testów dla koszyka zakupowego:
Komponenty do przetestowania:
ProductCard - karta produktu z przyciskiem "Dodaj do koszyka"
Cart - lista produktów w koszyku z ilościami
CartSummary - podsumowanie (suma, liczba produktów)
CheckoutForm - formularz płatności
Co przetestować:
ProductCard:
Wyświetla nazwę, cenę, zdjęcie
Przycisk wywołuje callback z produktem
Disabled gdy out of stock
Cart:
Pusty koszyk pokazuje "Koszyk pusty"
Wyświetla produkty z ilościami
Można zwiększyć/zmniejszyć ilość
Można usunąć produkt
CartSummary:
Oblicza poprawną sumę
Oblicza liczbę produktów
Pokazuje kod rabatowy jeśli zastosowany
CheckoutForm:
Waliduje dane karty kredytowej
Waliduje adres
Pokazuje błędy walidacji
Submit wysyła poprawne dane
Integration test:
Dodaj 3 produkty → sprawdź sumę
Zmień ilości → sprawdź nową sumę
Usuń produkt → sprawdź aktualizację
Wypełnij checkout → sprawdź submit
Wymagania techniczne:
✅ Minimum 20 testów (po ~4 na komponent)
✅ Użyj describe do organizacji
✅ Mock fetch/API calls
✅ Użyj userEvent (nie fireEvent)
✅ Test async operations z waitFor
✅ Custom render z CartProvider
✅ Osiągnij 90%+ coverage
✅ Dodaj 1 E2E test (Cypress/Playwright)
To profesjonalny test suite! Po ukończeniu tego projektu będziesz prawdziwym ekspertem od testowania React! 🧪✨