StackPractices
intermediate Por Mathias Paulenko

Pirámide de Tests: Unit, Integration y E2E

Cómo estructurar una suite de tests con la pirámide de testing. Cubre las proporciones de tests unitarios, de integración y E2E, el testing trophy y el anti-patrón ice cream cone.

Temas: testing

Overview

La pirámide de tests, acuñada por Mike Cohn en Succeeding with Agile (2009) y popularizada después por el bliki de Martin Fowler, define la proporción de tests en cada nivel de abstracción. Los tests unitarios forman la base ancha: ejercitan una función o clase en milisegundos y casi no cuestan ejecutar. Los tests de integración ocupan el medio, más angosto: cruzan una frontera real como una base de datos, un filesystem o un cliente HTTP. Los tests end-to-end son la punta: levantan la aplicación completa y la manejan por un browser o cliente API, así que un puñado de ellos puede costar más que miles de unitarios juntos.

El punto no es la geometría — es la economía que hay debajo. Los tests rápidos pueden correr en cada commit; los que tardan minutos solo tienen sentido como chequeo final de confianza antes de un deploy. Si las proporciones están bien, los desarrolladores ven los errores segundos después de introducirlos y la suite es lo bastante barata como para que nadie proponga borrarla. Si están mal, heredás una suite lenta, frágil y silenciosamente ignorada.

Un caso concreto: una suite de 400 tests donde 300 son unitarios, 80 de integración y 20 E2E suele terminar en menos de cinco minutos. La misma cobertura expresada como 200 tests E2E puede tardar una hora y fallar solo por timing.

Para una visión más amplia de cómo encaja la pirámide en una estrategia completa — objetivos de cobertura, presupuesto de flakiness, ownership — mirá la Guía de Estrategia de Testing.

When to Use

ContextoPor qué encaja
Pipelines de CI/CDFeedback rápido: los unitarios corren en segundos en cada commit
Equipos que refactorizan seguidoTests unitarios baratos dan confianza sin frenar la iteración
MicroserviciosUna pirámide por servicio mantiene el runtime acotado al crecer
Proyectos de larga vidaLos E2E son caros de mantener; la pirámide los minimiza
Problemas de flakinessMenos dependencia de tests E2E sensibles al timing

Una advertencia que conviene hacer desde el principio: la pirámide es el default para aplicaciones de negocio típicas, no una ley universal. Los frontends con mucha UI y las arquitecturas de microservicios suelen andar mejor con las variantes descritas más abajo.

When NOT to Use

  • Frontends con mucha UI: Los tests de componente e integración suelen dar mejor retorno que los unitarios puros en interfaces de usuario.
  • Microservicios: El testing honeycomb (más tests de integración alrededor de los bordes del servicio) puede encajar mejor que una pirámide estricta.
  • Prototipos: Si el código se va a tirar la semana que viene, una pirámide completa es sobre-inversión.
  • CRUD simples: Con casi nada de lógica de negocio, una suite chica de integración más unos pocos smoke tests E2E puede alcanzar.

Problem

La mayoría de los equipos no elige una suite desbalanceada — derrapa hacia ella. Los tests E2E se sienten como testing “de verdad”, así que cada bug reportado se convierte en otro test de browser. Seis meses después la suite tarda 45 minutos, falla dos veces por semana por razones que nadie puede reproducir, y los desarrolladores pushean sin esperar a CI. Los números de cobertura se ven bien mientras la confianza real se desploma.

La pirámide de tests ataca ese derrape haciendo explícita la estructura de costos de la suite. Le da al equipo un vocabulario compartido — “esto va en la capa unitaria” — para discutir dónde debe vivir un test antes de escribirlo, en vez de después de que ya se volvió flaky.

Solution

La pirámide clásica

Pirámide de tests de tres capas: una base ancha de tests unitarios del 60 al 70 por ciento, un medio más angosto de tests de integración del 20 al 30 por ciento, y una punta chica de tests end-to-end del 5 al 10 por ciento.

Cada capa intercambia velocidad por confianza. Un test unitario te dice qué función se rompió, casi al instante. Un test E2E te dice que algo se rompió, eventualmente, y te deja buscando dónde. La pirámide mantiene dominante la señal rápida y precisa, y reserva los chequeos lentos de alta confianza para los caminos críticos.

Tests unitarios — la base

Rápidos, aislados, deterministas. Sin I/O, sin red, sin base de datos:

