🧪

Testing Fullstack

👨‍🍳 Chef⏱️ 60 minutos

📋 Prerequisitos sugeridos

  • Node.js
  • React basico
  • Entender APIs REST

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

NivelHerramientaQue pruebaVelocidad
UnitariosJestLogica aisladaRapido
IntegracionSupertestEndpoints HTTPMedio
Base de datosTestcontainersQueries realesLento
ComponentesTesting LibraryUI aisladaRapido
E2EPlaywrightFlujos completosLento

Siguiente paso

-> CI/CD con GitHub Actions para correr tus tests en cada push