Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik21 min czytania

Vitest

Vitest to błyskawicznie szybki framework testowy dla Vite z pełną kompatybilnością Jest API, natywnym wsparciem TypeScript i ESM.

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

  1. Błyskawiczna szybkość - Współdzieli pipeline transformacji z Vite
  2. Zero konfiguracji - Działa out-of-box z TypeScript, JSX, ESM
  3. Inteligentny watch mode - Rerunnuje tylko dotknięte testy
  4. Natywny ESM - Nie wymaga transpilacji do CommonJS
  5. Kompatybilność z Jest - Migracja istniejących testów bez zmian
  6. Inline testing - Testy mogą być w tym samym pliku co kod
  7. Browser mode - Testy mogą działać w prawdziwej przeglądarce

Vitest vs Jest - szczegółowe porównanie

CechaVitestJest
Startup time~10x szybciejWolniejszy cold start
Hot Module ReplacementTak (z Vite)Nie
ESMNatywnieWymaga konfiguracji
TypeScriptOut-of-boxWymaga ts-jest lub babel
Watch modeInteligentny (graf zależności)Podstawowy (file change)
KonfiguracjaUżywa vite.configOsobny jest.config
Browser testingWbudowaneWymaga Puppeteer/Playwright
In-source testingTakNie
Snapshot testingTakTak
Coveragec8 lub istanbulistanbul

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

Code
Bash
# npm
npm install -D vitest

# pnpm
pnpm add -D vitest

# yarn
yarn add -D vitest

# bun
bun add -D vitest

Konfiguracja package.json

Code
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

TSvitest.config.ts
TypeScript
// 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

TSvitest.config.ts
TypeScript
// 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,
  },
})
TSsrc/test/setup.ts
TypeScript
// 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

TSvitest.config.ts
TypeScript
// 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
JSON
// tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

Pisanie testów

Podstawowa składnia

TSmath.test.ts
TypeScript
// 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)

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

TSuser-service.ts
TypeScript
// 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

TSutils.ts
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
Bash
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
TSsrc/test/setup.ts
TypeScript
// 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

TSButton.tsx
TypeScript
// 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

TSLoginForm.tsx
TypeScript
// 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

TSThemeContext.tsx
TypeScript
// 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

TSuseCounter.ts
TypeScript
// 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

TSCard.tsx
TypeScript
// 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

TSvitest.config.ts
TypeScript
// 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

Code
Bash
# Generuj raport coverage
pnpm test:coverage

# Z UI
pnpm vitest --coverage --ui

Vitest UI

Code
Bash
# Instalacja
npm install -D @vitest/ui

# Uruchomienie
pnpm vitest --ui

Vitest 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:

TSmath.ts
TypeScript
// 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:

TSvitest.config.ts
TypeScript
// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  define: {
    'import.meta.vitest': 'undefined',
  },
  test: {
    includeSource: ['src/**/*.{js,ts}'],
  },
})

Browser mode

Code
Bash
# Instalacja
npm install -D @vitest/browser webdriverio
TSvitest.config.ts
TypeScript
// 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
YAML
# .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: true

GitLab CI

.gitlab-ci.yml
YAML
# .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.xml

Migracja z Jest

Automatyczna migracja

Code
Bash
npx vitest-migrate

Ręczne zmiany

Code
TypeScript
// Jest
jest.fn()
jest.spyOn(obj, 'method')
jest.mock('./module')
jest.useFakeTimers()

// Vitest
vi.fn()
vi.spyOn(obj, 'method')
vi.mock('./module')
vi.useFakeTimers()
Code
TypeScript
// 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

Code
TEXT
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.tsx

2. Nazewnictwo testów

Code
TypeScript
// 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

Code
TypeScript
// ❌ 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

Code
TypeScript
// 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

Code
TypeScript
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?

Code
Bash
# Przez nazwę pliku
pnpm vitest Button.test.tsx

# Przez pattern
pnpm vitest -t "should render"

# W watch mode, naciśnij 't' i wpisz pattern

Jak debugować testy?

Code
TypeScript
// 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 scripts

Jak testować komponenty z zewnętrznymi zależnościami?

Code
TypeScript
// 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ć?

VitestPlaywright
Unit/integration testsE2E tests
Szybkie, izolowaneWolniejsze, realistyczne
jsdom/happy-domPrawdziwe przeglądarki
Testowanie logikiTestowanie user flows
Pojedyncze komponentyCał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.