Testowanie React w 2026: Vitest, React Testing Library i najlepsze praktyki

Kompleksowy przewodnik po testowaniu aplikacji React z użyciem Vitest i React Testing Library. Wzorce testowania komponentów, obsługa operacji asynchronicznych, strategie mockowania oraz praktyki przydatne na rozmowach rekrutacyjnych.

Diagram przepływu pracy testowania React w 2026 z Vitest i React Testing Library

Testowanie aplikacji React w 2026 roku koncentruje się na Vitest jako dominującym narzędziu do uruchamiania testów oraz React Testing Library (RTL) jako standardzie testowania komponentów. Ta kombinacja zapewnia szybkie pętle informacji zwrotnej, natywne wsparcie dla modułów ES oraz testy weryfikujące zachowanie zamiast szczegółów implementacji.

Filozofia testowania

Testy powinny odzwierciedlać sposób, w jaki użytkownicy wchodzą w interakcję z komponentami. Zapytania powinny opierać się na dostępnych rolach, etykietach i tekście — nie na klasach CSS czy atrybutach data-testid. Takie podejście wykrywa prawdziwe błędy i jest odporne na refaktoryzację.

Konfiguracja Vitest dla projektów React

Vitest zastąpił Jest jako preferowany runner testów dla nowoczesnych aplikacji React. Natywne wsparcie dla modułów ES eliminuje narzut transformacji, a ścisła integracja z Vite oznacza, że konfiguracja odzwierciedla środowisko deweloperskie.

Produkcyjna konfiguracja Vitest obsługuje transformację JSX, aliasy ścieżek i konfigurację środowiska testowego bez zbędnego boilerplate:

vitest.config.tstypescript
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    // Use jsdom for DOM APIs and React rendering
    environment: 'jsdom',
    // Run setup file before each test file
    setupFiles: ['./src/test/setup.ts'],
    // Include only test files, exclude e2e
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    // Enable global test APIs (describe, it, expect)
    globals: true,
    // Generate coverage reports
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: ['node_modules', 'src/test/**'],
    },
  },
})

Plik setup rozszerza matchery i konfiguruje czyszczenie między testami:

src/test/setup.tstypescript
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'

// Clean up rendered components after each test
afterEach(() => {
  cleanup()
})

// Mock window.matchMedia for responsive components
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
})

Ta konfiguracja zapewnia czysty stan dla każdego testu oraz obsługuje typowe mocki API przeglądarki, których brakuje w jsdom.

Testowanie komponentów z React Testing Library

React Testing Library wymusza testowanie komponentów z perspektywy użytkownika. Zamiast inspekcji wewnętrznego stanu czy wartości propsów, testy wchodzą w interakcję z renderowanym wyjściem poprzez zapytania dostępności.

Przykład komponentu wyszukiwania filtrującego wyniki i wyświetlającego stan ładowania:

SearchResults.tsxtsx
import { useState } from 'react'

interface SearchResultsProps {
  onSearch: (query: string) => Promise<string[]>
}