// calculator.test.js — Vitest / Jest
import { describe, it, expect } from 'vitest';
import { parsePrice, applyDiscount } from './pricing.js';

describe('parsePrice', () => {
  it('parses a valid decimal price', () => {
    expect(parsePrice('19.99')).toBe(19.99);
  });

  it('rejects non-numeric input', () => {
    // toThrow acepta una clase de Error, una instancia o un substring del mensaje
    expect(() => parsePrice('abc')).toThrow('Invalid price');
  });

  it('rejects negative prices', () => {
    expect(() => parsePrice('-5')).toThrow('Invalid price');
  });
});

describe('applyDiscount', () => {
  it('applies a percentage discount', () => {
    expect(applyDiscount(100, 0.1)).toBeCloseTo(90);
  });

  it('never returns a negative total', () => {
    expect(applyDiscount(50, 1.5)).toBe(0);
  });
});
# test_pricing.py — pytest
import pytest
from pricing import parse_price, apply_discount

class TestParsePrice:
    def test_parses_valid_decimal(self):
        assert parse_price('19.99') == 19.99

    def test_rejects_non_numeric(self):
        with pytest.raises(ValueError, match='Invalid price'):
            parse_price('abc')

class TestApplyDiscount:
    def test_applies_percentage(self):
        assert apply_discount(100, 0.1) == pytest.approx(90)

    def test_never_negative(self):
        assert apply_discount(50, 1.5) == 0

Tests de integración — el medio

Prueban cómo trabajan juntos los componentes. Usá dependencias reales donde se pueda — una base de datos de test real en lugar de un mock, un mock server en lugar de un cliente HTTP mockeado:

// orderRepository.integration.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { OrderRepository } from './OrderRepository';
import { createTestDatabase } from './testDatabase';

describe('OrderRepository (integration)', () => {
  let db: TestDatabase;
  let repo: OrderRepository;

  beforeEach(async () => {
    db = await createTestDatabase(); // Postgres real en Docker
    repo = new OrderRepository(db);
  });

  afterEach(() => db.teardown());

  it('persists and retrieves an order', async () => {
    const order = await repo.create({ userId: 'u1', total: 99.5 });
    const found = await repo.findById(order.id);

    expect(found).toMatchObject({ userId: 'u1', total: 99.5 });
  });

  it('enforces unique order ids', async () => {
    await repo.create({ id: 'o1', userId: 'u1', total: 10 });
    await expect(
      repo.create({ id: 'o1', userId: 'u2', total: 20 }),
    ).rejects.toThrow(/unique/i);
  });
});
# test_order_repository.py — pytest + testcontainers
import pytest
from order_repository import OrderRepository, DuplicateOrderError

@pytest.fixture
def repo(postgres_container):
    db = postgres_container.get_connection()
    db.run_migrations()
    yield OrderRepository(db)
    db.teardown()

def test_persists_and_retrieves_order(repo):
    order = repo.create(user_id='u1', total=99.5)
    found = repo.find_by_id(order.id)
    assert found.user_id == 'u1'
    assert found.total == 99.5

def test_enforces_unique_order_ids(repo):
    repo.create(id='o1', user_id='u1', total=10)
    with pytest.raises(DuplicateOrderError):
        repo.create(id='o1', user_id='u2', total=20)

Tests E2E — la punta

Prueban solo flujos completos de usuario. Mantenelos pocos y enfocados en los caminos críticos del negocio:

// checkout.e2e.spec.ts — Playwright
import { test, expect } from '@playwright/test';

test.describe('Checkout flow', () => {
  test('user completes a purchase', async ({ page }) => {
    await page.goto('/products');
    await page.click('[data-testid="product-1"]');
    await page.click('[data-testid="add-to-cart"]');
    await page.click('[data-testid="checkout"]');

    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="card"]', '4242424242424242');
    await page.click('[data-testid="pay"]');

    await expect(page.locator('.order-confirmation'))
      .toContainText('Order confirmed');
  });
});
# test_checkout_e2e.py — Playwright Python
from playwright.sync_api import sync_playwright, expect

def test_user_completes_purchase():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()

        page.goto('http://localhost:3000/products')
        page.click('[data-testid="product-1"]')
        page.click('[data-testid="add-to-cart"]')
        page.click('[data-testid="checkout"]')
        page.fill('[name="email"]', 'test@example.com')
        page.click('[data-testid="pay"]')

        expect(page.locator('.order-confirmation')) \
            .to_contain_text('Order confirmed')
        browser.close()

