Skip to content

Integration Testing API Endpoints — Testing Components Working Together

DodaTech Updated 2026-06-28 4 min read

In this tutorial, you will learn about Integration Testing API Endpoints. We cover key concepts, practical examples, and best practices to help you master this topic.

Integration testing for APIs verifies that components work together correctly — the route handler, middleware, database, serializers, and error handlers — using real dependencies where practical.

What You'll Learn

Setting up integration tests with test databases, testing API endpoints end-to-end within the application, using test containers for realistic database testing, and handling authentication in integration tests.

Code Example: FastAPI Integration Tests with TestClient

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from api.main import app
from api.database import Base, get_db

# Test database setup
TEST_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

def override_get_db():
    db = TestingSessionLocal()
    try:
        yield db
    finally:
        db.close()

app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)

class TestThreatAPI:
    @pytest.fixture(autouse=True)
    def setup_db(self):
        Base.metadata.create_all(bind=engine)
        yield
        Base.metadata.drop_all(bind=engine)

    def test_create_and_retrieve_threat(self):
        create_resp = client.post(
            "/api/v1/threats",
            json={
                "name": "SQL Injection",
                "severity": "high",
                "source_ip": "10.0.0.1"
            },
            headers={"Authorization": "Bearer test-token"}
        )
        assert create_resp.status_code == 201
        threat_id = create_resp.json()["id"]

        get_resp = client.get(
            f"/api/v1/threats/{threat_id}",
            headers={"Authorization": "Bearer test-token"}
        )
        assert get_resp.status_code == 200
        assert get_resp.json()["name"] == "SQL Injection"

    def test_list_threats_with_filter(self):
        client.post("/api/v1/threats", json={"name": "High Threat", "severity": "high", "source_ip": "10.0.0.1"}, headers={"Authorization": "Bearer test-token"})
        client.post("/api/v1/threats", json={"name": "Low Threat", "severity": "low", "source_ip": "10.0.0.2"}, headers={"Authorization": "Bearer test-token"})

        resp = client.get("/api/v1/threats?severity=high", headers={"Authorization": "Bearer test-token"})
        assert resp.status_code == 200
        assert len(resp.json()["threats"]) == 1

Code Example: Integration Tests with Docker Test Containers

import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

@pytest.fixture(scope="session")
def postgres_container():
    with PostgresContainer("postgres:15") as postgres:
        yield postgres

@pytest.fixture
def db_session(postgres_container):
    engine = create_engine(postgres_container.get_connection_url())
    Base.metadata.create_all(bind=engine)
    session = sessionmaker(bind=engine)()
    yield session
    session.close()
    Base.metadata.drop_all(bind=engine)

class TestThreatRepository:
    def test_save_and_find_by_id(self, db_session):
        repo = ThreatRepository(db_session)
        threat = Threat(name="Test Threat", severity="high", source_ip="10.0.0.1")
        saved = repo.save(threat)

        found = repo.find_by_id(saved.id)
        assert found is not None
        assert found.name == "Test Threat"
        assert found.severity == "high"

    def test_find_by_severity(self, db_session):
        repo = ThreatRepository(db_session)
        repo.save(Threat(name="High 1", severity="high", source_ip="10.0.0.1"))
        repo.save(Threat(name="High 2", severity="high", source_ip="10.0.0.2"))
        repo.save(Threat(name="Low 1", severity="low", source_ip="10.0.0.3"))

        high_threats = repo.find_by_severity("high")
        assert len(high_threats) == 2

Code Example: Testing Authenticated Endpoints

import jwt
import datetime

class TestAuthenticatedEndpoints:
    @pytest.fixture
    def auth_token(self):
        return jwt.encode(
            {
                "sub": "test-analyst",
                "roles": ["analyst"],
                "scope": "threat:read threat:write",
                "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1)
            },
            "test-secret",
            algorithm="HS256"
        )

    def test_protected_endpoint_with_valid_token(self, auth_token):
        resp = client.get(
            "/api/v1/threats",
            headers={"Authorization": f"Bearer {auth_token}"}
        )
        assert resp.status_code == 200

    def test_protected_endpoint_without_token(self):
        resp = client.get("/api/v1/threats")
        assert resp.status_code == 401

    def test_protected_endpoint_with_expired_token(self):
        expired = jwt.encode(
            {"sub": "test", "exp": datetime.datetime.utcnow() - datetime.timedelta(hours=1)},
            "test-secret", algorithm="HS256"
        )
        resp = client.get(
            "/api/v1/threats",
            headers={"Authorization": f"Bearer {expired}"}
        )
        assert resp.status_code == 401

Common Mistakes

1. Using Production Database in Tests

Tests should never touch production data. Use a separate test database with unique data. Drop and recreate tables between test runs.

2. Not Cleaning Up Test Data

Leftover test data causes flaky tests. Use fixtures with automatic cleanup (pytest yield fixtures, Django's setup/teardown).

3. Hardcoded Database URLs

Database URLs should be configurable via environment variables. Never hardcode test database credentials.

4. Testing Against In-Memory SQLite for PostgreSQL Features

SQLite does not support PostgreSQL-specific features (JSONB, array fields, full-text search). Use test containers for PostgreSQL-specific tests.

5. Sequential Test Dependencies

Tests that depend on the outcome of previous tests are fragile. Each test should set up its own data and be independently runnable.

Practice Questions

  1. What is the difference between integration and unit tests?
  2. Why use TestContainers instead of SQLite for integration tests?
  3. How do you handle authentication in integration tests?
  4. How should test database setup and teardown work?
  5. What is a fixture and why is it useful for integration tests?

Answers:

  1. Unit tests test a single component in isolation. Integration tests test how components work together (e.g., route + database + middleware).
  2. TestContainers runs the real database in Docker. SQLite has subtle differences from PostgreSQL/MySQL that can mask compatibility issues.
  3. Generate test tokens using the same JWT library and secret as the application. Override the auth dependency to return a known test user.
  4. Create the schema before each test module or class. Drop all data between tests. Use transactions that roll back for isolation.
  5. A fixture sets up test preconditions (database, test data, tokens) and cleans up afterward. Fixtures ensure each test starts from a known state.

Challenge: Build an integration test suite for a REST API with test containers (PostgreSQL), fixture-based test data, authentication token generation, and tests for create, read, update, delete, and list operations.

FAQ

Should integration tests use transactions?

Yes. Wrap each test in a transaction and roll back at the end. This keeps the database clean without schema recreation.

How do I handle external API dependencies?

Use HTTP mocks (responses, WireMock, MockServer) to simulate external APIs. Test the integration with your code, not with the external service.

How fast should integration tests be?

Under 100ms per test with SQLite, under 500ms with PostgreSQL containers. If slower, consider what is making them slow.

Should I test the database schema?

Indirectly. Your integration tests exercise queries. If a column is missing or type is wrong, the test will fail. Direct schema tests are rarely needed.

How many integration tests do I need?

At minimum: one per API endpoint (happy path), one per error condition (404, 422, 401), and one per filter/sort combination.

Mini Project

Build an integration test suite for a threat management API using FastAPI TestClient with PostgreSQL test containers, fixture-based test data, authentication token handling, and tests for CRUD operations, filtering, and error conditions.

What's Next

Now learn about End-to-End Testing APIs for testing complete workflows across multiple services.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro