React Testing in 2026: Vitest, React Testing Library en Best Practices

Professionele strategieën voor React testing in 2026 met Vitest als test runner en React Testing Library voor gedragsgebaseerde componenttests.

React Testing in 2026: Vitest, React Testing Library en Best Practices

React testing in 2026 draait om Vitest als dominante test runner en React Testing Library (RTL) als standaard voor het testen van componenten. Deze combinatie biedt snelle feedbackloops, native ES module-ondersteuning en tests die gedrag verifiëren in plaats van implementatiedetails.

Testing Filosofie

Tests dienen geschreven te worden op een manier die simuleert hoe gebruikers met componenten omgaan. Query's op toegankelijke rollen, labels en tekst zijn zinvoller dan CSS-klassen of data-testid attributen. Deze aanpak detecteert echte bugs en overleeft refactoring.

Vitest Configuratie voor React Projecten

Vitest heeft Jest vervangen als voorkeurstest runner voor moderne React applicaties. Native ES module-ondersteuning elimineert transformatie-overhead, en de nauwe Vite-integratie betekent dat de configuratie de ontwikkelomgeving weerspiegelt.

Een productierijpe Vitest configuratie verwerkt JSX transformatie, pad-aliassen en testomgeving setup zonder 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/**'],
    },
  },
})

Het setup-bestand breidt matchers uit en configureert opruiming tussen tests:

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

Deze setup biedt een schone basis voor elke test en behandelt veelvoorkomende browser API mocks die jsdom niet levert.

Component Testen met React Testing Library

React Testing Library dwingt het testen van componenten vanuit het perspectief van de gebruiker af. In plaats van interne state of prop-waarden te inspecteren, interageren tests met de gerenderde output via toegankelijkheidsquery's.

Een zoekcomponent filtert resultaten en toont laadstaten:

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

Tests verifiëren het gedrag van het component over gebruikersinteracties en asynchrone states heen:

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

De userEvent bibliotheek simuleert realistische gebruikersinteracties inclusief toetsenbordgebeurtenissen en focusbeheer, en detecteert bugs die synthetische events missen.

Asynchrone Test Patronen en waitFor

Asynchrone operaties vereisen expliciete wachtstrategieën. React Testing Library biedt findBy query's die getBy combineren met automatisch wachten, plus waitFor voor complexe assertions.

Query Prioriteit

Geef de voorkeur aan findBy boven waitFor + getBy bij het wachten op elementen die verschijnen. Reserveer waitFor voor assertions op bestaande elementen die van state veranderen.

Veelvoorkomende asynchrone patronen verschijnen bij het testen van data-fetching hooks en componenten:

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

Timeout-aanpassing helpt bij langzamere operaties zonder de hele testsuite te vertragen:

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

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

Klaar om je React / Next.js gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Mocking Strategieën: Modules, API's en Hooks

De mocking-mogelijkheden van Vitest behandelen externe afhankelijkheden zonder de modulegraph te vervuilen. Strategische mocking isoleert componenten van netwerkaanroepen, third-party bibliotheken en complexe afhankelijkheden.

Module mocking vervangt imports op bundlerniveau:

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

Voor React hooks die afhankelijk zijn van context of externe state, maakt de renderHook utility geïsoleerde tests mogelijk:

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) biedt API mocking op netwerkniveau, waardoor tests daadwerkelijke fetch-aanroepen kunnen uitvoeren:

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

React 19 Server Components Testen

Server Components bieden unieke testuitdagingen omdat ze op de server draaien en HTML naar de client streamen. Direct unit testen vereist een andere aanpak dan client component testing.

Voor Server Components die data ophalen, test men de rendering output met gemockte databronnen:

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

Integratietests met Playwright bieden de meest betrouwbare dekking voor Server Components door de volledige rendering pipeline te testen:

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

Custom Hooks Testen met Complexe State

Custom hooks die complexe state, side effects of externe subscriptions beheren, profiteren van dedicated tests. De renderHook utility van React Testing Library behandelt hook lifecycle en updates.

Een debounced search hook coördineert met een 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 }
}

Tests verifiëren debouncing-gedrag en state-overgangen:

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

Fake timers van Vitest maken deterministisch testen van tijdsafhankelijk gedrag mogelijk zonder daadwerkelijke vertragingen.

Sollicitatievragen over React Testing

Technische sollicitatiegesprekken onderzoeken vaak testkennis. Deze vragen beoordelen praktisch begrip in plaats van theoretische concepten.

Veelgemaakte Fout

Vermijd het testen van implementatiedetails zoals state-waarden of componentmethoden. Tests die breken bij het refactoren van interne structuur bieden vals vertrouwen en onderhoudslast.

Query prioriteit in React Testing Library

De aanbevolen query-prioriteit volgt toegankelijkheid: getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId. Rolgebaseerde query's zorgen ervoor dat componenten toegankelijk blijven en tests markup-wijzigingen overleven.

Wanneer findBy vs waitFor gebruiken

findBy query's wachten tot elementen in de DOM verschijnen en retourneren een Promise. Ze worden gebruikt wanneer nieuwe elementen na asynchrone operaties worden verwacht. waitFor wrapt assertions die mogelijk niet direct slagen, en herhaalt tot succes of timeout. waitFor wordt gebruikt bij het testen van state-veranderingen op bestaande elementen.

Fetch mocken vs MSW

Directe fetch mocking (vi.spyOn(global, 'fetch')) werkt voor eenvoudige gevallen maar vereist herimplementatie van Response objecten en error handling. MSW onderschept op netwerkniveau, waardoor tests daadwerkelijke fetch-aanroepen, request serialisatie en error handling codepaden kunnen uitvoeren.

Voor diepere oefening met deze concepten bieden de React Testing sollicitatievragen op SharpSkill uitstekende praktijkoefeningen.

Conclusie

  • Configureer Vitest met jsdom-omgeving, setup-bestanden voor matcher-uitbreidingen en coverage-rapportage voor zichtbaarheid in testdekkingsgaten
  • Query componenten op toegankelijke rollen en labels om tests te schrijven die gebruikersgerichte gedrag verifiëren en echte bugs detecteren
  • Gebruik findBy query's voor elementen die na asynchrone operaties verschijnen, waitFor voor assertions op veranderende state
  • Mock op het juiste niveau: module mocks voor unit isolatie, MSW voor realistische netwerklaag testing
  • Test custom hooks met renderHook en fake timers om complex state management en side effects te verifiëren
  • Server Components vereisen ofwel asynchrone rendering in tests of volledige integratietests met Playwright
  • Gerelateerde lectuur: Geavanceerde React Hooks patronen en TypeScript met React voor type-safe testing patronen

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Tags

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

Delen

Gerelateerde artikelen