Vitest, testy jednostkowe oparte o Vite
Vitest to narzędzie do uruchamiania testów, które korzysta z tej samej konfiguracji co Vite. Jeśli Twój projekt buduje się przez Vite, testy dziedziczą aliasy ścieżek, wtyczki i przekształcenia bez powielania ustawień. Licencja MIT, bieżąca wersja stabilna to 4.1.10, a piątka jest w becie.
Dlaczego to wygrało z Jestem
Odpowiedź nie brzmi „bo jest szybsze", choć tak się to zwykle reklamuje. Powód jest bardziej prozaiczny i dotyczy konfiguracji.
Przez lata typowy projekt frontendowy miał dwie równoległe konfiguracje: jedną dla narzędzia budującego i drugą dla narzędzia testującego. Obie musiały wiedzieć to samo o aliasach ścieżek, o obsłudze TypeScriptu, o tym, co zrobić z importem pliku CSS albo obrazka. Rozjeżdżały się regularnie, a objawem był test, który nie potrafił zaimportować modułu importowanego przez aplikację bez problemu.
Vitest usuwa ten problem, bo czyta konfigurację narzędzia budującego. Jedno źródło prawdy oznacza, że alias dodany dla aplikacji od razu działa w testach, a wtyczka przetwarzająca nietypowy format plików nie wymaga drugiej implementacji.
Szybkość jest tu skutkiem ubocznym, ale realnym. Testy uruchamiają się z modułów natywnych zamiast przechodzić przez warstwę transpilacji dla każdego pliku, a przy trybie obserwowania przeliczane są tylko pliki zależne od tego, co zmieniłeś. Przy dużym zestawie testów różnica jest odczuwalna.
Zgodność interfejsu z Jestem była decyzją strategiczną i to ona przesądziła o adopcji, także w projektach na Next.js, gdzie migracja bywała najbardziej bolesna. Funkcje opisujące testy, asercje i atrapy nazywają się tak samo, więc migracja istniejącego zestawu polega w większości na zmianie konfiguracji, a nie na przepisywaniu testów.
Jest jednak granica tej zgodności, o którą warto zapytać przed migracją. Testy korzystające z mechanizmów specyficznych dla starszego narzędzia, na przykład z jego własnego sposobu transformowania plików albo z wtyczek pisanych pod jego wewnętrzne API, nie przeniosą się automatycznie. To samo dotyczy zestawów mocno opartych o automatyczne podmienianie modułów bez jawnej deklaracji, bo tutaj podmiana jest zawsze jawna. W praktyce przeniesienie typowego zestawu testów jednostkowych zajmuje popołudnie, a zestawu z rozbudowaną własną infrastrukturą testową kilka dni.
Tryb przeglądarki, czyli największa zmiana w wersji 4
To najważniejsza rzecz, o której warto wiedzieć, bo materiały sprzed tego wydania opisują ją jako funkcję eksperymentalną.
Tryb przeglądarki przestał być eksperymentem w wersji 4 i jest oznaczony jako stabilny. Pozwala uruchamiać te same testy w prawdziwej przeglądarce zamiast w środowisku udającym dokument, bez zmiany kodu testów.
Różnica ma znaczenie praktyczne. Środowisko udające dokument to implementacja przeglądarki w JavaScripcie i nie odtwarza wszystkiego: wyliczanie stylów, rozmiary elementów, obserwatory widoczności i zachowanie zdarzeń wskaźnika działają tam inaczej albo wcale. Test sprawdzający, czy element jest widoczny, potrafi przechodzić w takim środowisku i zawodzić u użytkownika.
Wraz ze stabilizacją zmienił się jednak sposób konfiguracji i to jest pułapka przy aktualizacji. Dostawcę przeglądarki instaluje się teraz jako osobny pakiet, zależnie od tego, czy używasz Playwrighta, WebDriverIO czy trybu podglądu. Konfiguracja sprzed wersji 4 przestaje działać i wymaga poprawki.
Doszło też porównywanie zrzutów ekranu oraz zapis śladu wykonania, znany z Playwrighta. To drugie jest szczególnie przydatne przy testach zawodzących wyłącznie na serwerze budującym, bo zamiast zgadywać, otwierasz ślad i widzisz stan strony w każdym kroku.
Warto natomiast pamiętać o koszcie. Uruchomienie prawdziwej przeglądarki trwa dłużej niż środowiska udającego, więc rozsądny układ to większość testów w środowisku lekkim i tryb przeglądarki tam, gdzie liczy się faktyczne renderowanie.
Czym jest Vitest?
Vitest to nowoczesny framework testowy zbudowany specjalnie dla ekosystemu Vite. Wykorzystuje tę samą konfigurację co Vite, dzięki czemu testy uruchamiają się błyskawicznie - często 10x szybciej niż Jest. Vitest oferuje pełną kompatybilność z Jest API, co oznacza, że możesz przenieść istniejące testy praktycznie bez zmian.
Framework jest rozwijany przez twórców Vite (Evan You i zespół) i szybko stał się standardem testowania w projektach Vue, React i Svelte korzystających z Vite. Natywne wsparcie dla ESM, TypeScript i JSX sprawia, że konfiguracja jest minimalna - w większości przypadków wystarczy zainstalować paczkę i zacząć pisać testy.
Dlaczego Vitest zamiast Jest?
Kluczowe przewagi Vitest
- Błyskawiczna szybkość - Współdzieli pipeline transformacji z Vite
- Zero konfiguracji - Działa out-of-box z TypeScript, JSX, ESM
- Inteligentny watch mode - Rerunnuje tylko dotknięte testy
- Natywny ESM - Nie wymaga transpilacji do CommonJS
- Kompatybilność z Jest - Migracja istniejących testów bez zmian
- Inline testing - Testy mogą być w tym samym pliku co kod
- Browser mode - Testy mogą działać w prawdziwej przeglądarce
Vitest vs Jest - szczegółowe porównanie
| Cecha | Vitest | Jest |
|---|---|---|
| Startup time | ~10x szybciej | Wolniejszy cold start |
| Hot Module Replacement | Tak (z Vite) | Nie |
| ESM | Natywnie | Wymaga konfiguracji |
| TypeScript | Out-of-box | Wymaga ts-jest lub babel |
| Watch mode | Inteligentny (graf zależności) | Podstawowy (file change) |
| Konfiguracja | Używa vite.config | Osobny jest.config |
| Browser testing | Wbudowane | Wymaga Puppeteer/Playwright |
| In-source testing | Tak | Nie |
| Snapshot testing | Tak | Tak |
| Coverage | v8 lub istanbul | istanbul |
Kiedy wybrać Vitest?
- Projekt używa Vite - Oczywisty wybór, współdzieli konfigurację
- Nowy projekt - Szybszy setup i lepsza developer experience
- Migracja z Jest - API jest praktycznie identyczne
- TypeScript/ESM - Zero dodatkowej konfiguracji
- Potrzebujesz szybkości - Watch mode jest nieporównywalnie szybszy
Kiedy zostać przy Jest?
- Projekt bez Vite - Vitest może działać standalone, ale traci przewagę
- Duża istniejąca baza testów - Migracja może być ryzykowna
- Specyficzne Jest plugins - Nie wszystkie mają odpowiedniki
- Create React App - Jest jest domyślny i dobrze zintegrowany
Instalacja i konfiguracja
Podstawowa instalacja
# npm
npm install -D vitest
# pnpm
pnpm add -D vitest
# yarn
yarn add -D vitest
# bun
bun add -D vitestKonfiguracja package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch"
},
"devDependencies": {
"vitest": "^4.0.0"
}
}Konfiguracja vitest.config.ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
// Środowisko testowe
environment: 'jsdom', // lub 'node', 'happy-dom', 'edge-runtime'
// Globalne importy (describe, it, expect bez importowania)
globals: true,
// Setup files (uruchamiane przed testami)
setupFiles: ['./src/test/setup.ts'],
// Wzorce plików testowych
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
exclude: ['node_modules', 'dist', '.idea', '.git', '.cache'],
// Coverage
coverage: {
provider: 'v8', // lub 'istanbul'
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
],
},
// Timeouts
testTimeout: 10000,
hookTimeout: 10000,
// Równoległość
pool: 'threads', // lub 'forks', 'vmThreads', 'vmForks'
maxWorkers: '50%',
isolate: true,
// Snapshot
snapshotFormat: {
escapeString: true,
printBasicPrototype: true,
},
},
})Konfiguracja dla React
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
css: true,
},
})// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// Automatyczne czyszczenie po każdym teście
afterEach(() => {
cleanup()
})Konfiguracja dla Vue
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
})Konfiguracja TypeScript
// tsconfig.json
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}Pisanie testów
Podstawowa składnia
// math.test.ts
import { describe, it, expect, test } from 'vitest'
import { sum, multiply, divide } from './math'
// describe grupuje powiązane testy
describe('Math utilities', () => {
// Podstawowy test
it('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})
// test i it są równoważne
test('multiplies 2 * 3 to equal 6', () => {
expect(multiply(2, 3)).toBe(6)
})
// Zagnieżdżone describe dla organizacji
describe('sum function', () => {
it('handles positive numbers', () => {
expect(sum(1, 2)).toBe(3)
expect(sum(100, 200)).toBe(300)
})
it('handles negative numbers', () => {
expect(sum(-1, 1)).toBe(0)
expect(sum(-5, -3)).toBe(-8)
})
it('handles zero', () => {
expect(sum(0, 0)).toBe(0)
expect(sum(5, 0)).toBe(5)
})
})
describe('divide function', () => {
it('divides numbers correctly', () => {
expect(divide(10, 2)).toBe(5)
})
it('throws on division by zero', () => {
expect(() => divide(10, 0)).toThrow('Cannot divide by zero')
})
})
})Assertions (expect)
import { describe, it, expect } from 'vitest'
describe('Assertions', () => {
// Równość
it('equality matchers', () => {
expect(2 + 2).toBe(4) // Strict equality (===)
expect({ a: 1 }).toEqual({ a: 1 }) // Deep equality
expect([1, 2]).toStrictEqual([1, 2]) // Strict deep equality
})
// Truthiness
it('truthiness matchers', () => {
expect(true).toBeTruthy()
expect(false).toBeFalsy()
expect(null).toBeNull()
expect(undefined).toBeUndefined()
expect('defined').toBeDefined()
})
// Liczby
it('number matchers', () => {
expect(10).toBeGreaterThan(5)
expect(5).toBeGreaterThanOrEqual(5)
expect(3).toBeLessThan(5)
expect(5).toBeLessThanOrEqual(5)
expect(0.1 + 0.2).toBeCloseTo(0.3) // Floating point
})
// Stringi
it('string matchers', () => {
expect('Hello World').toContain('World')
expect('Hello World').toMatch(/world/i)
expect('Hello').toHaveLength(5)
})
// Tablice i iterables
it('array matchers', () => {
const arr = [1, 2, 3]
expect(arr).toContain(2)
expect(arr).toHaveLength(3)
expect(arr).toEqual(expect.arrayContaining([1, 3]))
})
// Obiekty
it('object matchers', () => {
const user = { name: 'John', age: 30, email: 'john@example.com' }
expect(user).toHaveProperty('name')
expect(user).toHaveProperty('name', 'John')
expect(user).toMatchObject({ name: 'John', age: 30 })
expect(user).toEqual(expect.objectContaining({ name: 'John' }))
})
// Wyjątki
it('exception matchers', () => {
const throwError = () => { throw new Error('Oops!') }
expect(throwError).toThrow()
expect(throwError).toThrow('Oops!')
expect(throwError).toThrow(Error)
expect(throwError).toThrow(/oops/i)
})
// Negacja
it('negation', () => {
expect(5).not.toBe(10)
expect([1, 2]).not.toContain(3)
})
})Testy asynchroniczne
import { describe, it, expect } from 'vitest'
import { fetchUser, fetchPosts, saveUser } from './api'
describe('Async tests', () => {
// Async/await - najbardziej czytelne
it('fetches user with async/await', async () => {
const user = await fetchUser(1)
expect(user).toHaveProperty('name')
expect(user.id).toBe(1)
})
// Promise return
it('fetches user with promise', () => {
return fetchUser(1).then(user => {
expect(user).toHaveProperty('name')
})
})
// Resolves/rejects matchers
it('uses resolves matcher', async () => {
await expect(fetchUser(1)).resolves.toHaveProperty('name')
await expect(fetchUser(-1)).rejects.toThrow('Not found')
})
// Wiele równoległych operacji
it('fetches multiple resources in parallel', async () => {
const [user, posts] = await Promise.all([
fetchUser(1),
fetchPosts(1)
])
expect(user.id).toBe(1)
expect(posts).toBeInstanceOf(Array)
})
// Timeout dla długich operacji
it('handles long operation', async () => {
const result = await saveUser({ name: 'Test' })
expect(result.success).toBe(true)
}, 10000) // 10 sekund timeout
})Setup i teardown
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'
import { createDatabase, closeDatabase, clearDatabase } from './db'
describe('Database tests', () => {
// Raz przed wszystkimi testami w describe
beforeAll(async () => {
await createDatabase()
console.log('Database created')
})
// Raz po wszystkich testach w describe
afterAll(async () => {
await closeDatabase()
console.log('Database closed')
})
// Przed każdym testem
beforeEach(async () => {
await clearDatabase()
console.log('Database cleared')
})
// Po każdym teście
afterEach(() => {
console.log('Test completed')
})
it('test 1', () => {
// Database jest czysta
})
it('test 2', () => {
// Database jest czysta
})
})Skip, only i todo
import { describe, it, expect } from 'vitest'
describe('Test modifiers', () => {
// Pomiń test
it.skip('skipped test', () => {
// Ten test nie zostanie uruchomiony
})
// Uruchom TYLKO ten test
it.only('focused test', () => {
// Tylko ten test zostanie uruchomiony
})
// Test do zaimplementowania
it.todo('implement this test later')
// Warunkowe pomijanie
it.skipIf(process.env.CI)('skip on CI', () => {
// Pominięte na CI
})
it.runIf(process.env.CI)('run only on CI', () => {
// Uruchomione tylko na CI
})
// Pomiń całe describe
describe.skip('skipped suite', () => {
it('test 1', () => {})
it('test 2', () => {})
})
// Sekwencyjne wykonanie testów
describe.concurrent('parallel tests', () => {
it('test 1', async () => {})
it('test 2', async () => {})
})
})Mocking
Mockowanie funkcji
import { describe, it, expect, vi } from 'vitest'
import { processUser, sendEmail } from './user'
describe('Mocking functions', () => {
// Podstawowy mock
it('creates a mock function', () => {
const mockFn = vi.fn()
mockFn('arg1', 'arg2')
expect(mockFn).toHaveBeenCalled()
expect(mockFn).toHaveBeenCalledTimes(1)
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2')
})
// Mock z implementacją
it('mocks with implementation', () => {
const mockFn = vi.fn((x: number) => x * 2)
expect(mockFn(5)).toBe(10)
expect(mockFn).toHaveReturnedWith(10)
})
// Mock zwracający wartość
it('mocks return value', () => {
const mockFn = vi.fn()
.mockReturnValue('default')
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
expect(mockFn()).toBe('first')
expect(mockFn()).toBe('second')
expect(mockFn()).toBe('default')
})
// Mock async function
it('mocks async function', async () => {
const mockFn = vi.fn()
.mockResolvedValue({ id: 1, name: 'John' })
.mockResolvedValueOnce({ id: 2, name: 'Jane' })
expect(await mockFn()).toEqual({ id: 2, name: 'Jane' })
expect(await mockFn()).toEqual({ id: 1, name: 'John' })
})
// Mock rejection
it('mocks rejected promise', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Failed'))
await expect(mockFn()).rejects.toThrow('Failed')
})
// Czyszczenie mocków
it('clears mock state', () => {
const mockFn = vi.fn().mockReturnValue(42)
mockFn()
mockFn()
expect(mockFn).toHaveBeenCalledTimes(2)
mockFn.mockClear() // Czyści calls, nie implementację
expect(mockFn).toHaveBeenCalledTimes(0)
expect(mockFn()).toBe(42)
mockFn.mockReset() // Czyści wszystko
expect(mockFn()).toBeUndefined()
})
})Mockowanie modułów
// user-service.ts
import axios from 'axios'
export async function getUser(id: number) {
const response = await axios.get(`/api/users/${id}`)
return response.data
}
// user-service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import axios from 'axios'
import { getUser } from './user-service'
// Mock całego modułu
vi.mock('axios')
const mockedAxios = vi.mocked(axios)
describe('User Service', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches user from API', async () => {
const mockUser = { id: 1, name: 'John' }
mockedAxios.get.mockResolvedValue({ data: mockUser })
const user = await getUser(1)
expect(mockedAxios.get).toHaveBeenCalledWith('/api/users/1')
expect(user).toEqual(mockUser)
})
it('handles API error', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network error'))
await expect(getUser(1)).rejects.toThrow('Network error')
})
})Mockowanie częściowe
// utils.ts
export function formatDate(date: Date): string {
return date.toISOString()
}
export function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
export function generateId(): string {
return Math.random().toString(36).substring(7)
}
// utils.test.ts
import { describe, it, expect, vi } from 'vitest'
// Mockuj tylko wybrane funkcje
vi.mock('./utils', async () => {
const actual = await vi.importActual('./utils')
return {
...actual,
generateId: vi.fn(() => 'mocked-id'),
}
})
import { formatDate, validateEmail, generateId } from './utils'
describe('Partial mocking', () => {
it('uses real formatDate', () => {
const date = new Date('2024-01-01')
expect(formatDate(date)).toBe('2024-01-01T00:00:00.000Z')
})
it('uses real validateEmail', () => {
expect(validateEmail('test@example.com')).toBe(true)
expect(validateEmail('invalid')).toBe(false)
})
it('uses mocked generateId', () => {
expect(generateId()).toBe('mocked-id')
})
})Spy na metodach
import { describe, it, expect, vi } from 'vitest'
describe('Spying', () => {
it('spies on object method', () => {
const user = {
name: 'John',
greet() {
return `Hello, ${this.name}!`
}
}
const spy = vi.spyOn(user, 'greet')
const result = user.greet()
expect(spy).toHaveBeenCalled()
expect(result).toBe('Hello, John!')
spy.mockRestore() // Przywróć oryginalną implementację
})
it('spies on console.log', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
console.log('test message')
expect(spy).toHaveBeenCalledWith('test message')
spy.mockRestore()
})
it('spies and changes implementation', () => {
const obj = {
getValue: () => 'original'
}
vi.spyOn(obj, 'getValue').mockReturnValue('mocked')
expect(obj.getValue()).toBe('mocked')
})
})Mockowanie timerów
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: NodeJS.Timeout
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn(...args), delay)
}
}
describe('Timer mocking', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('advances timers', async () => {
const callback = vi.fn()
setTimeout(callback, 1000)
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalled()
})
it('runs all timers', () => {
const callback1 = vi.fn()
const callback2 = vi.fn()
setTimeout(callback1, 1000)
setTimeout(callback2, 2000)
vi.runAllTimers()
expect(callback1).toHaveBeenCalled()
expect(callback2).toHaveBeenCalled()
})
it('tests debounce', () => {
const callback = vi.fn()
const debouncedFn = debounce(callback, 500)
debouncedFn('a')
debouncedFn('b')
debouncedFn('c')
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(500)
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('c')
})
it('mocks Date', () => {
vi.setSystemTime(new Date('2024-06-15'))
expect(new Date().toISOString()).toContain('2024-06-15')
})
})Mockowanie globalnych obiektów
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
describe('Global mocking', () => {
it('mocks fetch', async () => {
const mockResponse = { data: 'test' }
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse)
}))
const response = await fetch('/api/data')
const data = await response.json()
expect(data).toEqual(mockResponse)
vi.unstubAllGlobals()
})
it('mocks localStorage', () => {
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
}
vi.stubGlobal('localStorage', localStorageMock)
localStorage.setItem('key', 'value')
expect(localStorageMock.setItem).toHaveBeenCalledWith('key', 'value')
vi.unstubAllGlobals()
})
it('mocks window.location', () => {
const locationMock = {
href: 'http://localhost:3000',
assign: vi.fn(),
reload: vi.fn(),
}
vi.stubGlobal('location', locationMock)
location.assign('http://example.com')
expect(locationMock.assign).toHaveBeenCalledWith('http://example.com')
vi.unstubAllGlobals()
})
})Testowanie komponentów React
Setup dla React Testing Library
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})Testowanie komponentów
// Button.tsx
interface ButtonProps {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
variant?: 'primary' | 'secondary'
}
export function Button({
children,
onClick,
disabled = false,
variant = 'primary'
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
data-testid="button"
>
{children}
</button>
)
}
// Button.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('renders with correct class', () => {
render(<Button variant="secondary">Test</Button>)
expect(screen.getByTestId('button')).toHaveClass('btn-secondary')
})
it('handles click events', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<Button onClick={handleClick}>Click me</Button>)
await user.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByText('Click me')).toBeDisabled()
})
it('does not call onClick when disabled', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<Button onClick={handleClick} disabled>Click me</Button>)
await user.click(screen.getByText('Click me'))
expect(handleClick).not.toHaveBeenCalled()
})
})Testowanie formularzy
// LoginForm.tsx
import { useState } from 'react'
interface LoginFormProps {
onSubmit: (email: string, password: string) => void
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!email || !password) {
setError('All fields are required')
return
}
if (!email.includes('@')) {
setError('Invalid email format')
return
}
setError('')
onSubmit(email, password)
}
return (
<form onSubmit={handleSubmit} data-testid="login-form">
{error && <div role="alert">{error}</div>}
<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 { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
describe('LoginForm', () => {
it('renders form fields', () => {
render(<LoginForm onSubmit={() => {}} />)
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
})
it('shows error when fields are empty', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={() => {}} />)
await user.click(screen.getByRole('button', { name: /login/i }))
expect(screen.getByRole('alert')).toHaveTextContent('All fields are required')
})
it('shows error for invalid email', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={() => {}} />)
await user.type(screen.getByLabelText(/email/i), 'invalid')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
expect(screen.getByRole('alert')).toHaveTextContent('Invalid email format')
})
it('calls onSubmit with valid data', async () => {
const handleSubmit = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
expect(handleSubmit).toHaveBeenCalledWith('test@example.com', 'password123')
})
})Testowanie z kontekstem i providerami
// ThemeContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react'
interface ThemeContextType {
theme: 'light' | 'dark'
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const toggleTheme = () => {
setTheme(prev => prev === '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
}
// ThemedButton.tsx
import { useTheme } from './ThemeContext'
export function ThemedButton() {
const { theme, toggleTheme } = useTheme()
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
)
}
// ThemedButton.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from './ThemeContext'
import { ThemedButton } from './ThemedButton'
// Helper do renderowania z providerem
function renderWithTheme(ui: React.ReactElement) {
return render(
<ThemeProvider>{ui}</ThemeProvider>
)
}
describe('ThemedButton', () => {
it('shows current theme', () => {
renderWithTheme(<ThemedButton />)
expect(screen.getByText(/current theme: light/i)).toBeInTheDocument()
})
it('toggles theme on click', async () => {
const user = userEvent.setup()
renderWithTheme(<ThemedButton />)
await user.click(screen.getByRole('button'))
expect(screen.getByText(/current theme: dark/i)).toBeInTheDocument()
})
})Testowanie hooków
// useCounter.ts
import { useState, useCallback } from 'react'
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => setCount(c => c + 1), [])
const decrement = useCallback(() => setCount(c => c - 1), [])
const reset = useCallback(() => setCount(initialValue), [initialValue])
return { count, increment, decrement, reset }
}
// useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('increments counter', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('decrements counter', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
it('resets counter', () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.increment()
result.current.increment()
result.current.reset()
})
expect(result.current.count).toBe(10)
})
})Snapshot testing
// Card.tsx
interface CardProps {
title: string
description: string
imageUrl?: string
}
export function Card({ title, description, imageUrl }: CardProps) {
return (
<div className="card">
{imageUrl && <img src={imageUrl} alt={title} />}
<h2>{title}</h2>
<p>{description}</p>
</div>
)
}
// Card.test.tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Card } from './Card'
describe('Card', () => {
it('matches snapshot', () => {
const { container } = render(
<Card
title="Test Card"
description="This is a test description"
/>
)
expect(container).toMatchSnapshot()
})
it('matches snapshot with image', () => {
const { container } = render(
<Card
title="Test Card"
description="Description"
imageUrl="https://example.com/image.jpg"
/>
)
expect(container).toMatchSnapshot()
})
// Inline snapshot - zapisuje w pliku testowym
it('matches inline snapshot', () => {
const { container } = render(
<Card title="Title" description="Desc" />
)
expect(container.innerHTML).toMatchInlineSnapshot(`
"<div class=\\"card\\"><h2>Title</h2><p>Desc</p></div>"
`)
})
})Coverage
Konfiguracja coverage
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
coverage: {
provider: 'v8', // lub 'istanbul'
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage',
// Pliki do włączenia
include: ['src/**/*.{ts,tsx}'],
// Pliki do wykluczenia
exclude: [
'node_modules',
'src/test',
'**/*.d.ts',
'**/*.test.{ts,tsx}',
'**/index.ts',
],
// Progi coverage
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
// Nie podnoś progów automatycznie po lepszym przebiegu
autoUpdate: false,
},
},
},
})Uruchamianie coverage
# Generuj raport coverage
pnpm test:coverage
# Z UI
pnpm vitest --coverage --uiVitest UI
# Instalacja
npm install -D @vitest/ui
# Uruchomienie
pnpm vitest --uiVitest UI oferuje:
- Interaktywny podgląd testów w przeglądarce
- Filtrowanie i wyszukiwanie testów
- Podgląd coverage
- Re-run pojedynczych testów
- Widok grafu zależności
In-source testing
Vitest pozwala na pisanie testów w tym samym pliku co kod:
// math.ts
export function sum(a: number, b: number): number {
return a + b
}
export function multiply(a: number, b: number): number {
return a * b
}
// Testy w tym samym pliku - usuwane podczas buildu
if (import.meta.vitest) {
const { describe, it, expect } = import.meta.vitest
describe('sum', () => {
it('adds numbers', () => {
expect(sum(1, 2)).toBe(3)
})
})
describe('multiply', () => {
it('multiplies numbers', () => {
expect(multiply(2, 3)).toBe(6)
})
})
}Konfiguracja:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
define: {
'import.meta.vitest': 'undefined',
},
test: {
includeSource: ['src/**/*.{js,ts}'],
},
})Browser mode
# Instalacja (dostawca to osobny pakiet: playwright, webdriverio albo preview)
npm install -D vitest @vitest/browser-playwright// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [
{ browser: 'chromium' },
],
},
},
})Integracja z CI/CD
GitHub Actions
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Run tests
run: pnpm test:run
- name: Run coverage
run: pnpm test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
fail_ci_if_error: trueGitLab CI
# .gitlab-ci.yml
test:
image: node:20
stage: test
before_script:
- npm install -g pnpm
- pnpm install
script:
- pnpm test:run
- pnpm test:coverage
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xmlMigracja z Jest
Od czego zacząć migrację
Oficjalnego narzędzia przepisującego testy nie ma, a przewodnik migracji opisuje ją jako pracę ręczną. Zaczynasz od podmiany zależności i konfiguracji, a dopiero potem poprawiasz to, co nie przeszło.
npm uninstall jest ts-jest babel-jest jest-environment-jsdom
npm install -D vitest jsdomRęczne zmiany
// Jest
jest.fn()
jest.spyOn(obj, 'method')
jest.mock('./module')
jest.useFakeTimers()
// Vitest
vi.fn()
vi.spyOn(obj, 'method')
vi.mock('./module')
vi.useFakeTimers()// Jest - globals w setupFilesAfterEnv
// jest.config.js
module.exports = {
setupFilesAfterEnv: ['./src/setupTests.ts']
}
// Vitest
// vitest.config.ts
export default defineConfig({
test: {
globals: true,
setupFiles: ['./src/test/setup.ts']
}
})Best practices
1. Organizacja testów
src/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx # Testy obok kodu
│ │ └── index.ts
├── hooks/
│ ├── useCounter.ts
│ └── useCounter.test.ts
├── utils/
│ ├── format.ts
│ └── format.test.ts
└── test/
├── setup.ts # Globalny setup
├── mocks/ # Wspólne mocki
│ └── handlers.ts
└── utils/ # Test utilities
└── renderWithProviders.tsx2. Nazewnictwo testów
// Opisowe nazwy
describe('Button component', () => {
describe('when clicked', () => {
it('should call onClick handler', () => {})
it('should not call onClick when disabled', () => {})
})
describe('when loading', () => {
it('should show spinner', () => {})
it('should disable the button', () => {})
})
})3. Unikaj implementation details
// Zle - testowanie implementacji
it('sets state to true', () => {
const { result } = renderHook(() => useToggle())
expect(result.current[0]).toBe(false)
})
// Dobrze - testowanie zachowania
it('toggles from off to on', () => {
render(<Toggle />)
const button = screen.getByRole('switch')
expect(button).toHaveAttribute('aria-checked', 'false')
fireEvent.click(button)
expect(button).toHaveAttribute('aria-checked', 'true')
})4. Używaj odpowiednich queries
// Priorytet queries w React Testing Library:
// 1. getByRole - najbardziej dostępne
// 2. getByLabelText - dla formularzy
// 3. getByPlaceholderText - dla inputów
// 4. getByText - dla tekstu
// 5. getByTestId - ostateczność
// Dobrze
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/email/i)
// Zle
screen.getByTestId('submit-button')
container.querySelector('.btn-submit')5. Izoluj testy
describe('UserService', () => {
let db: TestDatabase
beforeEach(async () => {
db = await createTestDatabase()
})
afterEach(async () => {
await db.cleanup()
})
it('creates user', async () => {
// Test używa czystej bazy
})
})FAQ - Najczęściej zadawane pytania
Jak uruchomić pojedynczy test?
# Przez nazwę pliku
pnpm vitest Button.test.tsx
# Przez pattern
pnpm vitest -t "should render"
# W watch mode, naciśnij 't' i wpisz patternJak debugować testy?
// 1. Użyj console.log
it('debug test', () => {
const result = someFunction()
console.log('Result:', result)
})
// 2. Użyj screen.debug()
it('debug DOM', () => {
render(<Component />)
screen.debug() // Wyświetla cały DOM
screen.debug(screen.getByRole('button')) // Konkretny element
})
// 3. VS Code debugger
// Dodaj "test": "vitest --inspect-brk --no-file-parallelism" do scriptsJak testować komponenty z zewnętrznymi zależnościami?
// Mock zewnętrznego modułu
vi.mock('next/router', () => ({
useRouter: () => ({
push: vi.fn(),
pathname: '/',
query: {},
}),
}))
// Mock fetch
vi.stubGlobal('fetch', vi.fn())Vitest vs Playwright - kiedy co używać?
| Vitest | Playwright |
|---|---|
| Unit/integration tests | E2E tests |
| Szybkie, izolowane | Wolniejsze, realistyczne |
| jsdom/happy-dom | Prawdziwe przeglądarki |
| Testowanie logiki | Testowanie user flows |
| Pojedyncze komponenty | Cała aplikacja |
Co przyniesie wersja 5
Piątka jest w becie, więc szczegóły mogą się jeszcze zmienić, ale kilka rzeczy warto znać przy planowaniu, bo dotyczą wymagań środowiska.
Podniesione zostały progi wersji: potrzebny będzie Node.js co najmniej 22 i Vite co najmniej 6.4. Dla projektów stojących na starszych wersjach oznacza to, że aktualizacja narzędzia testowego pociągnie za sobą aktualizację reszty, a to zwykle osobne zadanie.
Zmienia się też domyślne zachowanie atrap. Czyszczenie atrap między testami będzie domyślnie włączone, czyli stan wywołań wyzeruje się przed każdym testem bez proszenia. To zmiana na lepsze, bo eliminuje częsty błąd polegający na zależnościach między testami, ale może ujawnić testy, które dotąd przechodziły przypadkiem, korzystając ze stanu pozostawionego przez poprzednika.
Usunięte zostaną wycofane wcześniej sposoby oznaczania testów wykonywanych po kolei. Jeśli Twój zestaw z nich korzysta, warto przejrzeć go wcześniej, bo to zmiana mechaniczna, ale dotyka wielu plików naraz.
Praktyczna rada: nie migruj na wydanie w becie w projekcie produkcyjnym, ale sprawdź już teraz, czy spełniasz wymagania wersji środowiska. Jeśli nie, aktualizacja Node.js jest tym, co warto zaplanować, zanim piątka wyjdzie stabilnie.
Atrapy modułów, czyli miejsce największych kłopotów
Podmienianie modułów w testach to funkcja, z której korzystają wszyscy i którą prawie wszyscy stosują za szeroko. Warto znać kilka rzeczy, zanim się w to wejdzie.
Pierwsza to podnoszenie wywołań. Deklaracja podmieniająca moduł jest przenoszona na górę pliku, przed importy, niezależnie od tego, gdzie ją napisałeś. Skutkiem jest to, że nie możesz odwołać się w niej do zmiennej zdefiniowanej wyżej w pliku, bo w momencie wykonania ta zmienna jeszcze nie istnieje. Komunikat błędu mówi o dostępie przed inicjalizacją i jest to najczęstsza pomyłka przy pierwszych testach z atrapami.
Druga to zakres. Podmieniony moduł zostaje podmieniony dla całego pliku testowego, a nie dla jednego przypadku. Jeśli potrzebujesz różnych zachowań w różnych testach, podmieniasz raz, a w poszczególnych testach zmieniasz to, co atrapa zwraca.
Trzecia, najważniejsza merytorycznie, to pytanie, czy w ogóle podmieniać. Test podmieniający wszystko dookoła sprawdza, czy kod wywołuje funkcje w ustalonej kolejności, a nie czy robi to, co powinien. Taki test przechodzi po refaktorze, który psuje zachowanie, i zawodzi po refaktorze, który niczego nie psuje. Podmieniaj granice systemu, czyli sieć, czas, losowość i system plików, a logikę własną testuj naprawdę.
Czwarta to czas. Sterowanie zegarem pozwala testować rzeczy zależne od upływu czasu bez czekania, i jest to niemal zawsze lepsze niż odliczanie w teście. Pamiętaj tylko o przywróceniu prawdziwego zegara po teście, bo inaczej kolejne testy w tym samym pliku odziedziczą zatrzymany czas i zaczną zachowywać się dziwnie.
Piąta dotyczy pokrycia kodu i jest bardziej o interpretacji niż o konfiguracji. Liczba procent mówi, ile linii wykonano podczas testów, a nie ile zachowań sprawdzono. Test wywołujący funkcję i nieweryfikujący wyniku podnosi pokrycie tak samo jak test rzetelny, więc próg ustawiony w konfiguracji chroni przed kodem nietkniętym, a nie przed kodem nieprzetestowanym. Traktuj tę liczbę jako sygnał, gdzie zajrzeć, a nie jako cel sam w sobie, bo zespół dążący do wysokiego procentu zwykle osiąga go najtańszym możliwym sposobem.
Vitest a alternatywy
| Cecha | Vitest | Jest | Playwright |
|---|---|---|---|
| Rodzaj testów | jednostkowe i komponentów | jednostkowe i komponentów | end to end i komponentów |
| Konfiguracja | dziedziczona z Vite | osobna | osobna |
| Obsługa modułów natywnych | natywna | przez warstwę zgodności | natywna |
| Prawdziwa przeglądarka | opcjonalnie, tryb przeglądarki | nie | zawsze |
| Szybkość pojedynczego testu | wysoka | średnia | niska |
| Ekosystem i materiały | rosnący | największy | duży |
Podział zadań między te narzędzia jest dziś dość ustalony. Logikę, funkcje pomocnicze i zachowanie komponentów sprawdzasz narzędziem lekkim. Przepływy przez całą aplikację, w których liczy się prawdziwa przeglądarka i prawdziwy serwer, testujesz osobno. Nakładanie się tych zakresów przy testach komponentów jest realne i wybór zależy od tego, czy potrzebujesz wiernego renderowania, czy szybkości.
Dokumentację znajdziesz na stronie Vitest, a kod źródłowy w repozytorium projektu.