El testing trophy

Kent C. Dodds propuso el testing trophy para trabajo de frontend, a partir del tweet de Guillermo Rauch: “Write tests. Not too many. Mostly integration.” Corre el énfasis: una base de análisis estático, una capa de integración dominante, menos tests unitarios y un conjunto mínimo de E2E.

Testing trophy de cuatro capas: una punta chica de tests end-to-end, una capa grande de integración en el medio, una capa más chica de unitarios, y una base ancha de análisis estático con tipos y linting.

Encaja con frontends JavaScript donde los tests estilo React Testing Library ejercitan los componentes como los vive el usuario — renderizar, clickear, asertar sobre el DOM — que queda entre el unitario clásico y el E2E. Dodds detalla la clasificación en The Testing Trophy and Testing Classifications.

El anti-patrón ice cream cone

Lo que pasa cuando las proporciones se invierten:

Forma de cono de helado invertido: una bocha grande de testing manual arriba, después una capa ancha de tests end-to-end, y cantidades chicas de tests de integración y unitarios abajo.

Las suites así parecen exhaustivas y se comportan pésimo: horas de runtime, fallos que aparecen y desaparecen con el timing, y una factura de mantenimiento que crece con cada cambio de UI. El blog de testing de Google argumentó el punto de fondo en Just Say No to More End-to-End Tests: una vez que tenés tests rápidos debajo, la mayoría de los chequeos end-to-end agregan poca señal.

Guía de decisión por tipo de test

Diagrama de decisión para elegir el tipo de test. Lógica pura sin dependencias lleva a test unitario. Cruzar una frontera de base de datos o red lleva a test de integración. Un journey crítico de usuario lleva a test end-to-end. Los tests lentos o flaky hay que bajarlos a una capa inferior.

Algunas reglas prácticas que acompañan bien al diagrama:

  • Unitario: lógica de negocio, validaciones, cálculos, máquinas de estado, edge cases.
  • Integración: contratos de API, queries a la base, flujos de auth, colas de mensajes.
  • E2E: registro, checkout, el camino central de ingresos — journeys donde una falla significa perder plata o usuarios.

Medir tu pirámide

Contá los tests por tipo y compará la distribución:

# Conteo aproximado por convención de directorios
echo "Unit:        $(find tests/unit -name '*.test.*' | wc -l)"
echo "Integration: $(find tests/integration -name '*.test.*' | wc -l)"
echo "E2E:         $(find tests/e2e -name '*.spec.*' | wc -l)"
# report_test_distribution.py
from pathlib import Path
import re

def count_tests(directory: str, pattern: str) -> int:
    path = Path(directory)
    if not path.exists():
        return 0
    return sum(
        len(re.findall(r'\b(it|test|def test_)\s*\(', f.read_text()))
        for f in path.rglob(pattern)
    )

unit = count_tests('tests/unit', '*.py')
integration = count_tests('tests/integration', '*.py')
e2e = count_tests('tests/e2e', '*.py')
total = unit + integration + e2e or 1

print(f"Unit:        {unit:4} ({unit/total:.0%})")
print(f"Integration: {integration:4} ({integration/total:.0%})")
print(f"E2E:         {e2e:4} ({e2e/total:.0%})")

if e2e / total > 0.15:
    print("\nWARNING: los tests E2E superan el 15% — revisá si hay ice cream cone")

Integración con CI/CD

Corré cada capa en una etapa distinta del pipeline:

# .github/workflows/tests.yml
name: Test Pyramid

on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:unit  # < 30 segundos

  integration:
    runs-on: ubuntu-latest
    needs: unit
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        ports: ['5432:5432']
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:integration  # < 3 minutos

  e2e:
    runs-on: ubuntu-latest
    needs: integration
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run build && npm run start &
      # wait-on hace polling a la URL; nada de sleep fijo que flaquea en runners lentos
      - run: npx wait-on http://localhost:3000 --timeout 30000
      - run: npm run test:e2e  # < 10 minutos

Explanation

Por qué existen las proporciones

La forma de la pirámide sale de dos costos que escalan en direcciones opuestas. El costo de ejecución sube al subir: un test unitario termina en microsegundos, uno de integración tarda decenas o cientos de milisegundos mientras habla con una base real, y un E2E tarda segundos o minutos porque levanta un browser, un servidor y datos de verdad. El costo de diagnóstico hace lo contrario: un unitario que falla nombra la función rota; un E2E que falla te dice que el flujo de compra está roto en algún lugar de una docena de servicios, y todavía tenés que ir a encontrarlo.

