Тестування React у 2026 році: Vitest, React Testing Library та найкращі практики

Повний посібник з тестування React-додатків за допомогою Vitest та React Testing Library. Патерни тестування компонентів, робота з асинхронними операціями, стратегії мокування та практики для технічних співбесід.

Діаграма робочого процесу тестування React у 2026 році з Vitest та React Testing Library

Тестування React у 2026 році зосереджується на Vitest як домінуючому інструменті запуску тестів та React Testing Library (RTL) як стандарті тестування компонентів. Ця комбінація забезпечує швидкі цикли зворотного зв'язку, нативну підтримку ES-модулів та тести, що перевіряють поведінку замість деталей реалізації.

Філософія тестування

Тести повинні відображати спосіб взаємодії користувачів з компонентами. Запити слід виконувати за доступними ролями, мітками та текстом — а не за CSS-класами чи атрибутами data-testid. Такий підхід виявляє справжні помилки та витримує рефакторинг.

Конфігурація Vitest для React-проєктів

Vitest замінив Jest як переважний раннер тестів для сучасних React-додатків. Нативна підтримка ES-модулів усуває накладні витрати на трансформацію, а тісна інтеграція з Vite означає, що конфігурація відображає середовище розробки.

Продакшн-готова конфігурація Vitest обробляє трансформацію JSX, псевдоніми шляхів та налаштування тестового середовища без зайвого шаблонного коду:

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/**'],
    },
  },
})

Файл налаштування розширює матчери та конфігурує очищення між тестами:

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(),
  })),
})

Це налаштування забезпечує чистий стан для кожного тесту та обробляє типові моки API браузера, яких немає в jsdom.

Тестування компонентів з React Testing Library

React Testing Library вимагає тестування компонентів з перспективи користувача. Замість інспекції внутрішнього стану чи значень пропсів, тести взаємодіють з відрендереним виводом через запити доступності.

Приклад компонента пошуку, що фільтрує результати та відображає стан завантаження:

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>
  )
}

Тести перевіряють поведінку компонента під час взаємодії користувача та асинхронних станів:

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.'
    )
  })
})

Бібліотека userEvent симулює реалістичні взаємодії користувача, включаючи події клавіатури та керування фокусом, виявляючи помилки, які пропускають синтетичні події.

Патерни асинхронного тестування та waitFor

Асинхронні операції вимагають явних стратегій очікування. React Testing Library надає запити findBy, що поєднують getBy з автоматичним очікуванням, та waitFor для складних асерцій.

Пріоритет запитів

Надавайте перевагу findBy над waitFor + getBy при очікуванні появи елементів. Резервуйте waitFor для асерцій на існуючих елементах, що змінюють стан.

Типові асинхронні патерни з'являються при тестуванні хуків та компонентів, що отримують дані:

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()
})

Налаштування таймаутів допомагає з повільнішими операціями без уповільнення всього набору тестів:

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

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

Готовий до співбесід з React / Next.js?

Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.

Стратегії мокування: модулі, API та хуки

Можливості мокування Vitest обробляють зовнішні залежності без забруднення графа модулів. Стратегічне мокування ізолює компоненти від мережевих викликів, сторонніх бібліотек та складних залежностей.

Мокування модулів замінює імпорти на рівні бандлера:

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',
    })
  })
})

Для React-хуків, що залежать від контексту або зовнішнього стану, утиліта renderHook дозволяє ізольоване тестування:

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) забезпечує мокування API на рівні мережі, дозволяючи тестам виконувати реальні 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 }
    )
  }),
]

Тестування Server Components у React 19

Server Components становлять унікальні виклики для тестування, оскільки виконуються на сервері та стрімлять HTML клієнту. Пряме модульне тестування вимагає іншого підходу, ніж тестування клієнтських компонентів.

Для Server Components, що виконують отримання даних, слід тестувати вивід рендерингу з замоканими джерелами даних:

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')
  })
})

Інтеграційні тести з Playwright забезпечують найнадійніше покриття для Server Components, тестуючи повний пайплайн рендерингу:

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()
})

Тестування кастомних хуків зі складним станом

Кастомні хуки, що керують складним станом, побічними ефектами або зовнішніми підписками, виграють від спеціалізованих тестів. Утиліта renderHook з React Testing Library обробляє життєвий цикл хука та оновлення.

Приклад хука debounced search, що координується з 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 }
}

Тести перевіряють поведінку debouncing та переходи станів:

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'])
  })
})

Фейкові таймери з Vitest дозволяють детерміністичне тестування поведінки, залежної від часу, без реальних затримок.

Питання технічних співбесід щодо тестування React

Технічні співбесіди часто досліджують знання тестування. Ці питання оцінюють практичне розуміння, а не теоретичні концепції.

Поширена помилка

Слід уникати тестування деталей реалізації, таких як значення стану чи методи компонентів. Тести, що ламаються при рефакторингу внутрішньої структури, дають хибну впевненість та навантаження на супровід.

Пріоритет запитів у React Testing Library

Рекомендований пріоритет запитів слідує доступності: getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId. Запити на основі ролей забезпечують доступність компонентів, а тести витримують зміни розмітки.

Коли використовувати findBy vs waitFor

Запити findBy чекають на появу елементів у DOM та повертають проміс. Їх слід використовувати, очікуючи нові елементи після асинхронних операцій. waitFor обгортає асерції, що можуть не пройти одразу, повторюючи спроби до успіху або таймауту. Слід використовувати waitFor при асерціях на зміни стану існуючих елементів.

Мокування fetch vs MSW

Пряме мокування fetch (vi.spyOn(global, 'fetch')) працює для простих випадків, але вимагає реімплементації об'єктів Response та обробки помилок. MSW перехоплює на рівні мережі, дозволяючи тестам виконувати реальні fetch-виклики, серіалізацію запитів та шляхи обробки помилок.

Для глибшої практики з цими концепціями варто ознайомитися з питаннями співбесід React Testing на SharpSkill.

Підсумок

  • Налаштування Vitest із середовищем jsdom, файлами setup для розширення матчерів та звітуванням покриття для видимості прогалин у тестовому покритті
  • Запити до компонентів за доступними ролями та мітками дозволяють писати тести, що перевіряють поведінку, видиму користувачу, та виявляють справжні помилки
  • Використання запитів findBy для елементів, що з'являються після асинхронних операцій, waitFor для асерцій на змінюваному стані
  • Мокування на відповідному рівні: моки модулів для модульної ізоляції, MSW для реалістичного тестування мережевого рівня
  • Тестування кастомних хуків з renderHook та фейковими таймерами для перевірки складного управління станом та побічних ефектів
  • Server Components вимагають або асинхронного рендерингу в тестах, або повних інтеграційних тестів з Playwright
  • Пов'язане читання: Просунуті патерни React Hooks та TypeScript з React для типобезпечних патернів тестування

Починай практикувати!

Перевір свої знання з нашими симуляторами співбесід та технічними тестами.

Теги

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

Поділитися

Пов'язані статті