Inicio / TypeScript / React: Frontend Moderno con TypeScript / Testing con Vitest y React Testing Library

Testing con Vitest y React Testing Library

Tests unitarios y de componentes con Vitest, RTL, user events y MSW.

Intermedio Web
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

Testing en React

El testing asegura que tu código funciona correctamente y no se rompe al hacer cambios. En React, las herramientas estándar son Vitest (test runner) y React Testing Library (testing de componentes).


Herramientas principales

Herramienta Propósito
Vitest Test runner rápido (compatible con Jest API)
React Testing Library (RTL) Testear componentes como lo haría un usuario
@testing-library/user-event Simular interacciones realistas
@testing-library/jest-dom Matchers adicionales para el DOM
MSW (Mock Service Worker) Interceptar peticiones HTTP en tests

Instalación

npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Configuración (vite.config.ts)

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
  },
})
// src/test/setup.ts
import '@testing-library/jest-dom'

Filosofía de React Testing Library

"Cuanto más se parezcan tus tests a cómo el usuario usa tu software, más confianza te darán."

  • ✅ Buscar elementos por texto, label, rol (como un usuario)
  • ❌ No buscar por clase CSS, ID o estructura interna (implementación)
  • ✅ Testear comportamiento (qué ve el usuario)
  • ❌ No testear implementación (estado interno, métodos privados)

Queries de RTL (cómo buscar elementos)

Query Uso Falla si no encuentra
getByText('Hola') Texto visible ✅ Sí
getByRole('button', { name: 'Enviar' }) Rol accesible ✅ Sí
getByLabelText('Email') Label de formulario ✅ Sí
getByPlaceholderText('Buscar...') Placeholder ✅ Sí
getByTestId('my-id') data-testid (último recurso) ✅ Sí
queryByText('Hola') Igual pero retorna null si no existe ❌ No
findByText('Hola') Async: espera a que aparezca ✅ Sí (await)

Prioridad recomendada

  1. getByRole — El más accesible y robusto
  2. getByLabelText — Para inputs de formulario
  3. getByText — Para texto estático
  4. getByPlaceholderText — Alternativa para inputs
  5. getByTestId — Último recurso cuando no hay otra opción

Test unitario: funciones puras

// utils/format.ts
export function formatPrice(amount: number): string {
  return `$${amount.toFixed(2)}`
}

export function capitalize(str: string): string {
  if (!str) return ''
  return str.charAt(0).toUpperCase() + str.slice(1)
}

// utils/format.test.ts
import { formatPrice, capitalize } from './format'

describe('formatPrice', () => {
  it('formatea un número como precio', () => {
    expect(formatPrice(29.9)).toBe('$29.90')
  })

  it('maneja cero', () => {
    expect(formatPrice(0)).toBe('$0.00')
  })

  it('maneja números grandes', () => {
    expect(formatPrice(1234.5)).toBe('$1234.50')
  })
})

describe('capitalize', () => {
  it('capitaliza la primera letra', () => {
    expect(capitalize('react')).toBe('React')
  })

  it('retorna string vacío para input vacío', () => {
    expect(capitalize('')).toBe('')
  })
})

Test de componente: renderizado

// Greeting.tsx
function Greeting({ name }: { name: string }) {
  return <h1>Hola, {name}!</h1>
}

// Greeting.test.tsx
import { render, screen } from '@testing-library/react'
import Greeting from './Greeting'

describe('Greeting', () => {
  it('muestra el saludo con el nombre', () => {
    render(<Greeting name="Ana" />)

    expect(screen.getByText('Hola, Ana!')).toBeInTheDocument()
  })

  it('renderiza como heading', () => {
    render(<Greeting name="Bob" />)

    expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Hola, Bob!')
  })
})

Test de interacción: user events

// Counter.tsx
function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <span data-testid="count">{count}</span>
      <button onClick={() => setCount(c => c + 1)}>Incrementar</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  )
}

// Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Counter from './Counter'