export function SearchResults({ onSearch }: SearchResultsProps) {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<string[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  async function handleSearch() {
    if (!query.trim()) return
    setIsLoading(true)
    setError(null)
    try {
      const data = await onSearch(query)
      setResults(data)
    } catch (e) {
      setError('Search failed. Please try again.')
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <div>
      <label htmlFor="search-input">Search</label>
      <input
        id="search-input"
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <button onClick={handleSearch} disabled={isLoading}>
        {isLoading ? 'Searching...' : 'Search'}
      </button>
      {error && <p role="alert">{error}</p>}
      <ul aria-label="Search results">
        {results.map((result, i) => (
          <li key={i}>{result}</li>
        ))}
      </ul>
    </div>
  )
}

Testy weryfikują zachowanie komponentu podczas interakcji użytkownika i stanów asynchronicznych:

SearchResults.test.tsxtypescript
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { SearchResults } from './SearchResults'

describe('SearchResults', () => {
  it('displays results after successful search', async () => {
    // Arrange: mock the search function
    const mockSearch = vi.fn().mockResolvedValue(['React', 'Vue', 'Angular'])
    const user = userEvent.setup()
    render(<SearchResults onSearch={mockSearch} />)

    // Act: type a query and click search
    await user.type(screen.getByLabelText('Search'), 'framework')
    await user.click(screen.getByRole('button', { name: 'Search' }))

    // Assert: results appear in the list
    expect(await screen.findByText('React')).toBeInTheDocument()
    expect(screen.getByText('Vue')).toBeInTheDocument()
    expect(mockSearch).toHaveBeenCalledWith('framework')
  })

  it('shows loading state during search', async () => {
    // Create a promise that doesn't resolve immediately
    let resolveSearch: (value: string[]) => void
    const searchPromise = new Promise<string[]>((resolve) => {
      resolveSearch = resolve
    })
    const mockSearch = vi.fn().mockReturnValue(searchPromise)
    const user = userEvent.setup()
    render(<SearchResults onSearch={mockSearch} />)

    await user.type(screen.getByLabelText('Search'), 'test')
    await user.click(screen.getByRole('button', { name: 'Search' }))

    // Button shows loading state
    expect(screen.getByRole('button', { name: 'Searching...' })).toBeDisabled()

    // Resolve the search to clean up
    resolveSearch!([])
  })

  it('displays error message on search failure', async () => {
    const mockSearch = vi.fn().mockRejectedValue(new Error('Network error'))
    const user = userEvent.setup()
    render(<SearchResults onSearch={mockSearch} />)

    await user.type(screen.getByLabelText('Search'), 'query')
    await user.click(screen.getByRole('button', { name: 'Search' }))

    // Error message appears with alert role for screen readers
    expect(await screen.findByRole('alert')).toHaveTextContent(
      'Search failed. Please try again.'
    )
  })
})

Biblioteka userEvent symuluje realistyczne interakcje użytkownika, włącznie ze zdarzeniami klawiatury i zarządzaniem focusem, wykrywając błędy pomijane przez zdarzenia syntetyczne.

Wzorce testowania asynchronicznego i waitFor

Operacje asynchroniczne wymagają jawnych strategii oczekiwania. React Testing Library dostarcza zapytania findBy, które łączą getBy z automatycznym oczekiwaniem, oraz waitFor dla złożonych asercji.

Priorytet zapytań

Preferuj findBy nad waitFor + getBy podczas oczekiwania na pojawienie się elementów. Rezerwuj waitFor dla asercji na istniejących elementach, które zmieniają stan.

Typowe wzorce asynchroniczne pojawiają się podczas testowania hooków i komponentów pobierających dane:

async-patterns.test.tsxtypescript
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'

// Wait for element to appear (preferred for new elements)
it('loads user profile on mount', async () => {
  render(<UserProfile userId="123" />)
  
  // findBy returns a promise, automatically waits up to 1000ms
  const heading = await screen.findByRole('heading', { name: /john doe/i })
  expect(heading).toBeInTheDocument()
})

// Wait for element to disappear
it('removes loading spinner after data loads', async () => {
  render(<DataTable />)
  
  // Wait for spinner to be removed from DOM
  await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'))
  expect(screen.getByRole('table')).toBeInTheDocument()
})

// Wait for multiple conditions
it('enables submit button when form is valid', async () => {
  const user = userEvent.setup()
  render(<RegistrationForm />)

  const submitButton = screen.getByRole('button', { name: 'Submit' })
  expect(submitButton).toBeDisabled()

  await user.type(screen.getByLabelText('Email'), 'user@example.com')
  await user.type(screen.getByLabelText('Password'), 'SecurePass123!')

  // waitFor retries the assertion until it passes or times out
  await waitFor(() => {
    expect(submitButton).toBeEnabled()
  })
})

// Avoid false positives with queryBy for absence checks
it('does not show premium badge for free users', async () => {
  render(<UserCard subscription="free" />)
  
  // queryBy returns null instead of throwing, use for absence checks
  expect(screen.queryByText('Premium')).not.toBeInTheDocument()
})

Dostosowanie timeout pomaga przy wolniejszych operacjach bez spowalniania całego zestawu testów:

typescript
// Increase timeout for slow operations
await screen.findByText('Upload complete', {}, { timeout: 5000 })

await waitFor(
  () => expect(mockSubmit).toHaveBeenCalled(),
  { timeout: 3000, interval: 100 }
)

Gotowy na rozmowy o React / Next.js?

Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.

Strategie mockowania: moduły, API i hooki

Możliwości mockowania Vitest obsługują zewnętrzne zależności bez zanieczyszczania grafu modułów. Strategiczne mockowanie izoluje komponenty od wywołań sieciowych, bibliotek zewnętrznych i złożonych zależności.

Mockowanie modułów zastępuje importy na poziomie bundlera:

api.test.tstypescript
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { fetchUserData } from './api'

// Mock the entire fetch module
vi.mock('./http-client', () => ({
  httpClient: {
    get: vi.fn(),
  },
}))

import { httpClient } from './http-client'

describe('fetchUserData', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('transforms API response into user model', async () => {
    // Type-safe mock implementation
    vi.mocked(httpClient.get).mockResolvedValue({
      data: { id: 1, first_name: 'John', last_name: 'Doe' },
    })

    const user = await fetchUserData(1)

    expect(user).toEqual({
      id: 1,
      fullName: 'John Doe',
    })
  })
})

Dla hooków React zależnych od kontekstu lub stanu zewnętrznego, narzędzie renderHook umożliwia izolowane testowanie:

useAuth.test.tstypescript
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { useAuth } from './useAuth'
import { AuthProvider } from './AuthContext'

describe('useAuth', () => {
  it('returns authenticated user after login', async () => {
    const mockLogin = vi.fn().mockResolvedValue({ id: 1, name: 'Alice' })
    
    const { result } = renderHook(() => useAuth(), {
      wrapper: ({ children }) => (
        <AuthProvider loginFn={mockLogin}>{children}</AuthProvider>
      ),
    })

    expect(result.current.user).toBeNull()
    expect(result.current.isAuthenticated).toBe(false)

    await result.current.login('alice@example.com', 'password')

    await waitFor(() => {
      expect(result.current.user).toEqual({ id: 1, name: 'Alice' })
      expect(result.current.isAuthenticated).toBe(true)
    })
  })
})

MSW (Mock Service Worker) zapewnia mockowanie API na poziomie sieci, umożliwiając testy wykonujące rzeczywiste wywołania fetch:

handlers.tstypescript
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'Test User',
    })
  }),

  http.post('/api/login', async ({ request }) => {
    const body = await request.json()
    if (body.email === 'valid@example.com') {
      return HttpResponse.json({ token: 'abc123' })
    }
    return HttpResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    )
  }),
]

