Vitest - Kompletny Przewodnik po Testowaniu z Vite
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 | c8 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": "^2.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'
poolOptions: {
threads: {
singleThread: false,
},
},
// 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 pozwól na spadek poniżej progów
thresholdAutoUpdate: false,
// Wszystkie pliki, nawet nietestowane
all: true,
},
},
})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
npm install -D @vitest/browser webdriverio// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
browser: {
enabled: true,
provider: 'webdriverio',
name: 'chrome',
},
},
})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
Automatyczna migracja
npx vitest-migrateRę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
// ❌ Testowanie implementacji
it('sets state to true', () => {
const { result } = renderHook(() => useToggle())
expect(result.current[0]).toBe(false)
})
// ✅ 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ść
// ✅ Dobre
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/email/i)
// ❌ Unikaj
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 --single-thread" 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 |
Podsumowanie
Vitest to nowoczesny framework testowy, który oferuje:
- Błyskawiczną szybkość - Dzięki integracji z Vite
- Zero konfiguracji - TypeScript, ESM, JSX out-of-box
- Kompatybilność z Jest - Łatwa migracja
- Świetne DX - Watch mode, UI, debugging
- Elastyczność - Od unit testów po browser testing
Jeśli używasz Vite, Vitest jest oczywistym wyborem. Nawet w projektach bez Vite warto rozważyć Vitest ze względu na szybkość i nowoczesne podejście do testowania.