describe('Counter', () => {
  it('comienza en 0', () => {
    render(<Counter />)
    expect(screen.getByTestId('count')).toHaveTextContent('0')
  })

  it('incrementa al hacer clic', async () => {
    const user = userEvent.setup()
    render(<Counter />)

    await user.click(screen.getByRole('button', { name: 'Incrementar' }))
    expect(screen.getByTestId('count')).toHaveTextContent('1')

    await user.click(screen.getByRole('button', { name: 'Incrementar' }))
    expect(screen.getByTestId('count')).toHaveTextContent('2')
  })

  it('resetea a 0', async () => {
    const user = userEvent.setup()
    render(<Counter />)

    await user.click(screen.getByRole('button', { name: 'Incrementar' }))
    await user.click(screen.getByRole('button', { name: 'Incrementar' }))
    await user.click(screen.getByRole('button', { name: 'Reset' }))

    expect(screen.getByTestId('count')).toHaveTextContent('0')
  })
})

Test de formularios

// LoginForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

describe('LoginForm', () => {
  it('envía el formulario con email y password', async () => {
    const onSubmit = vi.fn()
    const user = userEvent.setup()

    render(<LoginForm onSubmit={onSubmit} />)

    await user.type(screen.getByLabelText('Email'), 'ana@mail.com')
    await user.type(screen.getByLabelText('Contraseña'), 'secret123')
    await user.click(screen.getByRole('button', { name: 'Entrar' }))

    expect(onSubmit).toHaveBeenCalledWith({
      email: 'ana@mail.com',
      password: 'secret123',
    })
  })

  it('muestra error si el email está vacío', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={vi.fn()} />)

    await user.click(screen.getByRole('button', { name: 'Entrar' }))

    expect(screen.getByText('Email es requerido')).toBeInTheDocument()
  })
})

Test con datos asíncronos

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

// Mock del fetch global
beforeEach(() => {
  vi.spyOn(global, 'fetch').mockResolvedValue({
    ok: true,
    json: async () => ({ id: 1, name: 'Ana García', email: 'ana@mail.com' }),
  } as Response)
})

afterEach(() => {
  vi.restoreAllMocks()
})

describe('UserProfile', () => {
  it('muestra loading y luego los datos del usuario', async () => {
    render(<UserProfile userId={1} />)

    // Primero muestra loading
    expect(screen.getByText('Cargando...')).toBeInTheDocument()

    // Luego aparece el nombre (findBy espera hasta que aparezca)
    expect(await screen.findByText('Ana García')).toBeInTheDocument()
    expect(screen.queryByText('Cargando...')).not.toBeInTheDocument()
  })
})

Mock Service Worker (MSW)

MSW intercepta peticiones HTTP a nivel de red, sin mockear fetch:

import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'

const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: Number(params.id),
      name: 'Ana García',
      email: 'ana@mail.com',
    })
  }),

  http.post('/api/login', async ({ request }) => {
    const body = await request.json()
    if (body.email === 'ana@mail.com') {
      return HttpResponse.json({ token: 'fake-jwt' })
    }
    return HttpResponse.json({ error: 'Invalid' }, { status: 401 })
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

Testing de hooks

import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('inicia en 0 por defecto', () => {
    const { result } = renderHook(() => useCounter())
    expect(result.current.count).toBe(0)
  })

  it('incrementa correctamente', () => {
    const { result } = renderHook(() => useCounter())

    act(() => {
      result.current.increment()
    })

    expect(result.current.count).toBe(1)
  })

  it('acepta valor inicial', () => {
    const { result } = renderHook(() => useCounter(10))
    expect(result.current.count).toBe(10)
  })
})

Resumen

Tipo de test Herramienta Qué testea
Unitario Vitest Funciones puras, utils, lógica
Componente RTL + Vitest Renderizado, interacción, formularios
Hook renderHook Custom hooks aislados
Integración RTL + MSW Componente con llamadas HTTP
E2E Playwright / Cypress Flujo completo en navegador real
🔒

Ejercicio práctico disponible

Implementa un mini framework de testing

Desbloquear ejercicios
// Implementa un mini framework de testing
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes