TEST ARCHITECTURE

Layering a Test Suite: Unit, API, End-to-End

One of the most persistent mistakes I see in test suites is treating all tests as interchangeable. Teams either write only end-to-end tests because they feel "real," or they write only unit tests because they're fast, and then wonder why their suite keeps missing production bugs or grinding CI to a halt. The fix isn't picking the right layer — it's understanding why all three layers exist and what each one is actually responsible for.

The classic framing is the testing pyramid: lots of unit tests at the base, a meaningful layer of API/service tests in the middle, and a smaller set of end-to-end tests at the top. That shape isn't arbitrary. It reflects the cost curve of each layer — how long tests take to run, how often they break for reasons unrelated to your code, and how much setup they demand. When you invert the pyramid, you end up with a suite that's slow, brittle, and expensive to maintain. When you skip the middle layer entirely, you get fast feedback on isolated logic but miss the integration failures that only show up when services talk to each other.

In this article I want to walk through each layer concretely — what belongs there, what doesn't, and how to wire all three together into a cohesive strategy. The goal isn't a theoretical framework; it's a set of decisions you can apply to your own suite today.

Build an API Automation Framework in Python

Learn Python, Behave, GitHub Copilot, APIs, and CI/CD by building a real framework you can finish in a weekend.

Learn more

What Unit Tests Are Actually Responsible For (And Where They Stop)

Unit tests cover the smallest testable pieces of your code in isolation: a single function, a class method, a data-transformation utility. The defining characteristic is that they run without any external dependencies — no network, no database, no file system. If a test spins up a real HTTP connection, it's not a unit test, regardless of how small it feels.

In a Python test suite, this usually means testing things like request-building logic, response-parsing helpers, retry decorators, and schema validation functions. Here's a concrete example:

# utils/response_parser.py
def extract_user_ids(response_json):
    return [item["id"] for item in response_json.get("users", [])]

# tests/unit/test_response_parser.py
from utils.response_parser import extract_user_ids

def test_extracts_ids_from_valid_payload():
    payload = {"users": [{"id": 1}, {"id": 2}]}
    assert extract_user_ids(payload) == [1, 2]

def test_returns_empty_list_when_users_key_missing():
    assert extract_user_ids({}) == []

These tests run in milliseconds and never fail because an API is down or a container didn't start. That reliability is the whole point. When unit tests are fast and stable, engineers actually run them constantly — on save, on commit, in a pre-push hook. That tight feedback loop catches regressions before they compound.

Where unit tests stop being useful is at the boundary between your code and the outside world. You can mock an HTTP response all you want, but the mock only proves your code handles the shape of data you imagined. It says nothing about whether the real API actually returns that shape, or whether your authentication headers are constructed correctly, or whether a 429 rate-limit response is handled gracefully. That's the job of the next layer.

A practical rule of thumb: if you're writing a mock that's more complex than the code it's replacing, you've probably drifted out of unit-test territory. Keep mocks simple and push the integration questions down to the API layer where they belong.

The API Layer: Where Integration Failures Actually Live

The API (or service) test layer is where I spend the most time, and where most teams underinvest. These tests call real HTTP endpoints — either against a locally running service, a dedicated test environment, or a contract-verified sandbox — and assert on real responses. They exercise the full request/response cycle without driving a browser or orchestrating an entire user journey.

This is the layer that catches the bugs unit tests can't: a misconfigured auth scheme, a field that changed type in a recent deploy, a pagination cursor that breaks on the second page. If you're new to thinking about this layer systematically, the fundamentals of what makes API tests reliable and meaningful are worth grounding yourself in before you start scaling the suite.

In pytest, a basic API test at this layer looks like this:

# tests/api/test_users_endpoint.py
import pytest
import requests

BASE_URL = "http://localhost:8080"

def test_get_user_returns_expected_schema(auth_headers):
    response = requests.get(f"{BASE_URL}/users/1", headers=auth_headers)
    assert response.status_code == 200
    body = response.json()
    assert "id" in body
    assert "email" in body
    assert isinstance(body["id"], int)