Testowanie Server Components w React 19

Server Components stanowią unikalne wyzwanie testowe, ponieważ wykonują się na serwerze i strumieniują HTML do klienta. Bezpośrednie testy jednostkowe wymagają innego podejścia niż testowanie komponentów klienckich.

Dla Server Components wykonujących pobieranie danych, należy testować wyjście renderowania z mockami źródeł danych:

ServerComponent.test.tsxtypescript
import { describe, it, expect, vi } from 'vitest'

// Mock the data fetching function
vi.mock('./db', () => ({
  getUser: vi.fn().mockResolvedValue({ id: 1, name: 'Server User' }),
}))

import { render, screen } from '@testing-library/react'
import { UserProfile } from './UserProfile.server'

describe('UserProfile Server Component', () => {
  it('renders user data from database', async () => {
    // Server components are async, await the component
    const Component = await UserProfile({ userId: '1' })
    render(Component)

    expect(screen.getByRole('heading')).toHaveTextContent('Server User')
  })
})

Testy integracyjne z Playwright zapewniają najbardziej niezawodne pokrycie dla Server Components, testując pełny pipeline renderowania:

e2e/server-component.spec.tstypescript
import { test, expect } from '@playwright/test'

test('server component renders with streamed data', async ({ page }) => {
  await page.goto('/users/1')
  
  // Wait for streaming to complete
  await expect(page.getByRole('heading', { name: /profile/i })).toBeVisible()
  await expect(page.getByText('Server User')).toBeVisible()
})

Testowanie custom hooków ze złożonym stanem

Custom hooki zarządzające złożonym stanem, efektami ubocznymi lub zewnętrznymi subskrypcjami korzystają z dedykowanych testów. Narzędzie renderHook z React Testing Library obsługuje cykl życia hooka i aktualizacje.

Przykład hooka debounced search koordynującego się z API:

useDebounceSearch.tstypescript
import { useState, useEffect, useCallback } from 'react'

export function useDebounceSearch<T>(
  searchFn: (query: string) => Promise<T[]>,
  delay = 300
) {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<T[]>([])
  const [isLoading, setIsLoading] = useState(false)

  useEffect(() => {
    if (!query) {
      setResults([])
      return
    }

    setIsLoading(true)
    const timeoutId = setTimeout(async () => {
      try {
        const data = await searchFn(query)
        setResults(data)
      } finally {
        setIsLoading(false)
      }
    }, delay)

    return () => clearTimeout(timeoutId)
  }, [query, searchFn, delay])

  return { query, setQuery, results, isLoading }
}

Testy weryfikują zachowanie debouncingu i przejścia stanów:

useDebounceSearch.test.tstypescript
import { renderHook, act, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useDebounceSearch } from './useDebounceSearch'

