Skip to content

Integration Tests — Concept & Examples

What They Test

How components work together. The pieces are real — no mocks for the parts being integrated.

Test → HTTP request → Service → Database → Response → assert

Common scenarios: - API endpoint processes a request and returns JSON - Service reads and writes a real database - One service calls another service - Message goes through a queue and is consumed


Properties

Property Value
Speed 0.1–1 s per test
Count Hundreds on a real project
Feedback Seconds — find contract breaks between layers
Tool pytest + Playwright APIRequestContext

Tool: Playwright APIRequestContext

Playwright ships with a built-in HTTP client for API testing. No extra libraries. Same fixture infrastructure as E2E tests. One toolchain, two levels of the pyramid.

import pytest
from playwright.sync_api import APIRequestContext, Playwright


@pytest.fixture(scope="session")
def api_context(playwright: Playwright) -> APIRequestContext:
    context = playwright.request.new_context(
        base_url="http://localhost:8000",
    )
    yield context
    context.dispose()

Example: Task API

from collections.abc import Generator

import pytest
from playwright.sync_api import APIRequestContext


@pytest.fixture(autouse=True)
def clean_tasks(api_context: APIRequestContext) -> Generator[None, None, None]:
    api_context.delete("/tasks")
    yield
    api_context.delete("/tasks")


@pytest.mark.integration
class TestTasksAPI:
    def test_new_task_starts_with_open_status(
        self, api_context: APIRequestContext,
    ) -> None:
        response = api_context.post("/tasks", data={
            "title": "Checkout timeout on Safari",
            "assignee": "alice",
        })
        assert response.status == 201
        body = response.json()
        assert body["status"] == "open"
        assert body["assignee"] == "alice"

    def test_move_task_to_in_progress(
        self, api_context: APIRequestContext,
    ) -> None:
        create_response = api_context.post(
            "/tasks",
            data={"title": "Add rate limiting to /api/auth"},
        )
        task_id = create_response.json()["id"]
        update_response = api_context.patch(
            f"/tasks/{task_id}", data={"status": "in_progress"},
        )
        assert update_response.json()["status"] == "in_progress"
        assert update_response.json()["title"] == "Add rate limiting to /api/auth"

    @pytest.mark.parametrize("task_id, expected_status", [
        ("task_999", 404),
        ("nonexistent", 404),
        ("", 404),
    ])
    def test_get_invalid_task_returns_404(
        self,
        api_context: APIRequestContext,
        task_id: str,
        expected_status: int,
    ) -> None:
        response = api_context.get(f"/tasks/{task_id}")
        assert response.status == expected_status

    def test_unassigned_task_has_null_assignee(
        self, api_context: APIRequestContext,
    ) -> None:
        response = api_context.post(
            "/tasks",
            data={"title": "Investigate flaky test in CI"},
        )
        assert response.status == 201
        assert response.json()["assignee"] is None

Real HTTP requests to a running service. If someone renames a field or breaks the status logic — the test catches it before users do.


What Integration Tests Catch

Bug Type Example
Field rename status renamed to state in response
Wrong HTTP status Returns 200 instead of 201 on create
Missing field assignee not included in response body
DB constraint Duplicate unique key error surfaces
Auth leak Endpoint accessible without token

Scope: What to Mock vs What Not to Mock

Component In Integration Test
Database Real (use test DB or Testcontainers)
HTTP service under test Real
Third-party payment API Mock — don't charge real cards in tests
Email service Mock — don't send real emails
Internal service under test Real

The rule: mock at the system boundary (external payment, external email, SMS). Keep everything internal real — that's what integration testing is for.