Testovací pyramida
Testovací pyramida popisuje doporučený poměr různých typů testů. Čím níže v pyramidě, tím rychlejší, levnější a více testů. Čím výše, tím realističtější ale pomalejší.
| Typ | Co testuje | Nástroje | Rychlost |
|---|---|---|---|
| Unit | Izolovaná funkce, hook, utilita | Vitest, Jest | ⚡ Velmi rychlé |
| Integration | Komponenta + store, API + DB | Vitest + MSW, Supertest | 🏃 Rychlé |
| E2E | Celý uživatelský tok v prohlížeči | Playwright, Cypress | 🐢 Pomalé |
Vitest — moderní test runner
Vitest je Vite-nativní test runner — stejná konfigurace jako aplikace, ESM podpora, velmi rychlý hot reload testů. Pro nové projekty preferujte Vitest před Jest.
// vite.config.ts — přidat test konfigurace
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // simuluje browser DOM
globals: true, // describe, it, expect bez importu
setupFiles: ['./src/test/setup.ts'],
coverage: {
reporter: ['text', 'lcov', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
});
// src/test/setup.ts
import '@testing-library/jest-dom'; // přidá custom matchers
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup(); // vyčistí DOM po každém testu
});
Unit testy — funkce a utility
Unit test testuje jednu funkci v izolaci. Vždy stejný vstup → stejný výstup. Žádné side effects, žádné závislosti (mockovat je).
// src/utils/format.ts
export function formatCena(castka: number, mena = 'Kč'): string {
if (castka < 0) throw new Error('Cena nemůže být záporná');
return `${castka.toLocaleString('cs-CZ')} ${mena}`;
}
export function slugify(text: string): string {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // remove diacritics
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-');
}
// src/utils/format.test.ts
import { describe, it, expect } from 'vitest';
import { formatCena, slugify } from './format';
describe('formatCena', () => {
it('formátuje číslo s měnou', () => {
expect(formatCena(299)).toBe('299 Kč');
expect(formatCena(1000)).toBe('1 000 Kč');
});
it('akceptuje vlastní měnu', () => {
expect(formatCena(100, 'EUR')).toBe('100 EUR');
});
it('vyhodí chybu pro zápornou cenu', () => {
expect(() => formatCena(-1)).toThrow('Cena nemůže být záporná');
});
});
describe('slugify', () => {
it('převede text na slug', () => {
expect(slugify('Kapesní nůž')).toBe('kapesni-nuz');
expect(slugify('Hello World!')).toBe('hello-world');
});
it('odstraní speciální znaky', () => {
expect(slugify(' Více mezer ')).toBe('vice-mezer');
});
});
// Matchers — nejpoužívanější:
// expect(val).toBe(2) // přesná rovnost (===)
// expect(val).toEqual({ a: 1 }) // hluboká rovnost (deep equal)
// expect(val).toBeTruthy() // truthy hodnota
// expect(val).toBeNull() // null
// expect(arr).toHaveLength(3) // délka pole
// expect(arr).toContain('item') // obsahuje prvek
// expect(str).toMatch(/regex/) // match regex
// expect(fn).toThrow('msg') // vyhodí chybu
// expect(spy).toHaveBeenCalledWith(arg) // volání spy
// Kvíz: Co je správná struktura pro pojmenování unit testu?
React Testing Library
RTL testuje komponenty z pohledu uživatele — ne implementaci, ale chování. Principy: dotazovat prvky jako uživatel (role, label, text), ne jako developer (class, id, data-testid).
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
import { LoginForm } from './LoginForm';
// ── Základní render + assertions ───────────────────────────
describe('Button', () => {
it('renderuje text', () => {
render(<Button label="Odeslat" />);
expect(screen.getByText('Odeslat')).toBeInTheDocument();
});
it('volá onClick při kliknutí', async () => {
const handleClick = vi.fn(); // mock funkce
render(<Button label="Klikni" onClick={handleClick} />);
await userEvent.click(screen.getByRole('button', { name: 'Klikni' }));
expect(handleClick).toHaveBeenCalledOnce();
});
it('je disabled když prop disabled=true', () => {
render(<Button label="Neklíkej" disabled />);
expect(screen.getByRole('button')).toBeDisabled();
});
});
// ── Složitější komponenta s formulářem ─────────────────────
describe('LoginForm', () => {
it('zobrazí chybu pro prázdný email', async () => {
render(<LoginForm onSubmit={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /přihlásit/i }));
expect(await screen.findByText(/email je povinný/i)).toBeInTheDocument();
});
it('zavolá onSubmit se správnými daty', async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await userEvent.type(
screen.getByLabelText(/email/i),
'jana@example.com'
);
await userEvent.type(
screen.getByLabelText(/heslo/i),
'tajneheslo123'
);
await userEvent.click(screen.getByRole('button', { name: /přihlásit/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'jana@example.com',
password: 'tajneheslo123',
});
});
});
// Dotazovací priority (getBy > findBy > queryBy):
// getByRole('button') ← přístupnost (preferred!)
// getByLabelText(/email/i) ← formulář
// getByText(/odeslat/i) ← viditelný text
// getByPlaceholderText() ← fallback
// getByTestId('my-element') ← poslední záchrana
getBy — synchronní, vyhodí chybu pokud nenajde. findBy — asynchronní (čeká na async operaci), vrátí Promise. queryBy — synchronní, vrátí null pokud nenajde (pro asserting absence: expect(queryBy...).not.toBeInTheDocument()).Mocking — izolace závislostí
Mock nahradí skutečnou závislost (API, databáze, modul) kontrolovanou alternativou. Testy běží rychle a deterministicky bez sítě nebo DB.
import { vi, describe, it, expect, beforeEach } from 'vitest';
// ── vi.fn() — mock funkce ───────────────────────────────
const mockFetch = vi.fn();
mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) });
// Assertions na mock
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith('/api/products');
expect(mockFetch).toHaveBeenLastCalledWith(expect.stringContaining('/api'));
// ── vi.mock() — mockovat modul ──────────────────────────
vi.mock('../lib/prisma', () => ({
prisma: {
product: {
findMany: vi.fn().mockResolvedValue([
{ id: 1, nazev: 'Test nůž', cena: 299 }
]),
create: vi.fn().mockImplementation((data) =>
Promise.resolve({ id: Math.random(), ...data.data })
),
},
},
}));
// ── MSW (Mock Service Worker) — mock HTTP API ───────────
// npm install -D msw
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/products', () => {
return HttpResponse.json([
{ id: 1, nazev: 'Victorinox', cena: 299 }
]);
}),
http.post('/api/products', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 42, ...body }, { status: 201 });
}),
http.get('/api/products/:id', ({ params }) => {
if (params.id === '999') {
return HttpResponse.json({ error: 'Nenalezeno' }, { status: 404 });
}
return HttpResponse.json({ id: parseInt(params.id), nazev: 'Test' });
}),
);
// Setup v testech
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Override pro konkrétní test
it('zpracuje 404 chybu', async () => {
server.use(
http.get('/api/products/1', () =>
HttpResponse.json({ error: 'Nenalezeno' }, { status: 404 })
)
);
// ... test logika
});
// Kvíz: Proč je MSW (Mock Service Worker) lepší než přímé mockování fetch pro React komponenty?
Integration testy — API a DB
Integration testy testují spolupráci více vrstev — Express handler + validace + databáze. Používají skutečnou (test) DB nebo in-memory alternativu.
// tests/api/products.integration.test.ts
// npm install -D supertest @types/supertest
import request from 'supertest';
import app from '../../src/app';
import { prisma } from '../../src/lib/prisma';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
describe('POST /api/products', () => {
// Cleanup před každým testem
beforeEach(async () => {
await prisma.product.deleteMany();
});
afterAll(async () => {
await prisma.$disconnect();
});
it('vytvoří nový produkt s platnými daty', async () => {
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${testToken}`)
.send({ nazev: 'Test nůž', slug: 'test-nuz', cena: 299 });
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: expect.any(Number),
nazev: 'Test nůž',
cena: '299', // Decimal → string v JSON
});
// Ověřit v DB
const dbProduct = await prisma.product.findUnique({
where: { id: response.body.id }
});
expect(dbProduct).not.toBeNull();
});
it('vrátí 400 pro chybějící povinná pole', async () => {
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${testToken}`)
.send({ nazev: 'Bez ceny' }); // chybí cena
expect(response.status).toBe(400);
expect(response.body.error).toMatch(/validační/i);
});
it('vrátí 401 bez JWT tokenu', async () => {
const response = await request(app)
.post('/api/products')
.send({ nazev: 'Test', cena: 299 });
expect(response.status).toBe(401);
});
});
// Component integration test — s Context
describe('ProductList + CartContext', () => {
it('přidá produkt do košíku při kliknutí', async () => {
render(
<CartProvider>
<ProductList products={mockProducts} />
<CartSummary />
</CartProvider>
);
await userEvent.click(screen.getAllByText('Do košíku')[0]);
expect(screen.getByText(/1 položka/i)).toBeInTheDocument();
});
});
Playwright — E2E testy
Playwright automatizuje skutečný prohlížeč (Chromium, Firefox, WebKit). E2E testy testují celý uživatelský tok — od kliknutí po DB zápis.
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Autentizace', () => {
test('přihlášení a přesměrování na dashboard', async ({ page }) => {
await page.goto('/login');
// Vyplnit formulář
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Heslo').fill('adminpass123');
await page.getByRole('button', { name: /přihlásit/i }).click();
// Počkat na navigaci
await page.waitForURL('/dashboard');
await expect(page.getByText('Vítej, Admin!')).toBeVisible();
// Ověřit, že token je uložen
const token = await page.evaluate(() => localStorage.getItem('token'));
expect(token).toBeTruthy();
});
test('zobrazí chybu pro špatné heslo', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Heslo').fill('spatneheslo');
await page.getByRole('button', { name: /přihlásit/i }).click();
await expect(page.getByText(/neplatné přihlašovací údaje/i)).toBeVisible();
expect(page.url()).toContain('/login'); // zůstal na login
});
});
// tests/e2e/checkout.spec.ts
test('kompletní checkout flow', async ({ page }) => {
// Setup: přihlásit se
await loginAsUser(page);
// Přidat do košíku
await page.goto('/produkty');
await page.getByText('Victorinox Classic').first().click();
await page.getByRole('button', { name: /do košíku/i }).click();
// Ověřit badge košíku
await expect(page.getByTestId('cart-badge')).toHaveText('1');
// Checkout
await page.goto('/checkout');
await page.getByLabel('Jméno').fill('Jana Nováková');
await page.getByLabel('Adresa').fill('Wenceslas Square 1, Praha');
await page.getByRole('button', { name: /objednat/i }).click();
// Potvrzení
await expect(page.getByText(/děkujeme za objednávku/i)).toBeVisible();
});
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
// Kvíz: Proč by se E2E testy neměly spoléhat na CSS třídy nebo data-testid pro lokalizaci prvků?
Cvičení — Napište testy pro shopping cart
Napište unit testy pro cartReducer a integration test pro komponentu CartItem s RTL.
// src/reducers/cartReducer.ts (to je testovaný kód)
export function cartReducer(state: CartItem[], action: CartAction): CartItem[] {
switch (action.type) {
case 'ADD': {
const exists = state.find(i => i.id === action.item.id);
if (exists) {
return state.map(i =>
i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i
);
}
return [...state, { ...action.item, qty: 1 }];
}
case 'REMOVE':
return state.filter(i => i.id !== action.id);
case 'CLEAR':
return [];
default:
return state;
}
}
// src/reducers/cartReducer.test.ts — NAPIŠTE TESTY:
describe('cartReducer', () => {
it('přidá novou položku do prázdného košíku', () => {
// TODO: zavolat cartReducer s [] a ADD akcí
// expect výsledek délky 1 a qty 1
});
it('zvýší qty pro existující položku', () => {
// TODO: stav s existující položkou + ADD stejné ID
// expect qty = 2
});
it('odebere položku přes REMOVE', () => {
// TODO
});
it('vymaže košík přes CLEAR', () => {
// TODO
});
});
// CartItem.test.tsx — NAPIŠTE RTL TEST:
describe('CartItem', () => {
it('zobrazí název a cenu produktu', () => {
// TODO: render <CartItem> s mock daty
});
it('zavolá onRemove při kliknutí na X', async () => {
// TODO: render, userEvent.click, expect mock fn
});
});
Quality Engineer
Zvládáte unit testy, RTL, mocking s MSW a Playwright E2E.
// Taháček
// Vitest základní struktura
describe('Komponenta', () => {
beforeEach(() => { /* setup */ });
afterEach(() => { cleanup(); });
it('dělá X když Y', () => {
expect(result).toBe(expected);
expect(obj).toEqual({ a: 1 });
expect(fn).toThrow('message');
expect(mock).toHaveBeenCalledWith(arg);
});
});
// RTL dotazy (priority order)
getByRole('button', { name: /text/i })
getByLabelText(/email/i)
getByText(/odeslat/i)
findByText(/async text/i) // returns Promise
// Mocking
const mock = vi.fn().mockReturnValue(42);
vi.mock('./module', () => ({ fn: vi.fn() }));
// Playwright
await page.goto('/url');
await page.getByRole('button').click();
await page.getByLabel('Email').fill('text');
await expect(page.getByText('Hello')).toBeVisible();
await page.waitForURL('/dashboard');