Skip to content

Integration Tests — Common Mistakes

Mistake 1: Shared State Between Tests

One test creates data, another assumes it exists. Works locally, breaks on CI, breaks when pytest-randomly shuffles order.

from collections.abc import Generator

import pytest
from playwright.sync_api import APIRequestContext


@pytest.mark.integration
class TestSharedStateBad:
    """Bad: second test depends on data created by the first."""

    def test_add_note(self, api_context: APIRequestContext) -> None:
        response = api_context.post(
            "/notes",
            data={"author": "alice", "text": "Reproduced on staging"},
        )
        assert response.status == 201

    def test_list_includes_alice_note(
        self, api_context: APIRequestContext,
    ) -> None:
        # Only passes if test_add_note ran first — order-dependent
        response = api_context.get("/notes")
        notes = response.json()
        assert any(note["author"] == "alice" for note in notes)

The fix: clean state before and after every test.

@pytest.mark.integration
class TestSharedStateGood:
    """Good: each test creates its own data and cleans up."""

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

    def test_add_and_list_note(self, api_context: APIRequestContext) -> None:
        api_context.post(
            "/notes",
            data={"author": "bob", "text": "Fixed in commit a1b2c3"},
        )
        response = api_context.get("/notes")
        assert len(response.json()) == 1
        assert response.json()[0]["author"] == "bob"

    def test_empty_list_when_no_notes(
        self, api_context: APIRequestContext,
    ) -> None:
        response = api_context.get("/notes")
        assert response.json() == []

Rule: integration tests must be order-independent. Add pytest-randomly to the CI run to catch ordering issues early.


Mistake 2: Mocking Too Much (Integration Test in a Trench Coat)

If the database, the HTTP layer, and the serializer are all mocked in an "integration" test — what is actually being integrated? Nothing. It's a unit test pretending to be something else.

from unittest.mock import MagicMock, patch

import pytest

from myapp.services.order_service import create_order  # type: ignore[import]


@pytest.mark.integration
class TestOrderServiceOverMocked:
    """Bad: everything is mocked — no real integration happens."""

    @patch("myapp.db.session")
    @patch("myapp.serializers.OrderSerializer")
    @patch("myapp.services.payment_gateway.charge")
    def test_create_order(self, mock_charge, mock_serializer, mock_session) -> None:
        mock_session.add = MagicMock()
        mock_serializer.return_value.data = {"id": "1", "status": "paid"}
        mock_charge.return_value = {"success": True}
        # Tests the mock configuration, not the actual integration
        result = create_order(user_id="1", amount=50.0)
        assert result["status"] == "paid"

If you want to mock, do it at the unit level. At the integration level, let the components actually talk to each other.

Rule: mock only at the external boundary (third-party APIs, external payment, SMS). The database, internal services, and HTTP layer should be real.


Mistake 3: No Cleanup After Test Failure

A test creates data, the assertion fails, cleanup never runs. Next test run finds leftover data and fails for an unrelated reason.

@pytest.mark.integration
class TestCleanupBad:
    def test_create_user(self, api_context: APIRequestContext) -> None:
        api_context.post("/users", data={"email": "test@example.com"})
        response = api_context.get("/users")
        assert len(response.json()) == 1   # if this fails, cleanup never runs
        api_context.delete("/users")        # cleanup at the end — skipped on failure

Fix: use yield fixtures so teardown always runs.

@pytest.mark.integration
class TestCleanupGood:
    @pytest.fixture(autouse=True)
    def reset_users(self, api_context: APIRequestContext) -> Generator[None, None, None]:
        api_context.delete("/users")   # before
        yield
        api_context.delete("/users")   # after — always runs, even on failure

    def test_create_user(self, api_context: APIRequestContext) -> None:
        api_context.post("/users", data={"email": "test@example.com"})
        response = api_context.get("/users")
        assert len(response.json()) == 1

Checklist

Check Bad Pattern Fix
Test isolation Depends on previous test's data autouse fixture with yield cleanup
Order independence Fails when shuffled Add pytest-randomly to CI
Mock scope Everything mocked Only mock external 3rd-party boundaries
Cleanup on failure Cleanup at end of test body Use yield in fixture, not manual teardown
Unique test data Hardcoded shared IDs Generate unique IDs per test (uuid.uuid4())