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

Vitest, testy jednostkowe oparte o Vite

Vitest to narzędzie do testów korzystające z konfiguracji Vite. Wersja 4, tryb przeglądarki, atrapy modułów, pokrycie kodu i migracja z Jesta.

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

  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
Coveragev8 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": "^4.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', 'vmForks'
    maxWorkers: '50%',
    isolate: true,

    // 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 podnoś progów automatycznie po lepszym przebiegu
        autoUpdate: false,
      },
    },
  },
})

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 (dostawca to osobny pakiet: playwright, webdriverio albo preview)
npm install -D vitest @vitest/browser-playwright
TSvitest.config.ts
TypeScript
// 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
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

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.

Code
Bash
npm uninstall jest ts-jest babel-jest jest-environment-jsdom
npm install -D vitest jsdom

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

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ść

// Dobrze
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/email/i)

// Zle
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 --no-file-parallelism" 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

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

CechaVitestJestPlaywright
Rodzaj testówjednostkowe i komponentówjednostkowe i komponentówend to end i komponentów
Konfiguracjadziedziczona z Viteosobnaosobna
Obsługa modułów natywnychnatywnaprzez warstwę zgodnościnatywna
Prawdziwa przeglądarkaopcjonalnie, tryb przeglądarkiniezawsze
Szybkość pojedynczego testuwysokaśrednianiska
Ekosystem i materiałyrosnącynajwiększyduż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.