Lo que vas a construir
Una aplicacion fullstack completamente testeada: un API REST con Express y un frontend React, con tests en todos los niveles de la piramide de testing. Escribiras tests unitarios con Jest para la logica de negocio, tests de integracion con Supertest para los endpoints, tests de base de datos con Testcontainers, tests de componentes React con Testing Library, y tests end-to-end con Playwright. Al terminar, tendras una suite de tests completa con reporte de cobertura que te da confianza para hacer cambios sin romper nada.
Paso 1: Crear el proyecto
mkdir fullstack-testing && cd fullstack-testing
npm init -y
Instala las dependencias del backend:
npm install express cors
npm install -D typescript @types/node @types/express ts-node nodemon
Paso 2: Configurar TypeScript
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}
Paso 3: Crear la logica de negocio
// src/services/userService.ts
export interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
export interface CreateUserDTO {
name: string;
email: string;
}
// Simulamos una base de datos en memoria
const users: Map<string, User> = new Map();
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
export function validateName(name: string): boolean {
return name.length >= 2 && name.length <= 50;
}
export function createUser(data: CreateUserDTO): User {
if (!validateEmail(data.email)) {
throw new Error('Email invalido');
}
if (!validateName(data.name)) {
throw new Error('Nombre debe tener entre 2 y 50 caracteres');
}
// Verificar email unico
for (const user of users.values()) {
if (user.email === data.email) {
throw new Error('Email ya existe');
}
}
const user: User = {
id: crypto.randomUUID(),
name: data.name.trim(),
email: data.email.toLowerCase(),
createdAt: new Date()
};
users.set(user.id, user);
return user;
}
export function getUser(id: string): User | undefined {
return users.get(id);
}
export function getAllUsers(): User[] {
return Array.from(users.values());
}
export function deleteUser(id: string): boolean {
return users.delete(id);
}
export function clearUsers(): void {
users.clear();
}
Paso 4: Crear el API REST
// src/app.ts
import express from 'express';
import cors from 'cors';
import * as userService from './services/userService';
const app = express();
app.use(cors());
app.use(express.json());
// GET /api/users - Listar usuarios
app.get('/api/users', (req, res) => {
const users = userService.getAllUsers();
res.json(users);
});
// GET /api/users/:id - Obtener usuario
app.get('/api/users/:id', (req, res) => {
const user = userService.getUser(req.params.id);
if (!user) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
res.json(user);
});
// POST /api/users - Crear usuario
app.post('/api/users', (req, res) => {
try {
const user = userService.createUser(req.body);
res.status(201).json(user);
} catch (error) {
const message = error instanceof Error ? error.message : 'Error desconocido';
res.status(400).json({ error: message });
}
});
// DELETE /api/users/:id - Eliminar usuario
app.delete('/api/users/:id', (req, res) => {
const deleted = userService.deleteUser(req.params.id);
if (!deleted) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
res.status(204).send();
});
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
export { app };
// src/server.ts
import { app } from './app';
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`Servidor corriendo en http://localhost:${PORT}`);
});
Paso 5: Tests unitarios con Jest
npm install -D jest @types/jest ts-jest
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/server.ts'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
// src/services/userService.test.ts
import {
validateEmail,
validateName,
createUser,
getUser,
getAllUsers,
deleteUser,
clearUsers
} from './userService';
describe('UserService', () => {
beforeEach(() => {
clearUsers();
});
describe('validateEmail', () => {
it('acepta emails validos', () => {
expect(validateEmail('test@example.com')).toBe(true);
expect(validateEmail('user.name@domain.org')).toBe(true);
});
it('rechaza emails invalidos', () => {
expect(validateEmail('invalid')).toBe(false);
expect(validateEmail('no@domain')).toBe(false);
expect(validateEmail('@nodomain.com')).toBe(false);
expect(validateEmail('')).toBe(false);
});
});
describe('validateName', () => {
it('acepta nombres validos', () => {
expect(validateName('Juan')).toBe(true);
expect(validateName('Ana Maria Lopez')).toBe(true);
});
it('rechaza nombres muy cortos', () => {
expect(validateName('A')).toBe(false);
});
it('rechaza nombres muy largos', () => {
expect(validateName('A'.repeat(51))).toBe(false);
});
});
describe('createUser', () => {
it('crea usuario con datos validos', () => {
const user = createUser({
name: 'Juan Perez',
email: 'juan@example.com'
});
expect(user.id).toBeDefined();
expect(user.name).toBe('Juan Perez');
expect(user.email).toBe('juan@example.com');
expect(user.createdAt).toBeInstanceOf(Date);
});
it('normaliza el email a minusculas', () => {
const user = createUser({
name: 'Test User',
email: 'TEST@EXAMPLE.COM'
});
expect(user.email).toBe('test@example.com');
});
it('lanza error con email invalido', () => {
expect(() => createUser({
name: 'Test',
email: 'invalid'
})).toThrow('Email invalido');
});
it('lanza error con nombre invalido', () => {
expect(() => createUser({
name: 'A',
email: 'test@example.com'
})).toThrow('Nombre debe tener entre 2 y 50 caracteres');
});
it('lanza error si email ya existe', () => {
createUser({ name: 'User 1', email: 'test@example.com' });
expect(() => createUser({
name: 'User 2',
email: 'test@example.com'
})).toThrow('Email ya existe');
});
});
describe('getUser', () => {
it('retorna usuario existente', () => {
const created = createUser({
name: 'Test',
email: 'test@example.com'
});
const found = getUser(created.id);
expect(found).toEqual(created);
});
it('retorna undefined para usuario inexistente', () => {
expect(getUser('nonexistent-id')).toBeUndefined();
});
});
describe('getAllUsers', () => {
it('retorna array vacio sin usuarios', () => {
expect(getAllUsers()).toEqual([]);
});
it('retorna todos los usuarios', () => {
createUser({ name: 'User 1', email: 'user1@example.com' });
createUser({ name: 'User 2', email: 'user2@example.com' });
const users = getAllUsers();
expect(users).toHaveLength(2);
});
});
describe('deleteUser', () => {
it('elimina usuario existente', () => {
const user = createUser({
name: 'Test',
email: 'test@example.com'
});
expect(deleteUser(user.id)).toBe(true);
expect(getUser(user.id)).toBeUndefined();
});
it('retorna false para usuario inexistente', () => {
expect(deleteUser('nonexistent-id')).toBe(false);
});
});
});
Paso 6: Tests de integracion con Supertest
npm install -D supertest @types/supertest
// src/app.test.ts
import request from 'supertest';
import { app } from './app';
import { clearUsers } from './services/userService';
describe('Users API', () => {
beforeEach(() => {
clearUsers();
});
describe('GET /health', () => {
it('retorna status ok', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: 'ok' });
});
});
describe('POST /api/users', () => {
it('crea usuario con datos validos', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Juan Perez', email: 'juan@example.com' });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
name: 'Juan Perez',
email: 'juan@example.com'
});
expect(res.body.id).toBeDefined();
});
it('retorna 400 con email invalido', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Test', email: 'invalid' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Email invalido');
});
it('retorna 400 si email duplicado', async () => {
await request(app)
.post('/api/users')
.send({ name: 'User 1', email: 'test@example.com' });
const res = await request(app)
.post('/api/users')
.send({ name: 'User 2', email: 'test@example.com' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Email ya existe');
});
});
describe('GET /api/users', () => {
it('retorna lista vacia inicialmente', async () => {
const res = await request(app).get('/api/users');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('retorna usuarios creados', async () => {
await request(app)
.post('/api/users')
.send({ name: 'User 1', email: 'user1@example.com' });
await request(app)
.post('/api/users')
.send({ name: 'User 2', email: 'user2@example.com' });
const res = await request(app).get('/api/users');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
});
describe('GET /api/users/:id', () => {
it('retorna usuario existente', async () => {
const createRes = await request(app)
.post('/api/users')
.send({ name: 'Test User', email: 'test@example.com' });
const res = await request(app)
.get(`/api/users/${createRes.body.id}`);
expect(res.status).toBe(200);
expect(res.body.name).toBe('Test User');
});
it('retorna 404 para usuario inexistente', async () => {
const res = await request(app)
.get('/api/users/nonexistent-id');
expect(res.status).toBe(404);
expect(res.body.error).toBe('Usuario no encontrado');
});
});
describe('DELETE /api/users/:id', () => {
it('elimina usuario existente', async () => {
const createRes = await request(app)
.post('/api/users')
.send({ name: 'Test', email: 'test@example.com' });
const res = await request(app)
.delete(`/api/users/${createRes.body.id}`);
expect(res.status).toBe(204);
// Verificar que fue eliminado
const getRes = await request(app)
.get(`/api/users/${createRes.body.id}`);
expect(getRes.status).toBe(404);
});
it('retorna 404 para usuario inexistente', async () => {
const res = await request(app)
.delete('/api/users/nonexistent-id');
expect(res.status).toBe(404);
});
});
});
Paso 7: Tests de base de datos con Testcontainers
npm install -D testcontainers @testcontainers/postgresql pg @types/pg
// src/database/db.integration.test.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
describe('Database Integration', () => {
let container: StartedPostgreSqlContainer;
let pool: Pool;
beforeAll(async () => {
// Inicia un contenedor PostgreSQL real
container = await new PostgreSqlContainer()
.withDatabase('testdb')
.withUsername('testuser')
.withPassword('testpass')
.start();
pool = new Pool({
connectionString: container.getConnectionUri()
});
// Crear tabla
await pool.query(`
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`);
}, 60000); // Timeout de 60s para descargar imagen
afterAll(async () => {
await pool.end();
await container.stop();
});
beforeEach(async () => {
await pool.query('DELETE FROM users');
});
it('inserta y recupera usuario', async () => {
const insertResult = await pool.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
['Juan Perez', 'juan@example.com']
);
expect(insertResult.rows[0].name).toBe('Juan Perez');
expect(insertResult.rows[0].email).toBe('juan@example.com');
const selectResult = await pool.query(
'SELECT * FROM users WHERE email = $1',
['juan@example.com']
);
expect(selectResult.rows).toHaveLength(1);
});
it('rechaza emails duplicados', async () => {
await pool.query(
'INSERT INTO users (name, email) VALUES ($1, $2)',
['User 1', 'test@example.com']
);
await expect(
pool.query(
'INSERT INTO users (name, email) VALUES ($1, $2)',
['User 2', 'test@example.com']
)
).rejects.toThrow(/unique constraint/i);
});
it('genera UUID automaticamente', async () => {
const result = await pool.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id',
['Test', 'test@example.com']
);
expect(result.rows[0].id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
);
});
});
Paso 8: Crear componente React
Crea el frontend en una carpeta separada:
mkdir -p client/src
cd client
npm init -y
npm install react react-dom
npm install -D vite @vitejs/plugin-react typescript @types/react @types/react-dom
// client/src/components/UserForm.tsx
import { useState } from 'react';
interface User {
id: string;
name: string;
email: string;
}
interface Props {
onUserCreated: (user: User) => void;
}
export function UserForm({ onUserCreated }: Props) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('http://localhost:3001/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Error al crear usuario');
}
onUserCreated(data);
setName('');
setEmail('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Error desconocido');
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="user-form">
<div>
<label htmlFor="name">Nombre:</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Tu nombre"
required
/>
</div>
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="tu@email.com"
required
/>
</div>
{error && <p className="error" role="alert">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Creando...' : 'Crear Usuario'}
</button>
</form>
);
}
Paso 9: Tests de componentes con Testing Library
cd client
npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
// client/vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
globals: true
}
});
// client/src/test/setup.ts
import '@testing-library/jest-dom';
// client/src/components/UserForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserForm } from './UserForm';
describe('UserForm', () => {
const mockOnUserCreated = vi.fn();
beforeEach(() => {
vi.resetAllMocks();
global.fetch = vi.fn();
});
it('renderiza el formulario correctamente', () => {
render(<UserForm onUserCreated={mockOnUserCreated} />);
expect(screen.getByLabelText(/nombre/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /crear usuario/i })).toBeInTheDocument();
});
it('permite escribir en los campos', async () => {
const user = userEvent.setup();
render(<UserForm onUserCreated={mockOnUserCreated} />);
await user.type(screen.getByLabelText(/nombre/i), 'Juan Perez');
await user.type(screen.getByLabelText(/email/i), 'juan@example.com');
expect(screen.getByLabelText(/nombre/i)).toHaveValue('Juan Perez');
expect(screen.getByLabelText(/email/i)).toHaveValue('juan@example.com');
});
it('envia formulario y llama callback con usuario creado', async () => {
const user = userEvent.setup();
const mockUser = { id: '123', name: 'Juan', email: 'juan@example.com' };
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser
});
render(<UserForm onUserCreated={mockOnUserCreated} />);
await user.type(screen.getByLabelText(/nombre/i), 'Juan');
await user.type(screen.getByLabelText(/email/i), 'juan@example.com');
await user.click(screen.getByRole('button', { name: /crear usuario/i }));
await waitFor(() => {
expect(mockOnUserCreated).toHaveBeenCalledWith(mockUser);
});
// Verifica que los campos se limpiaron
expect(screen.getByLabelText(/nombre/i)).toHaveValue('');
expect(screen.getByLabelText(/email/i)).toHaveValue('');
});
it('muestra error del servidor', async () => {
const user = userEvent.setup();
(global.fetch as any).mockResolvedValueOnce({
ok: false,
json: async () => ({ error: 'Email ya existe' })
});
render(<UserForm onUserCreated={mockOnUserCreated} />);
await user.type(screen.getByLabelText(/nombre/i), 'Test');
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Email ya existe');
});
expect(mockOnUserCreated).not.toHaveBeenCalled();
});
it('deshabilita boton mientras carga', async () => {
const user = userEvent.setup();
let resolvePromise: any;
(global.fetch as any).mockImplementationOnce(
() => new Promise((resolve) => { resolvePromise = resolve; })
);
render(<UserForm onUserCreated={mockOnUserCreated} />);
await user.type(screen.getByLabelText(/nombre/i), 'Test');
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.click(screen.getByRole('button'));
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByRole('button')).toHaveTextContent('Creando...');
// Resolver la promesa para limpiar
resolvePromise({ ok: true, json: async () => ({ id: '1' }) });
});
});
Paso 10: Tests E2E con Playwright
npm install -D @playwright/test
npx playwright install
// e2e/users.spec.ts
import { test, expect } from '@playwright/test';
test.describe('User Management', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5173');
});
test('puede crear un nuevo usuario', async ({ page }) => {
// Llenar formulario
await page.getByLabel(/nombre/i).fill('Juan Perez');
await page.getByLabel(/email/i).fill('juan@example.com');
// Enviar
await page.getByRole('button', { name: /crear usuario/i }).click();
// Verificar que aparece en la lista
await expect(page.getByText('Juan Perez')).toBeVisible();
await expect(page.getByText('juan@example.com')).toBeVisible();
});
test('muestra error con email duplicado', async ({ page }) => {
// Crear primer usuario
await page.getByLabel(/nombre/i).fill('User 1');
await page.getByLabel(/email/i).fill('duplicate@example.com');
await page.getByRole('button', { name: /crear usuario/i }).click();
// Esperar que se cree
await expect(page.getByText('User 1')).toBeVisible();
// Intentar crear con mismo email
await page.getByLabel(/nombre/i).fill('User 2');
await page.getByLabel(/email/i).fill('duplicate@example.com');
await page.getByRole('button', { name: /crear usuario/i }).click();
// Verificar error
await expect(page.getByRole('alert')).toContainText(/ya existe/i);
});
test('valida campos requeridos', async ({ page }) => {
// Intentar enviar sin datos
await page.getByRole('button', { name: /crear usuario/i }).click();
// Verificar validacion HTML5
const nameInput = page.getByLabel(/nombre/i);
await expect(nameInput).toHaveAttribute('required');
});
test('puede eliminar usuario', async ({ page }) => {
// Crear usuario
await page.getByLabel(/nombre/i).fill('Delete Me');
await page.getByLabel(/email/i).fill('delete@example.com');
await page.getByRole('button', { name: /crear usuario/i }).click();
await expect(page.getByText('Delete Me')).toBeVisible();
// Eliminar
await page.getByRole('button', { name: /eliminar/i }).first().click();
// Verificar que desaparecio
await expect(page.getByText('Delete Me')).not.toBeVisible();
});
});
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: 2,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
webServer: [
{
command: 'npm run dev:server',
port: 3001,
reuseExistingServer: !process.env.CI,
},
{
command: 'npm run dev:client',
port: 5173,
reuseExistingServer: !process.env.CI,
},
],
});
Paso 11: Configurar scripts de test
// package.json
{
"scripts": {
"dev:server": "nodemon --exec ts-node src/server.ts",
"dev:client": "cd client && npm run dev",
"build": "tsc",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:integration": "jest --testPathPattern=integration",
"test:client": "cd client && npm run test",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "npm run test:coverage && npm run test:client && npm run test:e2e"
}
}
Paso 12: Ejecutar y ver cobertura
# Tests unitarios + integracion con cobertura
npm run test:coverage
Veras un reporte asi:
--------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files | 92.45 | 85.71 | 91.67 | 92.16 |
services | | | | |
userService.ts | 95.24 | 90.00 | 100.0 | 95.00 |
app.ts | 89.47 | 80.00 | 83.33 | 89.19 |
--------------------|---------|----------|---------|---------|
Test Suites: 3 passed, 3 total
Tests: 24 passed, 24 total
# Tests E2E
npm run test:e2e
Running 4 tests using 2 workers
4 passed (12.3s)
To open last HTML report run:
npx playwright show-report
Piramide de testing
| Nivel | Herramienta | Que prueba | Velocidad |
|---|---|---|---|
| Unitarios | Jest | Logica aislada | Rapido |
| Integracion | Supertest | Endpoints HTTP | Medio |
| Base de datos | Testcontainers | Queries reales | Lento |
| Componentes | Testing Library | UI aislada | Rapido |
| E2E | Playwright | Flujos completos | Lento |
Siguiente paso
-> CI/CD con GitHub Actions para correr tus tests en cada push