Con una base gruesa, la mayoría de los defectos los atrapan tests que son a la vez rápidos de correr y precisos de diagnosticar. Con la punta pesada, los mismos defectos se atrapan tarde, lento y vagamente — y cada refactor de UI rompe una pila de tests que estaban chequeando incidentalmente la implementación en vez del comportamiento.

Los ratios son punto de partida, no cuota

Cohn nunca quiso que 70/20/10 fuera un contrato. La distribución correcta sigue al riesgo, no a la geometría. Un servicio de facturación con mucha lógica de cálculo quiere una capa unitaria profunda; un API gateway fino casi no tiene lógica que testear unitariamente y gana su valor en tests de contrato e integración. La disciplina útil es preguntar “¿dónde se atrapa esta falla más barato?”, no auditar porcentajes.

Hay dos señales que importan más que el ratio en sí: el runtime total de la suite (si los desarrolladores esperan más de unos pocos minutos, dejan de esperar) y la tasa de flakiness (si el mismo test falla intermitente, le está enseñando al equipo a ignorar los fallos). Una suite que puntúa bien en esas dos está sana tenga la forma que tenga.

Comparación de las formas

FormaÉnfasisMejor encaje
PirámideUnitario ≫ integración ≫ E2EApps backend y full-stack típicas
TrophyIntegración primero, base estática, E2E finoFrontends JavaScript, UIs con muchos componentes
HoneycombIntegración alrededor de los bordes del servicioMicroservicios con dependencias reales en test
Ice cream coneManual + E2E dominantesAnti-patrón — lo que produce el derrape

Ninguna discute la economía de fondo; difieren sobre dónde está la mayor confianza por segundo para cada tipo de sistema.

Migrar desde un ice cream cone

Si tu suite ya está invertida, reescribir no suele ser viable — migrá incrementalmente:

  1. Inventario: etiquetá cada test por capa y runtime. La mayoría de los equipos descubre que un puñado de tests E2E produce la mayor parte del tiempo de reloj.
  2. Cuarentena de flakes: mové los tests intermitentes a un job no bloqueante con fecha límite para arreglarlos o borrarlos. No los dejes envenenando CI.
  3. Caracterizá los caminos críticos: para los flujos que el cono realmente protege, escribí primero los tests unitarios y de integración que faltan — así la cobertura existe antes de borrar nada.
  4. Borrá hacia abajo: eliminá los tests E2E cuyos escenarios ya cubren capas inferiores. Quedate solo con los journeys que genuinamente necesitan el stack completo.
  5. Ratchet: aplicá presupuestos en CI — por ejemplo, topes de cantidad y runtime de E2E — para que la suite no pueda invertirse en silencio otra vez.

Variants

Testing honeycomb

Propuesto por André Schaffer y Rickard Dybeck para los microservicios de Spotify (Testing of Microservices). En un servicio cuya complejidad significativa vive en sus interacciones, el honeycomb invierte la pirámide: muchos tests de integración alrededor de los bordes propios del servicio, unos pocos tests de “implementation detail” para lógica genuinamente aislada, y casi ningún test “integrated” que dependa de otros sistemas vivos.

Distribución del testing honeycomb: una capa grande de tests de integración en el medio, una cantidad menor de tests de detalle de implementación, y muy pocos o ningún test integrado que dependa de otros servicios vivos.

Tests de componente

Una capa intermedia entre unitario y E2E, común en aplicaciones React, Vue y Angular:

// UserProfile.component.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './UserProfile';

it('saves profile changes', async () => {
  const onSave = vi.fn();
  render(<UserProfile user={mockUser} onSave={onSave} />);

  await userEvent.type(screen.getByLabelText('Name'), 'Ada');
  await userEvent.click(screen.getByRole('button', { name: 'Save' }));

  expect(onSave).toHaveBeenCalledWith({ ...mockUser, name: 'Ada' });
});

Smoke tests

Una capa E2E mínima para verificación de deploy:

# smoke-test.sh — corré después de cada deploy
curl -sf https://api.example.com/health || exit 1
curl -sf https://api.example.com/api/status | jq -e '.db == "up"' || exit 1
echo "Smoke tests passed"

