Skip to content

Testing Pyramid for APIs — Unit, Integration, and E2E Testing Strategies

DodaTech Updated 2026-06-28 4 min read

In this tutorial, you will learn about Testing Pyramid for APIs. We cover key concepts, practical examples, and best practices to help you master this topic.

The testing pyramid for APIs recommends a balanced approach: many fast unit tests, fewer integration tests, and a small number of end-to-end tests to verify complete workflows.

What You'll Learn

The three layers of the API testing pyramid, appropriate test coverage for each layer, test execution speed trade-offs, and when to use each type of test.

Why It Matters

An unbalanced test suite wastes time and money. Too many E2E tests create slow, flaky CI pipelines. Too few unit tests miss bugs early. The pyramid guides efficient test investment.

Code Examples, Practice, FAQ, and Mini Project.

Code Example: Unit Test for API Validation Function

import pytest
from api.validation import validate_email, validate_threat_score

def test_validate_email_valid():
    assert validate_email("user@example.com") == True

def test_validate_email_invalid():
    assert validate_email("not-an-email") == False
    assert validate_email("") == False
    assert validate_email("@missing.com") == False

def test_validate_threat_score():
    assert validate_threat_score(85) == "high"
    assert validate_threat_score(50) == "medium"
    assert validate_threat_score(20) == "low"
    assert validate_threat_score(-1) == "invalid"
    assert validate_threat_score(101) == "invalid"

Code Example: Integration Test for API Endpoint

from fastapi.testclient import TestClient
from api.main import app

client = TestClient(app)

def test_create_threat_integration():
    response = client.post(
        "/api/v1/threats",
        json={
            "name": "SQL Injection Attempt",
            "severity": "high",
            "source_ip": "192.168.1.100"
        },
        headers={"Authorization": "Bearer test-token"}
    )
    assert response.status_code == 201
    data = response.json()
    assert data["name"] == "SQL Injection Attempt"
    assert data["severity"] == "high"
    assert "id" in data
    assert "created_at" in data

Code Example: E2E Test for Full Workflow

import requests

BASE_URL = "https://staging-api.durga-antivirus.com"

def test_threat_lifecycle_e2e():
    # 1. Login
    login_resp = requests.post(
        f"{BASE_URL}/api/auth/login",
        json={"username": "test-analyst", "password": "test-pass"}
    )
    assert login_resp.status_code == 200
    token = login_resp.json()["access_token"]
    headers = {"Authorization": f"Bearer {token}"}

    # 2. Create a threat
    create_resp = requests.post(
        f"{BASE_URL}/api/v1/threats",
        json={"name": "E2E Test Threat", "severity": "medium"},
        headers=headers
    )
    assert create_resp.status_code == 201
    threat_id = create_resp.json()["id"]

    # 3. Fetch the threat
    get_resp = requests.get(
        f"{BASE_URL}/api/v1/threats/{threat_id}",
        headers=headers
    )
    assert get_resp.status_code == 200
    assert get_resp.json()["name"] == "E2E Test Threat"

    # 4. Update severity
    update_resp = requests.patch(
        f"{BASE_URL}/api/v1/threats/{threat_id}",
        json={"severity": "high"},
        headers=headers
    )
    assert update_resp.status_code == 200

    # 5. Delete the threat
    delete_resp = requests.delete(
        f"{BASE_URL}/api/v1/threats/{threat_id}",
        headers=headers
    )
    assert delete_resp.status_code == 204

    # 6. Verify deletion
    verify_resp = requests.get(
        f"{BASE_URL}/api/v1/threats/{threat_id}",
        headers=headers
    )
    assert verify_resp.status_code == 404

    print("E2E threat lifecycle test passed")

Common Mistakes

1. Too Many E2E Tests

E2E tests are slow (seconds to minutes each) and flaky (network issues, database state). Keep E2E tests under 10% of the test suite.

2. No Integration Tests

Unit tests mock everything. Integration tests verify that the API actually works with the database, middleware, and external services.

3. Testing Implementation Details

Test behavior, not implementation. Avoid testing private functions or internal state. Tests that break on Refactoring provide negative value.

4. Shared Test State

Tests that depend on shared database state are flaky and order-dependent. Each test should set up and tear down its own data.

5. Slow Test Suite

Developers stop running tests when they take too long. Keep unit tests under 1ms each, integration tests under 100ms, and E2E tests under 10 seconds.

Practice Questions

  1. What is the recommended ratio of unit to integration to E2E tests?
  2. Why should E2E tests be kept to a minimum?
  3. What is the difference between integration and E2E tests?
  4. How do you test database interactions in integration tests?
  5. When should you mock external services vs use real ones?

Answers:

  1. 70/20/10: 70% unit tests, 20% integration tests, 10% E2E tests. The exact ratio varies by project.
  2. E2E tests are slow, flaky, expensive to maintain, and difficult to debug when they fail. They test too many components at once.
  3. Integration tests verify that components work together (database + API). E2E tests verify the complete system including external dependencies and user workflows.
  4. Use test databases (in-memory SQLite, PostgreSQL test containers, or Docker databases) with fixtures that set up and tear down data per test.
  5. Mock external services in unit and integration tests. Use real services in dedicated E2E tests (ideally on a staging environment).

Challenge: Refactor a test suite with 50% E2E tests to follow the testing pyramid. Move E2E tests to integration tests by replacing external dependencies with test doubles.

FAQ

What is the testing trophy instead of the pyramid?

The testing trophy (by Kent C. Dodds) emphasizes integration tests as the most valuable layer, with fewer unit and E2E tests. Both pyramids agree: minimize E2E tests.

Can I have zero E2E tests?

You can, but you risk missing integration issues that only appear in production. A small number of smoke tests (5-10) that verify critical user journeys is recommended.

Should API tests use the real database?

Integration tests should use the real database engine but with test-specific data. Use test containers (Testcontainers library) or in-memory databases for test isolation.

How do I measure test coverage?

Use coverage tools (pytest-cov, Istanbul, JaCoCo). Aim for 80%+ line coverage. Focus on covering critical business logic rather than chasing 100%.

What makes a test flaky?

Shared mutable state, network dependencies, time-based conditions, async race conditions, and environment-specific configuration.

Mini Project

Build a test suite for a threat intelligence API following the testing pyramid: 20+ unit tests for validation and business logic, 10+ integration tests for API endpoints with test database, and 3 E2E tests for critical user workflows.

What's Next

Now learn about Unit Testing API Functions for testing individual API components in isolation.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro