Estrategia de testing y mejores prácticas
Elige los tipos de test adecuados, fija objetivos de cobertura que importen, construye un pipeline de CI y elimina los tests inestables.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
Testing Trophy (modelo de Kent C. Dodds para apps React):
┌──────────┐
│ E2E │ Pocos flujos críticos
┌─┴──────────┴─┐
│ Integration │ La mayoría de tests aquí
┌─┴──────────────┴─┐
│ Unit Tests │ Funciones de utilidad, hooks
┌─┴──────────────────┴─┐
│ Static Analysis │ TypeScript, ESLint
└──────────────────────┘
# Test strategy decision matrix
What to test:
- User-visible behavior (renders, interactions, navigation)
- Business logic (calculations, validation, state transitions)
- Edge cases (empty states, errors, loading, boundary values)
- Regression bugs (write a test for every bug fix)
What NOT to test:
- Implementation details (internal state, private methods)
- Third-party library internals
- CSS styling (unless visual regression matters)
- Trivial code (simple prop passthrough components)Cuándo usarlo: Antes de escribir cualquier test — planifica tu estrategia de testing según el tamaño del proyecto, el equipo y la tolerancia al riesgo.
Ejemplo funcional
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
# Stage 1: Fast checks (run on every push)
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
# Stage 2: Unit and integration tests (run on every push)
unit-tests:
runs-on: ubuntu-latest
needs: lint-and-typecheck
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run test:run -- --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
# Stage 3: E2E tests (run on PRs to main and merges)
e2e-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Build application
run: npm run build
- name: Run E2E tests
run: npx playwright test --project=chromium
env:
CI: true
- name: Upload test results
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 7
# Stage 4: Deploy (only on main)
deploy:
runs-on: ubuntu-latest
needs: [unit-tests, e2e-tests]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: echo "Deploy step here"// scripts de package.json
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}Qué demuestra esto:
- Pipeline de CI en 4 etapas: lint/typecheck (rápido), tests unitarios, tests E2E, despliegue
- Los tests E2E se ejecutan contra un build de producción
- Artefactos subidos para depurar fallos
- Generación de informe de cobertura
- E2E solo se ejecuta en CI con un solo navegador por velocidad
Profundización
Cómo funciona
- Análisis estático (TypeScript + ESLint) detecta errores de tipos y problemas de calidad de código al instante — sin coste en tiempo de ejecución
- Tests unitarios se ejecutan en milisegundos por test — prueban funciones puras, hooks personalizados y módulos de utilidad
- Tests de integración (Testing Library) renderizan componentes reales con componentes hijos reales — prueban comportamiento visible para el usuario
- Tests E2E (Playwright) se ejecutan en navegadores reales contra una app en marcha — prueban recorridos completos del usuario entre páginas
- El pipeline de CI ejecuta primero las comprobaciones rápidas y deja las lentas detrás — falla rápido, ahorra cómputo
Cuándo usar cada tipo de test
| Tipo de test | Velocidad | Confianza | Usar para |
|---|---|---|---|
| TypeScript | instantáneo | detecta errores de tipos | Todo el código |
| Unitario | rápido (menos de 10 ms cada uno) | corrección de la lógica | Funciones puras, hooks, utilidades, stores de Zustand |
| Integración | medio (menos de 100 ms cada uno) | comportamiento del componente | Formularios, listas, componentes interactivos |
| E2E | lento (segundos cada uno) | el sistema completo funciona | Flujos de login, checkout, rutas críticas |
Directrices de cobertura
// vitest.config.ts -- practical coverage thresholds
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: {
// Set meaningful thresholds, not 100%
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.stories.{ts,tsx}",
"src/**/*.d.ts",
"src/app/**/layout.tsx",
"src/app/**/loading.tsx",
"src/app/**/not-found.tsx",
],
},
},
});Cuándo la cobertura miente:
- El 100 % de cobertura de líneas no significa que el 100 % de los comportamientos estén probados
- La cobertura mide qué líneas se ejecutaron, no qué aserciones pasaron
- Un test que renderiza un componente y no aserta nada sigue aumentando la cobertura
- Céntrate en probar rutas críticas y casos límite, no en alcanzar un número
Convenciones de nombres de tests
// Pattern: describe(unit) > it(behavior)
describe("CartStore", () => {
it("adds item to empty cart");
it("increments quantity for duplicate items");
it("removes item by id");
it("calculates total with mixed quantities");
});
// Pattern: should/when for complex behaviors
describe("CheckoutForm", () => {
it("submits order when all fields are valid");
it("shows validation error when email is missing");
it("disables submit button while processing");
});
// Avoid:
it("works"); // too vague
it("test addItem"); // do not start with "test"
it("should call setCount with count + 1"); // tests implementationCómo tratar tests inestables
| Síntoma | Causa | Solución |
|---|---|---|
| El test pasa en local y falla en CI | Diferencias de timing, variables de entorno faltantes | Usa waitFor en lugar de pausas fijas; revisa la config de entorno |
| El test falla de forma intermitente | Condiciones de carrera en código asíncrono | Añade aserciones adecuadas con reintento automático |
| El test falla solo en la primera ejecución | State de un test anterior que se filtra | Reinicia el state en beforeEach; usa contextos de navegador aislados |
| El test falla con "element not found" | El elemento aún no se ha renderizado | Usa consultas findBy o waitFor |
| Falla un test de captura de pantalla | Diferencias de renderizado de fuentes entre SO | Ejecuta tests visuales en Docker o limita a una plataforma |
Variaciones
CI mínima para proyectos pequeños:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run test:runHooks de pre-commit para feedback rápido:
// package.json (con lint-staged)
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "vitest related --run"]
}
}Notas de TypeScript
// TypeScript IS a testing tool -- strict mode catches bugs before tests
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
// These TypeScript errors replace entire categories of tests:
// - null/undefined access tests (strictNullChecks)
// - wrong argument type tests (strict function types)
// - missing property tests (exact optional property types)Errores comunes
-
Probar detalles de implementación — Aserciones sobre state interno, clases CSS o conteos de llamadas a mocks acoplan los tests a la estructura del código. Solución: Prueba lo que el usuario ve y hace. Si el usuario no puede observarlo, el test probablemente no debería asertarlo.
-
Demasiados tests E2E — Los tests E2E son lentos, caros de mantener y propensos a inestabilidad. Solución: Cubre rutas críticas (login, checkout, registro) con E2E. Usa tests de integración para todo lo demás.
-
Perseguir el 100 % de cobertura — Los equipos gastan un esfuerzo desproporcionado probando código trivial para alcanzar objetivos arbitrarios. Solución: Fija umbrales de cobertura al 75-85 %. Enfoca el esfuerzo manual en probar código crítico para el negocio.
-
No ejecutar tests en CI — Los tests que solo corren en local acaban rompiéndose sin que nadie lo note. Solución: Configura CI desde el primer día. Bloquea merges ante fallos de tests.
-
Ignorar tests inestables — Marcar tests inestables como
skipoculta bugs reales. Solución: Corrige la causa raíz (normalmente timing o filtrado de state). Pon en cuarentena los tests inestables en un job separado si hace falta. -
Probar bibliotecas de terceros — Escribir tests que verifican que tu biblioteca de UI renderiza un desplegable correctamente desperdicia tiempo. Solución: Confía en que las bibliotecas están probadas. Prueba tu uso de ellas (¿pasas los props correctos, manejas los callbacks?).
Alternativas
| Alternativa | Usar cuando | No usar cuando |
|---|---|---|
| Testing Trophy | Construyes apps React con mucha integración de componentes | Construyes una biblioteca de utilidades (entonces dominan los unitarios) |
| Testing Pyramid | Tienes una app tradicional con backend pesado y UI delgada | Tu app es mayormente frontend con llamadas a API |
| Sin E2E, solo integración | Proyecto pequeño, iteración rápida, sin flujos críticos de pago | Manejas dinero, autenticación o datos sensibles |
| Contract testing (Pact) | Microservicios con muchos equipos y consumidores de API | Monolito o un solo equipo |
FAQs
¿Qué es el Testing Trophy y en qué se diferencia del Testing Pyramid?
- El Testing Trophy (Kent C. Dodds) enfatiza tests de integración para apps React, con menos unitarios y E2E.
- El Testing Pyramid enfatiza muchos tests unitarios en la base.
- Usa el Trophy para apps con frontend pesado; usa la Pyramid para apps con backend pesado.
¿Qué NO debería probar?
- Detalles de implementación (state interno, métodos privados)
- Internos de bibliotecas de terceros
- Estilos CSS (salvo que importe la regresión visual)
- Código trivial (componentes simples de paso de props)
¿Cuál es un umbral de cobertura práctico?
Fija umbrales al 75-85 % para líneas, funciones y ramas. No persigas el 100 % — la cobertura mide qué líneas se ejecutaron, no qué aserciones son significativas.
Trampa: ¿El 100 % de cobertura de código significa que mi app está totalmente probada?
No. Un test que renderiza un componente y no aserta nada sigue aumentando la cobertura. La cobertura mide ejecución de líneas, no verificación de comportamiento. Céntrate en probar rutas críticas y casos límite.
¿Cómo debería nombrar mis tests?
Usa describe(unidad) > it(comportamiento):
describe("CartStore", () => {
it("adds item to empty cart");
it("increments quantity for duplicate items");
});Evita nombres vagos como "works" o nombres de detalles de implementación.
¿Qué causa tests inestables y cómo los corrijo?
- Problemas de timing: usa
waitForen lugar de pausas fijas. - Filtrado de state: reinicia el state en
beforeEach. - Elemento no encontrado: usa consultas
findBy. - Nunca marques tests inestables como
skipsin corregir la causa raíz.
¿Cuándo debería usar tests E2E frente a tests de integración?
- E2E: rutas críticas del usuario (login, checkout, registro) que necesitan comportamiento real del navegador.
- Integración: comportamiento de componentes, interacciones de formularios, gestión de state.
- Los tests E2E son lentos y caros — manténlos enfocados en flujos de alto valor.
Trampa: ¿Por qué es dañino probar detalles de implementación?
Aserciones sobre state interno, clases CSS o conteos de llamadas a mocks acoplan los tests a la estructura del código. Cuando refactorizas, los tests se rompen aunque el comportamiento no haya cambiado. Prueba lo que el usuario ve en su lugar.
¿Cómo reduce TypeScript la necesidad de ciertos tests?
Con strict: true, TypeScript detecta:
- acceso null/undefined (sustituye tests de comprobación de null)
- tipos de argumento incorrectos (sustituye tests de comprobación de tipos)
- propiedades faltantes (sustituye tests de existencia de propiedades)
¿Cómo debería ser mi pipeline de CI?
- Lint + typecheck (rápido, en cada push)
- Tests unitarios/integración (en cada push)
- Tests E2E (PRs y merges)
- Despliegue (solo rama main)
Falla rápido — ejecuta primero las comprobaciones baratas.
¿Debería escribir un test por cada corrección de bug?
Sí. Escribir un test de regresión por cada bug garantiza que no vuelva a aparecer. Es una de las prácticas de testing de mayor valor.
¿Cómo uso hooks de pre-commit para feedback rápido de tests?
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "vitest related --run"]
}
}Esto ejecuta solo los tests relacionados con los archivos modificados antes de cada commit.
Relacionado
- Configuración de Vitest — configurar el runner de tests unitarios
- Fundamentos de React Testing Library — patrones de testing de integración
- Configuración E2E con Playwright — runner de tests E2E
- Probar Server Components y Actions — estrategias de testing para RSC