def test_get_nonexistent_user_returns_404(auth_headers):
    response = requests.get(f"{BASE_URL}/users/99999", headers=auth_headers)
    assert response.status_code == 404

Notice there's no browser, no UI interaction, no full user flow. The test is surgical: one endpoint, one scenario, one assertion set. That focus is what makes API tests fast enough to run in CI on every pull request without becoming a bottleneck.

One design decision that pays off here is keeping test data management deliberate. When multiple tests share setup state — created users, seeded records, auth tokens — you need a clear strategy for how that data flows without tests stepping on each other. Managing shared test data safely is one of those problems that seems minor until your suite hits a few dozen tests and suddenly ordering matters.

For teams deciding between pytest and Behave at this layer, the choice usually comes down to audience: if stakeholders read the tests, BDD syntax earns its overhead. If the tests are purely for engineers, plain pytest is leaner and easier to maintain. I've written about when BDD is the right call versus plain pytest in more detail if you're weighing that decision.

Coverage targets for this layer: every happy path, every documented error code (400, 401, 403, 404, 422, 500), and any edge cases that have burned you in production. That's not exhaustive — it's deliberate.

End-to-End Tests: Fewer, Slower, and Worth Every Second

End-to-end (E2E) tests exercise the system the way a real user or a real downstream consumer would: multiple services, real infrastructure, full data flows from trigger to outcome. In an API-first context, an E2E test might create an order via the orders API, poll the fulfillment API until the status changes, and then verify the notification service emitted the right event. Every hop is real. Nothing is mocked.

That realism is exactly why you keep the count low. E2E tests are slow, they require a fully deployed environment, and they fail for reasons that have nothing to do with your code — network blips, environment drift, dependent service outages. Every E2E test you add is a maintenance commitment. The question to ask before writing one is: is there a bug class that only this test can catch, that unit and API tests cannot? If the answer is yes, write it. If not, you're duplicating coverage at a much higher cost.

A practical E2E test in Python might look like this:

# tests/e2e/test_order_fulfillment_flow.py
import time
import requests

def test_order_is_fulfilled_after_payment(auth_headers, base_url):
    # Step 1: Create order
    order_resp = requests.post(
        f"{base_url}/orders",
        json={"product_id": 42, "quantity": 1},
        headers=auth_headers
    )
    assert order_resp.status_code == 201
    order_id = order_resp.json()["id"]

    # Step 2: Simulate payment confirmation
    pay_resp = requests.post(
        f"{base_url}/payments",
        json={"order_id": order_id, "amount": 29.99},
        headers=auth_headers
    )
    assert pay_resp.status_code == 200

    # Step 3: Poll for fulfillment status (with timeout)
    for _ in range(10):
        status_resp = requests.get(
            f"{base_url}/orders/{order_id}",
            headers=auth_headers
        )
        if status_resp.json().get("status") == "fulfilled":
            break
        time.sleep(2)
    else:
        pytest.fail("Order was not fulfilled within the expected time window")

A few things to notice: the test is explicit about each step, it has a bounded retry loop rather than an infinite wait, and it tests a flow that genuinely crosses service boundaries. That's the bar an E2E test should clear.

In CI, the three layers should run at different cadences. Unit tests run on every commit — they're fast enough that there's no reason not to. API tests run on every pull request against a test environment. E2E tests run on merge to main, or on a scheduled nightly pipeline, or both. This cadence keeps the fast feedback loop intact for developers while still catching cross-service regressions before they reach production. When your suite grows large enough that even the API layer starts slowing down CI, that's the moment to look seriously at running tests in parallel without introducing flakiness.

The layered approach isn't about following a diagram — it's about matching the cost of a test to the question it's answering. Unit tests answer "does this logic work in isolation?" API tests answer "does this service behave correctly at its interface?" E2E tests answer "does the whole system deliver the outcome it promises?" When each layer does its own job cleanly, the suite becomes something you trust rather than something you tolerate.