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.
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
| Contexto | Por qué encaja |
|---|---|
| Pipelines de CI/CD | Feedback rápido: los unitarios corren en segundos en cada commit |
| Equipos que refactorizan seguido | Tests unitarios baratos dan confianza sin frenar la iteración |
| Microservicios | Una pirámide por servicio mantiene el runtime acotado al crecer |
| Proyectos de larga vida | Los E2E son caros de mantener; la pirámide los minimiza |
| Problemas de flakiness | Menos 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
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.
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:
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
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 | Énfasis | Mejor encaje |
|---|---|---|
| Pirámide | Unitario ≫ integración ≫ E2E | Apps backend y full-stack típicas |
| Trophy | Integración primero, base estática, E2E fino | Frontends JavaScript, UIs con muchos componentes |
| Honeycomb | Integración alrededor de los bordes del servicio | Microservicios con dependencias reales en test |
| Ice cream cone | Manual + E2E dominantes | Anti-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:
- 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.
- 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.
- 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.
- Borrá hacia abajo: eliminá los tests E2E cuyos escenarios ya cubren capas inferiores. Quedate solo con los journeys que genuinamente necesitan el stack completo.
- 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.
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
- 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.
- 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.
- 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.
- 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.
- 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. - 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
- The Test Pyramid — El bliki de Martin Fowler sobre el modelo, con el encuadre de confianza versus velocidad.
- Succeeding with Agile — El libro de Mike Cohn donde se introdujo la pirámide de automatización de tests.
- Write tests. Not too many. Mostly integration. — Kent C. Dodds desarmando la cita de Guillermo Rauch que inspiró el trophy.
- The Testing Trophy and Testing Classifications — El modelo trophy para testing de frontend.
- Testing of Microservices — El honeycomb en Spotify.
- Just Say No to More End-to-End Tests — Google Testing Blog sobre por qué las suites pesadas de E2E fallan.
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.
Recursos Relacionados
Patrón Test Double: Reemplazar Dependencias con Stubs,
Cómo usar test doubles para aislar unidades bajo test. Cubre stubs, spies, fakes, mocks y dummy objects con ejemplos en Python, JavaScript y Java.
PatternPatrón Contract Testing
Cómo usar contract testing para verificar que producers y consumers de API acuerdan en shapes de request y response. Cubre Pact consumer-driven contracts y provider verification.
PatternSnapshot Testing: Captura y Compara la Salida Serializada
Cómo usar el snapshot testing para detectar cambios no deseados en la salida serializada. Cubre snapshots de Jest, pytest-snapshot, syrupy y snapshots inline vs externos.
PatternGolden Master Testing: Caracteriza Legacy Code
Cómo usar golden master testing para caracterizar comportamiento de legacy code antes de refactorizar. Cubre captura de output, comparación de baselines, e incremental refactoring.
PatternPatrón Mock Server: Mock Server para Aislamiento de
Cómo usar mock servers para aislar integration tests de dependencias externas. Cubre WireMock, nock, MSW, y Mountebank con ejemplos de configuración.
GuideGuía de Estrategia de Testing
Una guía práctica para construir una estrategia de testing en capas con unit, integration y end-to-end tests.