Best Practices

  1. Empujá los tests hacia abajo siempre que puedas — Cuando un bug se escapa, agregá el test más barato que lo hubiera atrapado; casi nunca es un E2E.
  2. Mantené los unitarios herméticos — Sin I/O, sin red, sin estado mutable compartido. Un unitario que toca una base de datos es un test de integración con nombre engañoso.
  3. Usá test doubles deliberadamente — Mocks en los bordes de capa, no adentro de la unidad bajo test. Mirá el patrón Test Double para ver cuándo stubs, mocks y fakes justifican su complejidad.
  4. Poné time-box a cada capa — Unitarios debajo de 30 segundos, integración debajo de 3 minutos, E2E debajo de 10. Cuando una capa se pasa del presupuesto, eso es señal: los tests se están acumulando en el lugar equivocado.
  5. Tagueá los tests por capa — @pytest.mark.integration, test.describe('e2e'), convenciones de directorio. Los tags son lo que hace posible presupuestos y reportes por capa.
  6. Borrá los tests flaky de inmediato — Un test que falla aleatoriamente entrena al equipo a ignorar toda la suite. Cuarentena, fix o eliminación en días, no en trimestres.

Código companion: El repo companion de test-pyramid-pattern tiene tests unitarios ejecutables en JavaScript y Python, el spec E2E de Playwright, el script de reporte de distribución y el workflow de CI por capas.

Common Mistakes

  • El ice cream cone: Invertir la pirámide — mayoría de E2E y QA manual. Resultado: suites lentas, flakes constantes y releases frenados por miedo.
  • Tests unitarios de código trivial: Getters, setters y métodos pass-through no necesitan tests dedicados; quedan cubiertos gratis por los tests de comportamiento real.
  • El E2E navaja suiza: Usar tests de browser para cubrir edge cases. Las condiciones de borde van en tests unitarios donde corren en microsegundos.
  • Mockear de más: Mockear tanto del sistema que los tests solo verifican los mocks. Cuando un test necesita más de dos o tres doubles, suele querer ser un test de integración.
  • Distribución congelada: Poner 70/25/5 como objetivo permanente en vez de hipótesis inicial. Volvé a medir a medida que cambia la arquitectura.
  • E2E sin las capas de abajo: Correr solo tests end-to-end produce fallos que tardan horas en diagnosticarse — la peor combinación de lento y vago.

See Also

Preguntas frecuentes

¿Cómo reparto los tests entre unitarios, integración y E2E?

Usá el default de la pirámide — más o menos 60–70% unitarios, 20–30% de integración, 5–10% E2E — como hipótesis inicial, y después dejá que decida el riesgo. Poné cada test en la capa más barata que pueda atrapar la falla a la que apunta: las reglas de negocio van a la base, el comportamiento de fronteras al medio y solo los journeys críticos de usuario a la punta.

¿Cuál es la diferencia entre la pirámide y el testing trophy?

El trophy, de Kent C. Dodds, pone los tests de integración en la capa más ancha en vez de los unitarios, y agrega una base de análisis estático (tipos, linting). Apunta a frontend JavaScript, donde los tests estilo Testing Library difuminan la frontera unitario/integración. La pirámide sigue siendo el mejor default para servicios backend.

¿Cómo arreglo una pirámide invertida (ice cream cone)?

Incrementalmente: inventariá los tests por capa y runtime, poné en cuarentena los flakes, escribí primero cobertura unitaria y de integración para los caminos críticos, después borrá los tests E2E redundantes — y aplicá presupuestos por capa en CI para que la suite quede arreglada.

¿Los microservicios deberían usar la pirámide de tests?

Normalmente una variante por servicio. El honeycomb de Spotify mantiene muchos tests de integración alrededor de los bordes propios de cada servicio — su API, su base de datos, sus consumidores de mensajes — y casi ninguno que levante otros servicios vivos. La pirámide del sistema completo sigue aplicando, pero cada servicio lleva su propia forma más chica adentro.

¿Cuántos tests E2E son demasiados?

Un techo práctico es 5–10% de la suite. La prueba más fina es de intención: si un test E2E chequea algo que un unitario o de integración podría verificar — una regla de validación, un cálculo — pertenece a una capa más baja.

¿Los tests de integración necesitan bases de datos reales?

Donde sea práctico, sí — un Postgres real en un contenedor atrapa bugs de SQL que los mocks estructuralmente no pueden ver (violaciones de constraints, semántica de queries, coerción de tipos). Mockeá en las fronteras de proceso que no controlás: APIs de terceros, pasarelas de pago, proveedores de email.