describe('useDebounceSearch', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  it('debounces search calls', async () => {
    const mockSearch = vi.fn().mockResolvedValue(['result1', 'result2'])
    const { result } = renderHook(() => useDebounceSearch(mockSearch, 300))

    // Type multiple characters quickly
    act(() => result.current.setQuery('r'))
    act(() => result.current.setQuery('re'))
    act(() => result.current.setQuery('rea'))
    act(() => result.current.setQuery('reac'))
    act(() => result.current.setQuery('react'))

    // Search not called yet (debouncing)
    expect(mockSearch).not.toHaveBeenCalled()

    // Advance timers past debounce delay
    await act(async () => {
      vi.advanceTimersByTime(300)
    })

    // Only one search call with final query
    expect(mockSearch).toHaveBeenCalledTimes(1)
    expect(mockSearch).toHaveBeenCalledWith('react')
  })

  it('shows loading state during search', async () => {
    let resolveSearch: (value: string[]) => void
    const mockSearch = vi.fn().mockImplementation(
      () => new Promise((resolve) => { resolveSearch = resolve })
    )
    const { result } = renderHook(() => useDebounceSearch(mockSearch, 100))

    act(() => result.current.setQuery('test'))
    
    await act(async () => {
      vi.advanceTimersByTime(100)
    })

    expect(result.current.isLoading).toBe(true)

    await act(async () => {
      resolveSearch(['result'])
    })

    expect(result.current.isLoading).toBe(false)
    expect(result.current.results).toEqual(['result'])
  })
})

Fałszywe timery z Vitest umożliwiają deterministyczne testowanie zachowań zależnych od czasu bez rzeczywistych opóźnień.

Pytania rekrutacyjne dotyczące testowania React

Rozmowy techniczne często badają wiedzę o testowaniu. Te pytania oceniają praktyczne zrozumienie, a nie koncepcje teoretyczne.

Częsty błąd

Należy unikać testowania szczegółów implementacji, takich jak wartości stanu czy metody komponentów. Testy, które psują się podczas refaktoryzacji wewnętrznej struktury, dają fałszywe poczucie pewności i obciążenie związane z utrzymaniem.

Priorytet zapytań w React Testing Library

Zalecany priorytet zapytań kieruje się dostępnością: getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId. Zapytania oparte na rolach zapewniają dostępność komponentów, a testy przetrwają zmiany w strukturze znaczników.

Kiedy używać findBy vs waitFor

Zapytania findBy czekają na pojawienie się elementów w DOM i zwracają obietnicę. Należy ich używać, oczekując nowych elementów po operacjach asynchronicznych. waitFor opakowuje asercje, które mogą nie przejść natychmiast, ponawiając próby aż do sukcesu lub timeout. Należy używać waitFor podczas asercji na zmianach stanu istniejących elementów.

Mockowanie fetch vs MSW

Bezpośrednie mockowanie fetch (vi.spyOn(global, 'fetch')) działa dla prostych przypadków, ale wymaga reimplementacji obiektów Response i obsługi błędów. MSW przechwytuje na poziomie sieci, pozwalając testom wykonywać rzeczywiste wywołania fetch, serializację żądań i ścieżki obsługi błędów.

Dla głębszej praktyki z tymi koncepcjami, warto zapoznać się z pytaniami rekrutacyjnymi React Testing na SharpSkill.

Podsumowanie

  • Konfiguracja Vitest ze środowiskiem jsdom, plikami setup dla rozszerzeń matcherów i raportowaniem pokrycia dla widoczności luk w pokryciu testowym
  • Zapytania o komponenty przez dostępne role i etykiety pozwalają pisać testy weryfikujące zachowanie widoczne dla użytkownika i wykrywające prawdziwe błędy
  • Używanie zapytań findBy dla elementów pojawiających się po operacjach asynchronicznych, waitFor dla asercji na zmieniającym się stanie
  • Mockowanie na odpowiednim poziomie: mocki modułów dla izolacji jednostkowej, MSW dla realistycznego testowania warstwy sieciowej
  • Testowanie custom hooków z renderHook i fałszywymi timerami w celu weryfikacji złożonego zarządzania stanem i efektów ubocznych
  • Server Components wymagają albo asynchronicznego renderowania w testach, albo pełnych testów integracyjnych z Playwright
  • Powiązana lektura: Zaawansowane wzorce React Hooks i TypeScript z React dla wzorców testowania z typami

Zacznij ćwiczyć!

Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.

Tagi

#react
#testing
#vitest
#react-testing-library

Udostępnij

Powiązane artykuły