Pruebas Automatizadas en Frontend: Guía Completa con Vitest y Playwright en 2025
Hola HaWkers, pruebas automatizadas dejaron de ser lujo para tornarse necesidad. En 2025, con aplicaciones cada vez más complejas, tener una suite de tests robusta es lo que separa proyectos profesionales de amateur.
¿Cuántas veces un "pequeño cambio" rompió algo en producción? Pruebas automatizadas son tu red de seguridad. Vamos a explorar cómo implementarlas de forma efectiva.
El Escenario de Testing en 2025
El ecosistema de testing frontend maduró significativamente. Vitest se convirtió en el estándar para unit tests, mientras Playwright domina el E2E.
Por Qué Vitest y Playwright
Vitest:
- Integración nativa con Vite
- Velocidad superior a Jest
- API compatible con Jest (migración fácil)
- HMR para tests (feedback instantáneo)
- Soporte TypeScript out-of-box
Playwright:
- Multi-browser (Chromium, Firefox, WebKit)
- Auto-wait inteligente
- Debugging visual excepcional
- Trace viewer para investigar fallas
- Paralelización automática
Configurando el Ambiente
Instalación del Vitest
# En proyecto existente con Vite
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
# Para Vue
npm install -D vitest @testing-library/vue @testing-library/jest-dom jsdom
# Para Svelte
npm install -D vitest @testing-library/svelte @testing-library/jest-dom jsdom// 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',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
});// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// Cleanup después de cada test
afterEach(() => {
cleanup();
});Instalación del Playwright
# Instalación
npm init playwright@latest
# Estructura creada:
# tests/
# playwright.config.ts
# .github/workflows/playwright.yml (opcional)// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Unit Tests con Vitest
Testando Funciones Puras
// src/utils/formatters.ts
export function formatCurrency(value: number, locale = 'es-ES'): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'EUR',
}).format(value);
}
export function formatDate(date: Date, locale = 'es-ES'): string {
return new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date);
}
export function slugify(text: string): string {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}// src/utils/formatters.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency, formatDate, slugify } from './formatters';
describe('formatCurrency', () => {
it('formatea valores en EUR correctamente', () => {
expect(formatCurrency(1234.56)).toBe('1.234,56 €');
});
it('formatea valores negativos', () => {
expect(formatCurrency(-100)).toBe('-100,00 €');
});
it('formatea cero', () => {
expect(formatCurrency(0)).toBe('0,00 €');
});
});
describe('formatDate', () => {
it('formatea fecha en formato español', () => {
const date = new Date('2025-03-15');
expect(formatDate(date)).toBe('15/03/2025');
});
});
describe('slugify', () => {
it('convierte texto a slug', () => {
expect(slugify('Hola Mundo')).toBe('hola-mundo');
});
it('remueve acentos', () => {
expect(slugify('Café con Leche')).toBe('cafe-con-leche');
});
it('remueve caracteres especiales', () => {
expect(slugify('Test@#$%123')).toBe('test-123');
});
it('remueve guiones duplicados', () => {
expect(slugify('múltiples espacios')).toBe('multiples-espacios');
});
});Testando Hooks React
// src/hooks/useCounter.ts
import { useState, useCallback } from 'react';
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, decrement, reset };
}// src/hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('inicia con valor default 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('inicia con valor personalizado', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('incrementa el contador', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrementa el contador', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('resetea para valor inicial', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Testando Componentes React
// src/components/TodoList.tsx
import { useState } from 'react';
interface Todo {
id: number;
text: string;
completed: boolean;
}
export function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [input, setInput] = useState('');
const addTodo = () => {
if (input.trim()) {
setTodos([
...todos,
{ id: Date.now(), text: input.trim(), completed: false }
]);
setInput('');
}
};
const toggleTodo = (id: number) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const removeTodo = (id: number) => {
setTodos(todos.filter(todo => todo.id !== id));
};
return (
<div>
<h1>Lista de Tareas</h1>
<div>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Nueva tarea..."
aria-label="Nueva tarea"
/>
<button onClick={addTodo}>Agregar</button>
</div>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
aria-label={`Marcar ${todo.text}`}
/>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}>
{todo.text}
</span>
<button
onClick={() => removeTodo(todo.id)}
aria-label={`Eliminar ${todo.text}`}
>
Eliminar
</button>
</li>
))}
</ul>
{todos.length === 0 && <p>No hay tareas</p>}
</div>
);
}// src/components/TodoList.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { TodoList } from './TodoList';
describe('TodoList', () => {
it('renderiza estado vacío inicialmente', () => {
render(<TodoList />);
expect(screen.getByText('Lista de Tareas')).toBeInTheDocument();
expect(screen.getByText('No hay tareas')).toBeInTheDocument();
});
it('agrega nueva tarea', async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByLabelText('Nueva tarea');
const button = screen.getByText('Agregar');
await user.type(input, 'Comprar pan');
await user.click(button);
expect(screen.getByText('Comprar pan')).toBeInTheDocument();
expect(screen.queryByText('No hay tareas')).not.toBeInTheDocument();
});
it('no agrega tarea vacía', async () => {
const user = userEvent.setup();
render(<TodoList />);
const button = screen.getByText('Agregar');
await user.click(button);
expect(screen.getByText('No hay tareas')).toBeInTheDocument();
});
it('marca tarea como completada', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Agregar tarea
await user.type(screen.getByLabelText('Nueva tarea'), 'Test tarea');
await user.click(screen.getByText('Agregar'));
// Marcar como completada
const checkbox = screen.getByLabelText('Marcar Test tarea');
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
it('elimina tarea', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Agregar tarea
await user.type(screen.getByLabelText('Nueva tarea'), 'Tarea para eliminar');
await user.click(screen.getByText('Agregar'));
expect(screen.getByText('Tarea para eliminar')).toBeInTheDocument();
// Eliminar
await user.click(screen.getByLabelText('Eliminar Tarea para eliminar'));
expect(screen.queryByText('Tarea para eliminar')).not.toBeInTheDocument();
expect(screen.getByText('No hay tareas')).toBeInTheDocument();
});
it('limpia input después de agregar', async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByLabelText('Nueva tarea') as HTMLInputElement;
await user.type(input, 'Nueva tarea');
await user.click(screen.getByText('Agregar'));
expect(input.value).toBe('');
});
});
Tests E2E con Playwright
Test de Flujo Completo
// e2e/todo.spec.ts
import { test, expect } from '@playwright/test';
test.describe('App de Tareas', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('muestra estado vacío inicialmente', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Lista de Tareas' })).toBeVisible();
await expect(page.getByText('No hay tareas')).toBeVisible();
});
test('flujo completo de CRUD', async ({ page }) => {
// CREATE
await page.getByLabel('Nueva tarea').fill('Comprar pan');
await page.getByRole('button', { name: 'Agregar' }).click();
await expect(page.getByText('Comprar pan')).toBeVisible();
await expect(page.getByText('No hay tareas')).not.toBeVisible();
// UPDATE (marcar como completada)
await page.getByLabel('Marcar Comprar pan').check();
await expect(page.getByLabel('Marcar Comprar pan')).toBeChecked();
// DELETE
await page.getByLabel('Eliminar Comprar pan').click();
await expect(page.getByText('Comprar pan')).not.toBeVisible();
await expect(page.getByText('No hay tareas')).toBeVisible();
});
test('permite agregar múltiples tareas', async ({ page }) => {
const tareas = ['Tarea 1', 'Tarea 2', 'Tarea 3'];
for (const tarea of tareas) {
await page.getByLabel('Nueva tarea').fill(tarea);
await page.getByRole('button', { name: 'Agregar' }).click();
}
for (const tarea of tareas) {
await expect(page.getByText(tarea)).toBeVisible();
}
});
});Test de Autenticación
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Autenticación', () => {
test('login con credenciales válidas', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('usuario@test.com');
await page.getByLabel('Contraseña').fill('senha123');
await page.getByRole('button', { name: 'Entrar' }).click();
// Verifica redirect para dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Bienvenido')).toBeVisible();
});
test('muestra error con credenciales inválidas', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('usuario@test.com');
await page.getByLabel('Contraseña').fill('senhaerrada');
await page.getByRole('button', { name: 'Entrar' }).click();
await expect(page.getByText('Credenciales inválidas')).toBeVisible();
await expect(page).toHaveURL('/login');
});
test('logout funciona correctamente', async ({ page }) => {
// Login primero
await page.goto('/login');
await page.getByLabel('Email').fill('usuario@test.com');
await page.getByLabel('Contraseña').fill('senha123');
await page.getByRole('button', { name: 'Entrar' }).click();
// Logout
await page.getByRole('button', { name: 'Salir' }).click();
await expect(page).toHaveURL('/login');
});
});Tests con Estado Autenticado
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '.auth/user.json');
setup('autenticar usuario', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('usuario@test.com');
await page.getByLabel('Contraseña').fill('senha123');
await page.getByRole('button', { name: 'Entrar' }).click();
await expect(page).toHaveURL('/dashboard');
// Guarda estado de autenticación
await page.context().storageState({ path: authFile });
});// playwright.config.ts (actualizado)
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// ... configuraciones anteriores
projects: [
// Setup project para autenticación
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
Mocking y Stubs
Mocking de APIs con Vitest
// src/services/api.ts
export async function fetchUsers() {
const response = await fetch('/api/users');
return response.json();
}
export async function createUser(data: { name: string; email: string }) {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data),
});
return response.json();
}// src/services/api.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchUsers, createUser } from './api';
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('API Service', () => {
beforeEach(() => {
mockFetch.mockClear();
});
describe('fetchUsers', () => {
it('retorna lista de usuarios', async () => {
const users = [
{ id: 1, name: 'Juan' },
{ id: 2, name: 'María' },
];
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve(users),
});
const result = await fetchUsers();
expect(mockFetch).toHaveBeenCalledWith('/api/users');
expect(result).toEqual(users);
});
});
describe('createUser', () => {
it('crea usuario con datos correctos', async () => {
const newUser = { id: 3, name: 'Pedro', email: 'pedro@test.com' };
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve(newUser),
});
const result = await createUser({
name: 'Pedro',
email: 'pedro@test.com',
});
expect(mockFetch).toHaveBeenCalledWith('/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'Pedro', email: 'pedro@test.com' }),
});
expect(result).toEqual(newUser);
});
});
});Mocking de APIs con Playwright
// e2e/mocked-api.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Con API mockeada', () => {
test('muestra usuarios de API mockeada', async ({ page }) => {
// Interceptar request y retornar datos mockeados
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Usuario Mockeado 1' },
{ id: 2, name: 'Usuario Mockeado 2' },
]),
});
});
await page.goto('/users');
await expect(page.getByText('Usuario Mockeado 1')).toBeVisible();
await expect(page.getByText('Usuario Mockeado 2')).toBeVisible();
});
test('maneja errores de API', async ({ page }) => {
await page.route('**/api/users', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Error interno' }),
});
});
await page.goto('/users');
await expect(page.getByText('Error al cargar usuarios')).toBeVisible();
});
});
Buenas Prácticas
Pirámide de Tests
/\
/ \ E2E (10%)
/----\ - Flujos críticos
/ \ - Happy paths principales
/--------\ Integration (20%)
/ \ - Componentes complejos
/ \ - Hooks con side effects
/--------------\ Unit (70%)
/ \ - Funciones puras
\ - Lógica de negocio
\ - Componentes simplesConvenciones de Nombrado
// Buena nomenclatura para tests
describe('ComponentName', () => {
describe('cuando está en estado X', () => {
it('debe hacer Y', () => {});
});
describe('cuando usuario hace Z', () => {
it('debe resultar en W', () => {});
});
});
// Ejemplos concretos
describe('LoginForm', () => {
describe('cuando credenciales son válidas', () => {
it('debe redirigir para dashboard', () => {});
it('debe guardar token en localStorage', () => {});
});
describe('cuando credenciales son inválidas', () => {
it('debe mostrar mensaje de error', () => {});
it('debe mantener usuario en la página de login', () => {});
});
});Script para CI/CD
// package.json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "vitest run && playwright test"
}
}Conclusión
Pruebas automatizadas son inversión, no costo. La configuración inicial puede parecer trabajo extra, pero la confianza que traen para hacer cambios y refactorings es invaluable.
Con Vitest y Playwright tienes las herramientas más modernas y eficientes del ecosistema. Comienza con unit tests para lógica crítica y expanda gradualmente para E2E en flujos principales.
Si quieres profundizar en herramientas de desarrollo modernas, recomiendo que veas otro artículo: Vite vs Webpack en 2025 donde vas a descubrir cuál build tool elegir para tu proyecto.

