Patrón Test Pyramid: Balancear Proporciones de Unit,
Cómo estructurar un test suite usando la test pyramid. Cubre proporciones de unit, integration y E2E tests, la testing trophy, y el anti-pattern ice cream cone.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Overview
La test pyramid es una estrategia para estructurar automated test suites. Mike Cohn introdujo el concepto en “Succeeding with Agile” (2009): muchos unit tests rápidos en la base, menos integration tests en el medio, y muy pocos E2E tests lentos arriba. El goal es máximo confidence con mínimo execution time. Con los años, surgieron shapes alternativas — la testing trophy (Guillermo Rauch) y el ice cream cone anti-pattern — cada una addressando diferentes realidades de proyecto.
When to Use
- Establecer una test strategy para un proyecto nuevo
- Auditar un test suite existente para balance y coverage
- Convencer a un equipo de invertir en unit tests vs solo E2E tests
- Setear time budgets para CI/CD pipelines
- Decidir qué tipo de test escribir para una feature nueva
When NOT to Use
- Scripts o prototypes pequeños — unos pocos unit tests alcanzan
- Legacy systems sin tests — empezá con characterization tests, no una pyramid
- Proyectos donde E2E tests son la única opción feasible (e.g., testear una third-party integration)
- Cuando el equipo no tiene skills para unit testing — invertí en training primero
Solution
La test pyramid clásica
/\
/ \ E2E (5-10%)
/----\ Lento, brittle, high confidence
/ \
/--------\ Integration (20-30%)
/ \ Velocidad media, confidence media
/------------\
/ \ Unit (60-70%)
/----------------\ Rápido, aislado, low confidence por test
Unit tests (base de la pyramid)
# Python — unit test example
import pytest
def test_calculate_discount():
result = calculate_discount(price=100, discount_percent=20)
assert result == 80
def test_calculate_discount_zero():
result = calculate_discount(price=100, discount_percent=0)
assert result == 100
def test_calculate_discount_full():
result = calculate_discount(price=100, discount_percent=100)
assert result == 0
def test_calculate_discount_negative_price():
with pytest.raises(ValueError):
calculate_discount(price=-100, discount_percent=20)
// JavaScript — unit test example
describe('calculateDiscount', () => {
test('applies percentage discount', () => {
expect(calculateDiscount(100, 20)).toBe(80);
});
test('handles zero discount', () => {
expect(calculateDiscount(100, 0)).toBe(100);
});
test('handles full discount', () => {
expect(calculateDiscount(100, 100)).toBe(0);
});
test('rejects negative price', () => {
expect(() => calculateDiscount(-100, 20)).toThrow(ValueError);
});
});
Integration tests (medio de la pyramid)
# Python — integration test con real database
import pytest
from myapp.db import get_session
from myapp.repositories import UserRepository
from myapp.services import UserService
@pytest.fixture
def db_session():
session = get_session("sqlite:///:memory:")
session.create_tables()
yield session
session.drop_all()
def test_user_persists_across_service_calls(db_session):
repo = UserRepository(db_session)
service = UserService(repo)
# Create
user = service.create_user("Alice", "alice@x.com")
user_id = user.id
# Read desde same session (integration: service + repository + db)
found = service.get_user(user_id)
assert found.name == "Alice"
# Update
service.update_name(user_id, "Alice Smith")
updated = service.get_user(user_id)
assert updated.name == "Alice Smith"
// JavaScript — integration test con real API
import { request } from 'supertest';
import { app } from '../app';
import { testDb } from '../test/db';
describe('POST /api/users', () => {
beforeEach(async () => {
await testDb.truncate('users');
});
test('creates user and retrieves it', async () => {
// Create
const createRes = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@x.com' })
.expect(201);
// Retrieve
const getRes = await request(app)
.get(`/api/users/${createRes.body.id}`)
.expect(200);
expect(getRes.body.name).toBe('Alice');
expect(getRes.body.email).toBe('alice@x.com');
});
test('rejects duplicate email', async () => {
await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@x.com' })
.expect(201);
const res = await request(app)
.post('/api/users')
.send({ name: 'Bob', email: 'alice@x.com' })
.expect(409);
expect(res.body.error.code).toBe('DUPLICATE_EMAIL');
});
});
E2E tests (tope de la pyramid)
// JavaScript — Playwright E2E test
import { test, expect } from '@playwright/test';
test('user can register and login', async ({ page }) => {
// Navigate a registration
await page.goto('https://app.example.com/register');
// Fill registration form
await page.fill('[data-testid=name]', 'Alice');
await page.fill('[data-testid=email]', 'alice@x.com');
await page.fill('[data-testid=password]', 'SecurePass123!');
await page.click('[data-testid=submit]');
// Verify redirect a dashboard
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.locator('h1')).toHaveText('Welcome, Alice');
// Logout
await page.click('[data-testid=logout]');
// Login
await page.goto('https://app.example.com/login');
await page.fill('[data-testid=email]', 'alice@x.com');
await page.fill('[data-testid=password]', 'SecurePass123!');
await page.click('[data-testid=submit]');
await expect(page).toHaveURL(/\/dashboard$/);
});
La testing trophy (Guillermo Rauch)
/\
/ \ E2E (pocos)
/----\
/ \
/--------\ Integration (muchos) — el medio ancho
/ \
/------------\
/ \ Unit (algunos)
/----------------\
La testing trophy invierte el énfasis: más integration tests, menos unit tests. La rationale es que los integration tests proveen higher confidence para aplicaciones modernas donde la complexity está en el wiring, no en funciones individuales.
El ice cream cone (anti-pattern)
/----------------\ E2E (muchos) — lento, brittle, expensive
\ /
\------------/
\ / Integration (pocos)
\--------/
\ /
\----/
\ / Unit (pocos)
\/
El ice cream cone es el anti-pattern más común: los equipos dependen de E2E tests porque son fáciles de escribir (no mocking) pero el suite se vuelve lento y flaky.
Midiendo tu pyramid
# Python — contar tests por tipo
import os
import re
from pathlib import Path
def count_tests_by_type(project_root):
counts = {"unit": 0, "integration": 0, "e2e": 0}
for path in Path(project_root).rglob("test_*.py"):
content = path.read_text()
test_count = len(re.findall(r"def test_", content))
if "e2e" in str(path) or "e2e" in str(path.parent):
counts["e2e"] += test_count
elif "integration" in str(path) or "integration" in str(path.parent):
counts["integration"] += test_count
else:
counts["unit"] += test_count
total = sum(counts.values())
for test_type, count in counts.items():
pct = (count / total * 100) if total else 0
print(f"{test_type}: {count} ({pct:.1f}%)")
return counts
# Output:
# unit: 245 (68.2%)
# integration: 89 (24.8%)
# e2e: 25 (7.0%)
CI/CD pipeline con pyramid stages
# .github/workflows/test-pipeline.yml
name: Test Pipeline
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- name: Run unit tests (deberían ser rápidos)
run: pytest tests/unit/ -v --tb=short
- name: Check unit test time
run: |
TIME=$(pytest tests/unit/ --durations=0 2>&1 | grep "passed in" | grep -oP '[\d.]+')
echo "Unit tests took ${TIME}s"
# Fails si unit tests toman más de 30 segundos
if (( $(echo "$TIME > 30" | bc -l) )); then
echo "FAIL: Unit tests too slow"
exit 1
fi
integration-tests:
needs: unit-tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- name: Run integration tests
run: pytest tests/integration/ -v
env:
DATABASE_URL: postgresql://postgres:test@localhost:5432/test
e2e-tests:
needs: integration-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Start app
run: |
npm run build
npm run start &
sleep 5
- name: Run E2E tests
run: npx playwright test
Guía de decisión de test type
# Framework de decisión:
# 1. Es una pure function sin dependencies? → UNIT TEST
# 2. Involucra múltiples components pero no external services? → INTEGRATION TEST
# 3. Verifica un user journey through el full system? → E2E TEST
# 4. Verifica un API contract entre servicios? → CONTRACT TEST
# 5. Verifica una UI interaction específica? → COMPONENT TEST
# Mapping de ejemplo:
# calculate_discount() → UNIT
# UserService + UserRepository → INTEGRATION
# User registers and logs in → E2E
# order-service ↔ user-service → CONTRACT
# Button click shows dropdown → COMPONENT
Variants
El testing honeycomb
/\
/ \ E2E (pocos)
/----\
/ \
/--------\ Integration (muchos)
/ \ Unit (algunos)
/------------\
Variación de Adam Tornhill: los integration tests forman el medio ancho, los unit tests son menos pero todavía significant. Suits aplicaciones donde la business logic vive en interacciones entre components.
Component tests (React)
// JavaScript — component test (entre unit e integration)
import { render, screen, fireEvent } from '@testing-library/react';
import { UserForm } from './UserForm';
test('form submits with entered values', () => {
const onSubmit = jest.fn();
render(<UserForm onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'Alice' },
});
fireEvent.change(screen.getByLabelText('Email'), {
target: { value: 'alice@x.com' },
});
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(onSubmit).toHaveBeenCalledWith({
name: 'Alice',
email: 'alice@x.com',
});
});
Smoke tests (E2E minimal)
# Python — smoke tests: critical paths only
import pytest
import requests
BASE_URL = "http://localhost:8000"
class TestSmoke:
"""Corre después de cada deploy — verifica que la app esté alive."""
def test_health_check(self):
res = requests.get(f"{BASE_URL}/health")
assert res.status_code == 200
assert res.json()["status"] == "healthy"
def test_home_page_loads(self):
res = requests.get(f"{BASE_URL}/")
assert res.status_code == 200
def test_api_responds(self):
res = requests.get(f"{BASE_URL}/api/v1/status")
assert res.status_code == 200
Best Practices
-
For a deeper guide, see Software Testing Strategy Guide.
-
Apuntá a 70/25/5 — unit/integration/E2E como starting point, ajustá a tu contexto
-
Los unit tests deberían correr en segundos — si toman minutos, son integration tests
-
Los E2E tests deberían cubrir critical paths only — login, checkout, core workflow
-
Corré unit tests en cada save — fast feedback loop
-
Corré integration tests en cada push — feedback medio
-
Corré E2E tests en cada PR merge — lento pero thorough
-
Taggeá tests por tipo —
@pytest.mark.unit,@pytest.mark.integration,@pytest.mark.e2e -
Trackeá flaky tests — los E2E tests son la source más común de flakiness
-
No persigas 100% coverage — 70-80% con buena distribución es mejor que 100% unit-only
Common Mistakes
- Ice cream cone: demasiados E2E tests, muy pocos unit tests. El suite es lento, flaky, y expensive de mantener.
- All unit, no integration: los unit tests pasan pero la app rompe en producción porque los components no se wire correctamente.
- E2E testear todo: usar E2E tests para lógica que podría ser unit tested. Cada E2E test agrega minutos al CI.
- No taggear tests: no podés correr solo unit tests o solo integration tests. Todo corre junto, slowing feedback.
- Ignorar flaky tests: los flaky E2E tests erosionan trust en el suite. Fixealos o removelos inmediatamente.
FAQ
¿Qué es la test pyramid?
Una estrategia para estructurar test suites: muchos unit tests rápidos en la base, menos integration tests en el medio, y muy pocos E2E tests lentos arriba. El goal es máximo confidence con mínimo execution time.
¿Qué es la testing trophy?
Una alternativa a la pyramid propuesta por Guillermo Rauch. Enfatiza más integration tests y menos unit tests, argumentando que los integration tests proveen higher confidence para aplicaciones modernas.
¿Qué es el ice cream cone anti-pattern?
Cuando un test suite tiene muchos E2E tests y pocos unit tests. Esto resulta en test suites lentos, flaky, que son expensive de mantener y erosionan developer trust.
¿Cuántos E2E tests debería tener?
Suficientes para cubrir critical user journeys — típicamente 5-15 tests. Login, registration, checkout, core workflow. No E2E testees cada edge case — para eso están los unit tests.
¿Debería usar la pyramid o la trophy?
Depende. Usá la pyramid para libraries, CLI tools, y apps con business logic complex. Usá la trophy para web apps donde la complexity está en wirear components juntos. Adaptá a tu contexto.
Recursos Relacionados
Test Double: Replace Dependencies with Stubs, Spies, Fakes
How to use test doubles to isolate units under test. Covers stubs, spies, fakes, mocks, and dummy objects with examples in Python, JavaScript, and Java.
PatternContract Testing: Verify Consumer-Producer API Contracts
How to use contract testing to verify that API producers and consumers agree on request and response shapes. Covers Pact consumer-driven contracts and provider verification.
PatternSnapshot Testing: Capture and Compare Serialized Output
How to use snapshot testing to detect unintended changes in serialized output. Covers Jest snapshots, pytest snapshot, and inline vs external snapshots.
PatternGolden Master Testing
How to use golden master testing to characterize legacy code behavior before refactoring. Covers capturing output, comparing baselines, and incremental refactoring.
PatternMock Server: Stand Up a Mock Server for Integration Test
How to use mock servers to isolate integration tests from external dependencies. Covers WireMock, nock, MSW, and Mountebank with configuration examples.