Testing en React 2026: Vitest, React Testing Library y Mejores Prácticas

Dominar el testing en React con Vitest y React Testing Library. Aprender patrones de testing de componentes, manejo asíncrono, estrategias de mocking y mejores prácticas para entrevistas técnicas en 2026.

Diagrama de testing en React 2026 con Vitest y React Testing Library

El testing en React durante 2026 se centra en Vitest como el ejecutor de tests dominante y React Testing Library (RTL) como el estándar para testing de componentes. Esta combinación ofrece ciclos de retroalimentación rápidos, soporte nativo de módulos ES y tests que verifican comportamiento en lugar de detalles de implementación.

Filosofía de Testing

Escribir tests que se asemejen a cómo los usuarios interactúan con los componentes. Consultar por roles accesibles, etiquetas y texto—no por clases CSS o atributos data-testid. Este enfoque detecta bugs reales y sobrevive al refactoring.

Configuración de Vitest para Proyectos React

Vitest reemplazó a Jest como el ejecutor de tests preferido para aplicaciones React modernas. El soporte nativo de módulos ES elimina la sobrecarga de transformación, y la integración estrecha con Vite significa que la configuración refleja el entorno de desarrollo.

Una configuración de Vitest lista para producción maneja la transformación JSX, alias de rutas y configuración del entorno de testing sin 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/**'],
    },
  },
})

El archivo de configuración extiende los matchers y configura la limpieza entre 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(),
  })),
})

Esta configuración proporciona una base limpia para cada test y maneja los mocks comunes de APIs del navegador que jsdom no posee.

Testing de Componentes con React Testing Library

React Testing Library impone probar los componentes desde la perspectiva del usuario. En lugar de inspeccionar el estado interno o valores de props, los tests interactúan con la salida renderizada a través de consultas de accesibilidad.

Consideremos un componente de búsqueda que filtra resultados y muestra estados de carga:

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

Los tests verifican el comportamiento del componente a través de interacciones del usuario y estados asíncronos:

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

La biblioteca userEvent simula interacciones de usuario realistas incluyendo eventos de teclado y gestión de foco, detectando bugs que los eventos sintéticos omiten.

Patrones de Testing Asíncrono y waitFor

Las operaciones asíncronas requieren estrategias de espera explícitas. React Testing Library proporciona consultas findBy que combinan getBy con espera automática, más waitFor para aserciones complejas.

Prioridad de Consultas

Preferir findBy sobre waitFor + getBy cuando se espera que aparezcan elementos. Reservar waitFor para aserciones sobre elementos existentes que cambian de estado.

Los patrones asíncronos comunes aparecen al probar hooks y componentes de obtención de datos:

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

La personalización de timeouts ayuda con operaciones lentas sin ralentizar toda la suite:

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

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

¿Listo para aprobar tus entrevistas de React / Next.js?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Estrategias de Mocking: Módulos, APIs y Hooks

Las capacidades de mocking de Vitest manejan dependencias externas sin contaminar el grafo de módulos. El mocking estratégico aísla los componentes de llamadas de red, bibliotecas de terceros y dependencias complejas.

El mocking de módulos reemplaza imports a nivel del bundler:

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

Para los hooks de React que dependen de contexto o estado externo, el utilitario renderHook permite testing aislado:

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) proporciona mocking de APIs a nivel de red, permitiendo tests que ejercitan llamadas fetch reales:

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

Testing de Server Components en React 19

Los Server Components presentan desafíos de testing únicos ya que se ejecutan en el servidor y transmiten HTML al cliente. El testing unitario directo requiere un enfoque diferente al testing de componentes cliente.

Para Server Components que realizan obtención de datos, probar la salida del renderizado con fuentes de datos mockeadas:

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

Los tests de integración con Playwright proporcionan la cobertura más confiable para Server Components al probar el pipeline de renderizado completo:

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

Testing de Custom Hooks con Estado Complejo

Los hooks personalizados que gestionan estado complejo, efectos secundarios o suscripciones externas se benefician de tests dedicados. El utilitario renderHook de React Testing Library maneja el ciclo de vida y actualizaciones de los hooks.

Consideremos un hook de búsqueda con debounce que se coordina con una 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 }
}

Los tests verifican el comportamiento del debounce y las transiciones de estado:

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

Los fake timers de Vitest permiten testing determinístico del comportamiento dependiente del tiempo sin demoras reales.

Preguntas de Entrevista sobre Testing en React

Las entrevistas técnicas frecuentemente indagan sobre conocimientos de testing. Estas preguntas evalúan la comprensión práctica en lugar de conceptos teóricos.

Error Común

Evitar probar detalles de implementación como valores de estado o métodos de componentes. Los tests que fallan al refactorizar la estructura interna proporcionan falsa confianza y carga de mantenimiento.

Prioridad de consultas en React Testing Library

La prioridad de consulta recomendada sigue la accesibilidad: getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId. Las consultas basadas en roles aseguran que los componentes permanezcan accesibles y los tests sobrevivan a cambios de markup.

Cuándo usar findBy vs waitFor

Las consultas findBy esperan a que los elementos aparezcan en el DOM y devuelven una promesa. Usarlas cuando se esperan nuevos elementos después de operaciones asíncronas. waitFor envuelve aserciones que pueden no pasar inmediatamente, reintentando hasta el éxito o timeout. Usar waitFor para aserciones sobre cambios de estado en elementos existentes.

Mocking de fetch vs MSW

El mocking directo de fetch (vi.spyOn(global, 'fetch')) funciona para casos simples pero requiere reimplementar objetos Response y manejo de errores. MSW intercepta a nivel de red, permitiendo a los tests ejercitar llamadas fetch reales, serialización de solicitudes y rutas de código de manejo de errores.

Para práctica más profunda con estos conceptos, explorar las preguntas de entrevista de React Testing en SharpSkill.

Conclusión

  • Configurar Vitest con entorno jsdom, archivos de configuración para extensiones de matchers y reportes de cobertura para visibilidad sobre brechas de cobertura de tests
  • Consultar componentes por roles y etiquetas accesibles para escribir tests que verifican comportamiento orientado al usuario y detectan bugs reales
  • Usar consultas findBy para elementos que aparecen después de operaciones asíncronas, waitFor para aserciones sobre estado cambiante
  • Mockear al nivel apropiado: mocks de módulos para aislamiento unitario, MSW para testing realista de la capa de red
  • Probar hooks personalizados con renderHook y fake timers para verificar gestión de estado complejo y efectos secundarios
  • Los Server Components requieren renderizado asíncrono en tests o tests de integración completos con Playwright
  • Lectura relacionada: Patrones avanzados de React Hooks y TypeScript con React para patrones de testing type-safe

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

Etiquetas

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

Compartir

Artículos relacionados