Integration Testing API Endpoints — Testing Components Working Together
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
- What is the difference between integration and unit tests?
- Why use TestContainers instead of SQLite for integration tests?
- How do you handle authentication in integration tests?
- How should test database setup and teardown work?
- What is a fixture and why is it useful for integration tests?
Answers:
- Unit tests test a single component in isolation. Integration tests test how components work together (e.g., route + database + middleware).
- TestContainers runs the real database in Docker. SQLite has subtle differences from PostgreSQL/MySQL that can mask compatibility issues.
- Generate test tokens using the same JWT library and secret as the application. Override the auth dependency to return a known test user.
- Create the schema before each test module or class. Drop all data between tests. Use transactions that roll back for isolation.
- 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
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