Everyone agrees testing is important. Few agree on how much, what kind, and where to focus. The testing pyramid provides a framework: many fast unit tests, fewer integration tests, even fewer end-to-end tests.

But the pyramid only works if you understand why each layer exists.

The Pyramid

EIU2nnEtietTgerTsaettssitos(nf(eTmwea,sntyss,lo(fwsa,osmtee,x,pcemhneesdaiipvu)em)speed)

Unit tests: Test individual functions/classes in isolation Integration tests: Test components working together E2E tests: Test the full system like a user would

Unit Tests: The Foundation

Fast, focused, isolated:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# tests/test_pricing.py
import pytest
from pricing import calculate_discount

def test_no_discount_for_small_orders():
    assert calculate_discount(50) == 0

def test_five_percent_discount_over_100():
    assert calculate_discount(100) == 5

def test_ten_percent_discount_over_500():
    assert calculate_discount(500) == 50

def test_handles_zero():
    assert calculate_discount(0) == 0

def test_handles_negative():
    with pytest.raises(ValueError):
        calculate_discount(-10)

Characteristics:

  • Run in milliseconds
  • No database, network, or filesystem
  • Test one thing per test
  • Use mocks for external dependencies

Mocking Dependencies

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from unittest.mock import Mock, patch

def test_send_welcome_email():
    # Mock the email service
    mock_email = Mock()
    
    user_service = UserService(email_service=mock_email)
    user_service.create_user("alice@example.com")
    
    # Verify email was sent
    mock_email.send.assert_called_once_with(
        to="alice@example.com",
        template="welcome"
    )

@patch('services.payment_gateway')
def test_process_payment(mock_gateway):
    mock_gateway.charge.return_value = {"id": "ch_123", "status": "success"}
    
    result = process_order(order)
    
    assert result.charge_id == "ch_123"
    mock_gateway.charge.assert_called_once()

Integration Tests: The Middle Layer

Test real interactions between components:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# tests/integration/test_user_db.py
import pytest
from database import Database
from repositories import UserRepository

@pytest.fixture
def db():
    db = Database("postgresql://localhost/test_db")
    db.migrate()
    yield db
    db.clear()

@pytest.fixture
def user_repo(db):
    return UserRepository(db)

def test_create_and_retrieve_user(user_repo):
    # Test actual database operations
    user = user_repo.create(email="alice@example.com", name="Alice")
    
    retrieved = user_repo.get(user.id)
    
    assert retrieved.email == "alice@example.com"
    assert retrieved.name == "Alice"

def test_find_by_email(user_repo):
    user_repo.create(email="bob@example.com", name="Bob")
    
    found = user_repo.find_by_email("bob@example.com")
    
    assert found is not None
    assert found.name == "Bob"

API Integration Tests

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# tests/integration/test_api.py
import pytest
from fastapi.testclient import TestClient
from app import app

@pytest.fixture
def client():
    return TestClient(app)

def test_create_user_endpoint(client):
    response = client.post("/users", json={
        "email": "test@example.com",
        "name": "Test User"
    })
    
    assert response.status_code == 201
    assert response.json()["email"] == "test@example.com"

def test_create_user_invalid_email(client):
    response = client.post("/users", json={
        "email": "not-an-email",
        "name": "Test"
    })
    
    assert response.status_code == 422

E2E Tests: The Top Layer

Test the full user journey:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# tests/e2e/test_checkout.py
import pytest
from playwright.sync_api import Page

def test_complete_checkout_flow(page: Page):
    # Navigate to product
    page.goto("/products/widget")
    
    # Add to cart
    page.click("button:has-text('Add to Cart')")
    
    # Go to checkout
    page.click("a:has-text('Checkout')")
    
    # Fill shipping info
    page.fill("#email", "test@example.com")
    page.fill("#address", "123 Test St")
    page.fill("#city", "Test City")
    
    # Enter payment
    page.fill("#card-number", "4242424242424242")
    page.fill("#expiry", "12/25")
    page.fill("#cvc", "123")
    
    # Submit order
    page.click("button:has-text('Place Order')")
    
    # Verify success
    assert page.locator("h1:has-text('Order Confirmed')").is_visible()
    assert page.locator(".order-number").is_visible()

