Testing Unitario en 2026: No Quedan Excusas
Las herramientas se han puesto al día. La IA escribe scaffolds de tests, los frameworks son rápidos, y ya no hay buenas razones para enviar código sin probar.

Hace tres años, las excusas para no escribir tests eran al menos plausibles. Los frameworks de testing eran lentos. Configurar mocks era tedioso. Escribir tests tomaba más tiempo que escribir el código. El ROI no era obvio para una startup de 3 personas corriendo hacia el product-market fit. No estaba de acuerdo con esas excusas, pero las entendía.
En 2026, esas excusas se han ido. Vitest ejecuta una suite completa de tests en el tiempo que antes le tomaba a Jest arrancar. Las herramientas de IA generan archivos de tests completos a partir de la firma de una función. TypeScript detecta categorías enteras de bugs en tiempo de compilación, reduciendo lo que realmente necesita testing. Docker Compose levanta bases de datos reales para tests de integración en segundos. La brecha entre "sin tests" y "bien testeado" nunca ha sido más pequeña.
El Panorama Moderno del Testing
El ecosistema de testing se ha consolidado alrededor de unas pocas herramientas excelentes, y la fragmentación que antes hacía de la configuración de tests un proyecto de investigación ha desaparecido en gran parte.
Vitest vs Jest: La Decisión Está Tomada
Para cualquier proyecto nuevo, Vitest es la opción por defecto. No porque Jest sea malo — Jest es un framework sólido y probado en batalla — sino porque Vitest es mediblemente mejor en cada dimensión que importa para el desarrollo moderno.
Velocidad: Vitest usa esbuild para la transformación y ejecuta tests en worker threads con soporte nativo de ESM. En mis proyectos, la misma suite de tests corre 3-5x más rápido bajo Vitest comparado con Jest. Una suite que tomaba 45 segundos en Jest se completa en 12 segundos en Vitest.
Configuración: Vitest reutiliza tu configuración de Vite. Si ya estás usando Vite para tu build (y en 2026, la mayoría de los proyectos lo están), no hay configuración adicional. Compara eso con el moduleNameMapper de Jest, transform, transformIgnorePatterns, y el inevitable troubleshooting de por qué algún paquete ESM no funciona.
Compatibilidad: Vitest implementa una API compatible con Jest. describe, it, expect, beforeEach, afterEach, vi.fn(), vi.mock() — todo funciona como se espera. Migrar de Jest a Vitest es mayormente un find-and-replace de jest.fn() a vi.fn().
// vitest.config.ts — this is often all you need
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', '**/*.d.ts', '**/*.config.*'],
},
},
});
Cuándo quedarse con Jest: Si tienes una suite de tests de Jest grande (500+ tests) y no hay problemas inmediatos, la migración no es urgente. Jest sigue recibiendo actualizaciones y funciona bien. Pero para proyectos nuevos o proyectos con menos de 100 tests, Vitest es la opción obvia.
La Pirámide de Testing en la Práctica
La pirámide de testing tradicional (muchos tests unitarios, menos tests de integración, pocos tests E2E) sigue siendo correcta en principio, pero los límites se han desplazado.
Tests unitarios verifican funciones individuales, métodos utilitarios y lógica de negocio pura. Deben ser rápidos (menos de 10ms cada uno), no tener dependencias externas y testear comportamiento en lugar de implementación.
Tests de integración verifican que los componentes trabajen juntos — rutas de API con consultas a base de datos, métodos de servicio con llamadas a APIs externas, componentes de React con su gestión de estado. Son más lentos (100-500ms cada uno) pero detectan bugs que los tests unitarios no captan.
Tests E2E verifican flujos completos de usuario a través de la aplicación real. Son los más lentos (5-30 segundos cada uno) y los más frágiles, pero detectan bugs que nada más detecta. Herramientas como Playwright han hecho los tests E2E significativamente más confiables que la era de Selenium.
La proporción que busco: 70% unitarios, 20% integración, 10% E2E. Los números exactos importan menos que tener representación en cada nivel.
Qué Testear y Qué Saltar
No todo el código necesita el mismo rigor de testing. Saber dónde invertir el esfuerzo de testing es tan importante como saber cómo escribir tests.
Siempre Testear
Lógica de negocio y reglas de dominio. Si el código implementa una regla de negocio — cálculo de precios, verificación de permisos, validación de datos, transición de máquina de estados — necesita tests. Estas son las reglas que, si están mal, cuestan dinero o crean vulnerabilidades de seguridad.
// pricing.ts
export function calculateOrderTotal(items: OrderItem[], discount?: Discount): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
if (!discount) return subtotal;
if (discount.type === 'percentage') {
return subtotal * (1 - discount.value / 100);
}
if (discount.type === 'fixed') {
return Math.max(0, subtotal - discount.value);
}
return subtotal;
}
// pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateOrderTotal } from './pricing';
describe('calculateOrderTotal', () => {
const items: OrderItem[] = [
{ id: '1', name: 'Burger', price: 12.99, quantity: 2 },
{ id: '2', name: 'Fries', price: 4.99, quantity: 1 },
];
it('calculates subtotal without discount', () => {
expect(calculateOrderTotal(items)).toBe(30.97);
});
it('applies percentage discount', () => {
const discount = { type: 'percentage' as const, value: 10 };
expect(calculateOrderTotal(items, discount)).toBeCloseTo(27.873);
});
it('applies fixed discount', () => {
const discount = { type: 'fixed' as const, value: 5 };
expect(calculateOrderTotal(items, discount)).toBe(25.97);
});
it('does not go below zero with fixed discount', () => {
const discount = { type: 'fixed' as const, value: 50 };
expect(calculateOrderTotal(items, discount)).toBe(0);
});
it('handles empty items array', () => {
expect(calculateOrderTotal([])).toBe(0);
});
});
Funciones de transformación de datos. Cualquier función que transforma datos de una forma a otra — mapeadores de respuestas de API, serializadores de datos de formularios, parsers de CSV — necesita tests con inputs representativos y casos extremos.
Caminos de manejo de errores. ¿Qué pasa cuando la API devuelve un 500? ¿Cuando el input es null? ¿Cuando el archivo no existe? Los caminos de error son donde se esconden los bugs porque los desarrolladores prueban el camino feliz manualmente y asumen que el camino de error funciona.
Funciones utilitarias. Formateadores de strings, helpers de fechas, utilidades de arrays, funciones de validación. Estas se usan en todas partes, y un bug en una función utilitaria se multiplica a través del codebase.
Saltar o Testear Ligeramente
Wrappers directos de framework. Si tu función es un wrapper delgado alrededor de un método de framework bien testeado, testearlo testea el framework, no tu código. Un componente de React que renderiza <h1>{title}</h1> no necesita un test que verifique que el h1 se renderiza.
Archivos de configuración. Configuración estática, constantes, definiciones de tipos — estos no necesitan tests. TypeScript ya los valida en tiempo de compilación.
Código de integración con librerías de terceros. Código que llama a stripe.charges.create() con parámetros de tu modelo de datos no necesita un test unitario que mockee Stripe y verifique que lo llamaste. Necesita un test de integración que verifique el flujo completo de cobro.
Estrategias de Mocking
El mocking es donde los tests pasan de "verificar comportamiento" a "testear detalles de implementación." El objetivo es mockear lo menos posible mientras se mantienen los tests rápidos y determinísticos.
El Enfoque de Inyección de Dependencias
En lugar de mockear módulos, pasa las dependencias como parámetros. Esto hace que el testing sea natural y evita la magia de vi.mock() que acopla los tests a la estructura de módulos.
// user-service.ts
export function createUserService(db: Database, emailClient: EmailClient) {
return {
async createUser(data: CreateUserInput): Promise<User> {
const user = await db.users.create({ data });
await emailClient.send({
to: user.email,
template: 'welcome',
data: { name: user.name },
});
return user;
},
};
}
// user-service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createUserService } from './user-service';
describe('createUserService', () => {
it('creates user and sends welcome email', async () => {
const mockUser = { id: '1', name: 'Jane', email: 'jane@example.com' };
const db = {
users: { create: vi.fn().mockResolvedValue(mockUser) },
};
const emailClient = {
send: vi.fn().mockResolvedValue(undefined),
};
const service = createUserService(db as any, emailClient as any);
const result = await service.createUser({ name: 'Jane', email: 'jane@example.com' });
expect(result).toEqual(mockUser);
expect(emailClient.send).toHaveBeenCalledWith({
to: 'jane@example.com',
template: 'welcome',
data: { name: 'Jane' },
});
});
});
Sin llamadas a vi.mock(), sin strings de rutas de módulos, sin setup dependiente del orden. El test es explícito sobre qué es real y qué es falso.
Cuándo Usar vi.mock()
El mocking a nivel de módulo es apropiado cuando no puedes controlar la inyección de dependencias — típicamente al testear componentes de React que importan módulos directamente o al testear código que usa globals específicos del entorno.
// When you genuinely need module mocking
vi.mock('./api-client', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test User' }),
}));
MSW para Mocking de APIs
Para tests que hacen solicitudes HTTP, Mock Service Worker (MSW) intercepta solicitudes de red a nivel del service worker. Esto es superior a mockear fetch o axios porque testea tu código real de cliente HTTP.
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.example.com/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Test User',
email: 'test@example.com',
});
}),
http.post('https://api.example.com/orders', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: 'order-1', ...body, status: 'created' },
{ status: 201 }
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Generación de Tests Asistida por IA
Las herramientas de IA han cambiado genuinamente la economía de escribir tests. Generar el scaffold inicial del test — el boilerplate, los casos básicos del camino feliz, los casos extremos estándar — es exactamente el tipo de trabajo repetitivo y basado en patrones que los LLMs manejan bien.
Lo Que la IA Hace Bien
- Generar archivos de test a partir de firmas de funciones y definiciones de tipos
- Sugerir casos extremos en los que podrías no pensar (arrays vacíos, valores null, números límite)
- Escribir boilerplate repetitivo de setup/teardown
- Crear objetos mock que coinciden con las formas de las interfaces
- Generar casos de test parametrizados para funciones con muchas combinaciones de input
Lo Que la IA Hace Mal
- Entender contexto de negocio ("esto debería fallar porque los usuarios no pueden ordenar después de medianoche" es conocimiento de dominio)
- Testear interacciones complejas de estado a través de múltiples llamadas de funciones
- Escribir tests de integración significativos que ejerciten fronteras reales del sistema
- Decidir qué testear y qué no testear
- Escribir tests que verifican comportamiento en lugar de implementación
El Flujo de Trabajo Práctico
Mi flujo de trabajo con testing asistido por IA:
- Escribir la función o módulo.
- Pedir a la IA que genere un archivo de test con casos básicos.
- Revisar y corregir los tests generados — los tests generados por IA a menudo testean detalles de implementación en lugar de comportamiento.
- Agregar casos específicos del dominio que la IA no incluyó.
- Ejecutar los tests y verificar que realmente detectan bugs rompiendo temporalmente el código.
La IA hace el paso 2 (que solía ser la parte tediosa) y yo me enfoco en los pasos 3-5 (que requieren criterio). Esto reduce el tiempo de escritura de tests en aproximadamente 50-60% para código estándar de utilidades y servicios.
Testeando Componentes de React
El testing de componentes ha evolucionado significativamente. El cambio del shallow rendering de Enzyme a la filosofía de testing centrada en el usuario de React Testing Library cambió cómo pienso sobre los tests de componentes.
Qué Testear en Componentes
- ¿El componente se renderiza correctamente con diferentes props?
- ¿Las interacciones del usuario (clic, escritura, selección) disparan el comportamiento esperado?
- ¿El componente maneja estados de carga, error y vacío?
- ¿El renderizado condicional funciona correctamente?
Qué No Testear en Componentes
- Valores de estado interno (testea lo que el usuario ve, no lo que React rastrea)
- Detalles de implementación (qué función se llamó, cuántos re-renders pasaron)
- Estilos (usa testing de regresión visual para eso)
// OrderSummary.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OrderSummary } from './OrderSummary';
describe('OrderSummary', () => {
const defaultProps = {
items: [
{ id: '1', name: 'Burger', price: 12.99, quantity: 2 },
{ id: '2', name: 'Fries', price: 4.99, quantity: 1 },
],
onCheckout: vi.fn(),
};
it('displays order items with prices', () => {
render(<OrderSummary {...defaultProps} />);
expect(screen.getByText('Burger')).toBeInTheDocument();
expect(screen.getByText('$25.98')).toBeInTheDocument(); // 12.99 * 2
expect(screen.getByText('Fries')).toBeInTheDocument();
expect(screen.getByText('$4.99')).toBeInTheDocument();
});
it('displays total', () => {
render(<OrderSummary {...defaultProps} />);
expect(screen.getByText('Total: $30.97')).toBeInTheDocument();
});
it('calls onCheckout when checkout button is clicked', async () => {
const user = userEvent.setup();
render(<OrderSummary {...defaultProps} />);
await user.click(screen.getByRole('button', { name: /checkout/i }));
expect(defaultProps.onCheckout).toHaveBeenCalledOnce();
});
it('shows empty state when no items', () => {
render(<OrderSummary {...defaultProps} items={[]} />);
expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /checkout/i })).not.toBeInTheDocument();
});
it('disables checkout button during loading', () => {
render(<OrderSummary {...defaultProps} isLoading={true} />);
expect(screen.getByRole('button', { name: /checkout/i })).toBeDisabled();
});
});
Nota: sin getByTestId a menos que sea necesario, sin verificar estado interno, sin verificar clases CSS. Los tests se leen como una descripción de lo que un usuario ve y hace.
Testeando Rutas de API
Los tests de rutas de API son tests de integración que verifican que tu handler HTTP funciona correctamente con su middleware, validación y formato de respuesta.
// Using supertest with an Express app
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { db } from '../database';
describe('POST /api/orders', () => {
let authToken: string;
beforeAll(async () => {
await db.migrate.latest();
await db.seed.run();
// Get auth token for test user
const loginResponse = await request(app)
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'testpassword' });
authToken = loginResponse.body.token;
});
afterAll(async () => {
await db.destroy();
});
it('creates an order with valid data', async () => {
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [{ productId: 'prod-1', quantity: 2 }],
deliveryAddress: '123 Main St',
});
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: expect.any(String),
status: 'pending',
items: expect.arrayContaining([
expect.objectContaining({ productId: 'prod-1', quantity: 2 }),
]),
});
});
it('returns 401 without auth token', async () => {
const response = await request(app)
.post('/api/orders')
.send({ items: [{ productId: 'prod-1', quantity: 2 }] });
expect(response.status).toBe(401);
});
it('returns 400 with invalid data', async () => {
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({ items: [] });
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
});
it('returns 400 when product does not exist', async () => {
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [{ productId: 'nonexistent', quantity: 1 }],
deliveryAddress: '123 Main St',
});
expect(response.status).toBe(400);
expect(response.body.message).toContain('not found');
});
});
Testeando Operaciones de Base de Datos
Testear operaciones de base de datos requiere una base de datos real. Mockear consultas SQL testea el mock, no la consulta. Usa una base de datos de testing que se recrea para cada ejecución de tests.
// database.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
datasources: { db: { url: process.env.TEST_DATABASE_URL } },
});
beforeEach(async () => {
// Clean tables in dependency order
await prisma.orderItem.deleteMany();
await prisma.order.deleteMany();
await prisma.user.deleteMany();
});
describe('Order repository', () => {
it('creates order with items and calculates total', async () => {
const user = await prisma.user.create({
data: { email: 'test@example.com', name: 'Test User' },
});
const order = await prisma.order.create({
data: {
userId: user.id,
items: {
create: [
{ productName: 'Burger', price: 12.99, quantity: 2 },
{ productName: 'Fries', price: 4.99, quantity: 1 },
],
},
total: 30.97,
},
include: { items: true },
});
expect(order.items).toHaveLength(2);
expect(order.total).toBe(30.97);
expect(order.userId).toBe(user.id);
});
it('enforces unique email constraint', async () => {
await prisma.user.create({
data: { email: 'duplicate@example.com', name: 'User 1' },
});
await expect(
prisma.user.create({
data: { email: 'duplicate@example.com', name: 'User 2' },
})
).rejects.toThrow();
});
});
Configuración de Base de Datos de Testing en CI
# In GitHub Actions
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
Con Prisma, ejecuta npx prisma migrate deploy antes de los tests para aplicar el schema. Con SQL puro, ejecuta scripts de migración. La base de datos de testing empieza limpia en cada ejecución de CI.
Métricas de Cobertura Que Importan
La cobertura de código es una señal útil, pero es un objetivo terrible.
La Trampa de la Cobertura
Optimizar para 100% de cobertura lleva a tests que existen para cubrir líneas en lugar de verificar comportamiento. He visto tests como este:
// This test exists purely for coverage
it('should have a default export', () => {
expect(module).toBeDefined();
});
Este test no verifica nada significativo. Suma al número de cobertura mientras agrega cero confianza.
Objetivos de Cobertura Significativos
En lugar de un umbral general de cobertura, uso diferentes objetivos para diferentes categorías de código:
| Categoría | Objetivo | Razón |
|---|---|---|
| Lógica de negocio / Dominio | 90%+ | Los bugs aquí cuestan dinero |
| Rutas de API / Controllers | 80%+ | Fronteras de integración |
| Funciones utilitarias | 95%+ | Amplio uso, alto impacto |
| Componentes UI | 70%+ | Bugs visuales detectados por E2E |
| Configuración / Setup | Sin objetivo | Estático, raramente se rompe |
Configura umbrales de cobertura por directorio:
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
thresholds: {
'src/domain/**': { statements: 90, branches: 85 },
'src/api/**': { statements: 80, branches: 75 },
'src/utils/**': { statements: 95, branches: 90 },
},
},
},
});
La Alternativa del Mutation Testing
Si los números de cobertura se sienten vacíos, el mutation testing proporciona una evaluación más honesta. Herramientas como Stryker introducen pequeños cambios (mutaciones) en tu código y verifican que tus tests los detecten. Una mutación que sobrevive (los tests siguen pasando) indica una brecha en tu suite de tests.
npx stryker run
El mutation testing es lento (ejecuta toda tu suite de tests por cada mutación) pero revelador. Lo ejecuto mensualmente en lugar de en cada commit.
Desarrollo Dirigido por Tests en la Práctica
Practico TDD selectivamente, no dogmáticamente. Funciona excepcionalmente bien para cierto código y agrega fricción a otro.
Dónde Brilla el TDD
Funciones puras con especificaciones claras. Si conoces los inputs y outputs esperados antes de escribir el código, escribir el test primero es natural y productivo.
Correcciones de bugs. Antes de corregir un bug, escribe un test que lo reproduzca. Luego corrige el código hasta que el test pase. Esto garantiza que el bug se mantiene corregido.
Refactoring. Escribe tests para el comportamiento existente antes de refactorizar. Los tests actúan como una red de seguridad, verificando que el código refactorizado produce resultados idénticos.
Dónde el TDD Agrega Fricción
Código exploratorio. Cuando estás descubriendo cómo algo debería funcionar — experimentando con una API, prototipando una UI, explorando una estructura de datos — escribir tests primero te ralentiza. Escribe el código, estabiliza la interfaz, luego agrega tests.
Componentes UI. Los tests de Testing Library para componentes de React se escriben mejor después de que el componente existe, porque el output renderizado del componente (sobre el cual los tests hacen assert) no se conoce hasta que lo construyes.
El Ciclo Red-Green-Refactor
Cuando hago TDD, sigo el ciclo clásico estrictamente:
- Red: Escribir un test que falla (porque el código no existe todavía).
- Green: Escribir el código mínimo para que el test pase. No optimizar.
- Refactor: Limpiar el código manteniendo todos los tests en verde.
La disciplina del paso 2 — escribir el código mínimo — es la más importante y la más frecuentemente violada. La tentación de escribir "la implementación real" inmediatamente derrota el propósito del TDD, que es dejar que los tests guíen el diseño de forma incremental.
La Inversión Paga Interés Compuesto
Cada test que escribes tiene una vida útil medida en años. Un test escrito hoy detectará regresiones en el refactoring de la próxima semana, la adición de funcionalidades del próximo mes y la actualización de framework del próximo año. Los 10 minutos que pasas escribiendo un test ahorran 2 horas de debugging seis meses después, pero nunca ves ese ahorro explícitamente — simplemente nunca tienes el bug.
La parte más difícil del testing no son las herramientas ni las técnicas. Es la disciplina de escribir el test ahora, cuando el código está fresco en tu mente, en lugar de agregarlo al backlog donde muere silenciosamente. La excusa de las herramientas se fue. La excusa de la velocidad se fue. La única excusa que queda es la priorización, y enviar código testeado no debería ser negociable.
Danil Ulmashev
Full Stack Developer
Interesado en trabajar juntos?