E2E test guidelines:

  • Test critical user journeys only
  • Keep them minimal (they’re slow and flaky)
  • Run in CI, not on every commit
  • Use stable selectors (data-testid, not CSS classes)

What to Test Where

Test TypeWhat to TestWhat NOT to Test
UnitBusiness logic, calculations, transformationsDatabase, APIs, UI
IntegrationDatabase queries, API contracts, service interactionsBusiness logic details
E2ECritical user journeys, smoke testsEdge cases, error handling

Test Organization

testsuiefc/nn2ioitexnte/tf/tttgtttttufteeereeeeeraesssasssssecstttttttttstt___i_____/o.pvuoupacsrpratnsaphiiyili/eyiegecilrm.cnsids_epku.na.rnyoppgtpetu.y.iyp_tppoos.yynsep.iryptvyoircye..ppyy

Test Speed Matters

1
2
3
4
5
6
7
8
# Run unit tests constantly (< 10 seconds)
pytest tests/unit/ -q

# Run integration tests before commit (< 2 minutes)
pytest tests/integration/

# Run E2E tests in CI only (< 10 minutes)
pytest tests/e2e/

Slow tests don’t get run. Fast tests catch bugs early.

Fixtures and Factories

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# tests/fixtures/factories.py
import factory
from models import User, Order

class UserFactory(factory.Factory):
    class Meta:
        model = User
    
    email = factory.Sequence(lambda n: f"user{n}@example.com")
    name = factory.Faker("name")

class OrderFactory(factory.Factory):
    class Meta:
        model = Order
    
    user = factory.SubFactory(UserFactory)
    total = factory.Faker("pydecimal", left_digits=3, right_digits=2)

# Usage
user = UserFactory()
order = OrderFactory(user=user, total=99.99)

Testing Async Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import pytest

@pytest.mark.asyncio
async def test_async_fetch():
    result = await fetch_user_data(user_id="123")
    assert result["id"] == "123"

@pytest.mark.asyncio
async def test_concurrent_operations():
    results = await asyncio.gather(
        fetch_data("a"),
        fetch_data("b"),
        fetch_data("c"),
    )
    assert len(results) == 3

Coverage: A Guide, Not a Goal

1
2
3
4
5
6
pytest --cov=src --cov-report=html

# Output
# src/pricing.py        95%
# src/validation.py     88%
# src/utils.py          72%

Coverage tells you what’s NOT tested. 100% coverage doesn’t mean no bugs — it means every line ran at least once.

Focus on:

  • Critical business logic: 90%+
  • API endpoints: 80%+
  • Utilities: 70%+

Testing Anti-Patterns

Testing Implementation, Not Behavior

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# ❌ Bad: Tests implementation details
def test_uses_cache():
    service = UserService()
    service.get_user("123")
    assert service._cache["123"] is not None

# ✅ Good: Tests behavior
def test_returns_same_user_on_second_call():
    service = UserService()
    user1 = service.get_user("123")
    user2 = service.get_user("123")
    assert user1 == user2

Flaky Tests

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# ❌ Bad: Depends on timing
def test_cache_expires():
    cache.set("key", "value", ttl=1)
    time.sleep(1.1)  # Flaky!
    assert cache.get("key") is None

# ✅ Good: Control time
def test_cache_expires(mock_time):
    cache.set("key", "value", ttl=60)
    mock_time.advance(61)
    assert cache.get("key") is None

Testing Everything in E2E

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# ❌ Bad: E2E test for validation logic
def test_email_validation_in_signup(page):
    page.goto("/signup")
    page.fill("#email", "invalid")
    page.click("button:submit")
    assert page.locator(".error").text == "Invalid email"

# ✅ Good: Unit test for validation
def test_email_validation():
    assert validate_email("invalid") == False
    assert validate_email("valid@example.com") == True

The testing pyramid works because each layer serves a purpose. Unit tests verify logic quickly. Integration tests verify contracts. E2E tests verify user journeys.

Write many fast unit tests. Write enough integration tests to verify boundaries. Write few E2E tests for critical paths. Run the fast tests constantly, the slow tests before merge.

Tests that don’t run don’t help. Speed and reliability matter as much as coverage.