19.3. Integration Testing
Table of Contents
Why Integration Testing Matters
Unit tests focus on small pieces of code in isolation. Integration tests focus on how pieces work together.
In backend development, most real bugs appear when different parts of the system interact, for example:
- API layer + business logic + database
- Authentication middleware + protected endpoints
- Background workers + queues + database
Integration testing helps you catch these problems before they appear in production.
Definition:
Integration testing checks that multiple components of your system work correctly together, using realistic inputs and infrastructure, such as a real or test database and real HTTP requests.
Unit tests ask:
"Does this function return the right result for this input?"
Integration tests ask:
"Given a running API and a database, does this endpoint behave correctly end to end?"
Unit Tests vs Integration Tests
You should already understand unit tests in this course, so here we only contrast them with integration tests.
Levels of Testing in a Backend
A simplified view:
| Level | What it tests | Speed | Typical tools |
|---|---|---|---|
| Unit tests | Single function / method | Very fast | pytest, unittest |
| Integration tests | Multiple components working together | Medium | pytest + HTTP client + test DB |
| End to end tests | Whole system from a user perspective | Slow | Browser tests, API tests with real deps |
Integration tests live between unit tests and full end to end tests.
What Integration Tests Usually Include
An integration test for a backend often involves:
- A running HTTP server or an in-process test client
- A real database engine, but usually a separate test database
- Real routing, middleware, serialization and validation
- Sometimes external services, but often replaced by test doubles (fakes, mocks)
Examples:
- Creating a user through
POST /usersand checking what is stored in the database - Logging in through
POST /auth/loginand checking the returned token and status code - Creating an order and ensuring that stock is reduced in the inventory table
What Integration Tests Look Like for Web Backends
Typical Integration Test Scenario
A classic backend integration test checks: HTTP request β application β database β HTTP response.
Example scenario in plain language:
- Start your API app in a test mode.
- Ensure the test database is empty.
- Send an HTTP
POST /usersrequest with JSON data. - Assert:
- HTTP status code is
201 Created. - JSON response contains the right fields.
- Query the test database directly.
- Assert:
- A new user exists with the expected values.
- Password was hashed, not stored in plain text.
This is already far beyond what any single unit test can guarantee.
Important Integration Test Properties
Integration tests are:
- Stateful
They often read and write to shared state, such as a database. You must reset this state between tests. - Slower
They involve I/O, network, and process start up. Use them wisely, not for every tiny detail. - Closer to real usage
They test the real configuration and glue code that unit tests usually skip.
When to Write Integration Tests
You do not need an integration test for every function. Focus on critical flows and boundaries.
Good Candidates for Integration Tests
These areas benefit a lot from integration testing:
| Area | Example integration tests |
|-------------------------------|-------------------------------------------------------------------------|
| Authentication & sessions | Login endpoint, token creation, token validation |
| Database access & transactions | Create, update, delete flows for important entities |
| Validation & error responses | Invalid payloads, missing fields, invalid types |
| Security critical behavior | Access control, permission checks, resource ownership |
| Cross-component workflows | Creating an order updates inventory and records a payment entry |
If a bug would be very harmful in production (security, money, data loss), it is usually worth an integration test.
When a Unit Test Is Enough
Prefer a unit test when:
- You are testing pure logic, such as a price calculation function.
- The logic does not depend on network, database, or frameworks.
- You want fast feedback on implementation details.
You can often do both:
- Unit test the internal logic for many edge cases.
- One or two integration tests to confirm the full flow.
Designing Integration Tests
Black Box vs White Box
You can treat your backend as a:
- Black box
You do not care about internal structure. You only use the public API.
Example: Use only HTTP endpoints and database as an implementation detail. - White box
You know the internals and sometimes reach into them for setup or checks.
Example: Calling internal services directly, or reading in-memory state.
Most backend integration tests are black box from the HTTP side, white box about the database:
- You call endpoints as a client.
- You inspect the test database to verify results.
Arrange, Act, Assert Pattern
Structure each test with three steps:
- Arrange
Set up test data and environment. For example, create a user in the database. - Act
Perform the primary action. For example, send an HTTP request to/login. - Assert
Check that the observable results match expectations. For example, token returned, status code is 200, database updated.
Example in pseudocode:
def test_login_success(integration_client, db_session):
# Arrange
create_user(db_session, email="test@example.com", password="correct_password")
# Act
response = integration_client.post("/login", json={
"email": "test@example.com",
"password": "correct_password",
})
# Assert
assert response.status_code == 200
data = response.json()
assert "access_token" in dataTest Only One Scenario per Test
Each integration test should check one clear scenario:
test_create_user_successtest_create_user_duplicate_email_returns_400test_create_user_missing_email_returns_422
If you combine many different error cases into a single test, it becomes hard to maintain and debug.
Integration Testing Strategy in Python Backends
You will see concrete tools in later chapters, especially when testing FastAPI. Here we focus on patterns, not specific libraries.
Typical Test Stack
A common setup for Python backend integration tests:
| Concern | Example choice (you will see details later) |
|---|---|
| Test runner | pytest |
| HTTP client | Framework test client or httpx |
| Test database | PostgreSQL instance or in memory SQLite (temporary) |
| Database cleanup | Fixtures or database transactions per test |
| Test configuration | Separate config for tests (test DB, no emails, etc.) |
Separate Configuration for Tests
Your application will have configuration for:
- Database URL
- Email provider credentials
- Secrets and API keys
- Debug / production flags
For integration tests you usually have separate values, for example:
| Environment | DB name | Email sending | Logging level |
|---|---|---|---|
| Development | app_dev | Maybe real or sandbox | DEBUG |
| Test (integration tests) | app_test | Disabled or fake | WARNING |
| Production | app_prod | Real | INFO |
Rule:
Never run integration tests against your production database or services.
Always use a separate test environment, preferably isolated and disposable.
This prevents real data loss, spam emails to users, or charges on real payment providers.
Handling Databases in Integration Tests
Most backend integration tests talk to a database. Handling it correctly is critical.
Using a Separate Test Database
Always point integration tests to a separate database instance or schema.
Example pattern:
- Environment variable
DATABASE_URLfor normal runs. - Environment variable
TEST_DATABASE_URLfor tests.
Your test setup:
- Reads
TEST_DATABASE_URL. - Creates the database or clears existing data.
- Runs migrations to create tables.
- Uses this database for all tests.
You can do this once per test session to reduce overhead.
Keeping Tests Independent
Integration tests often change database state. You must keep tests independent, so one test does not break another.
Common approaches:
| Approach | Idea | Pros | Cons |
|---|---|---|---|
| Truncate tables between tests | After each test, delete all rows from all tables | Simple to understand | Can be slow |
| Use DB transactions per test | Start a transaction, run the test, then roll back | Very fast | Needs careful setup |
| Create a fresh DB per test class | Create a new temporary DB or schema for a group of tests (test class) | Good isolation between groups | More complex infrastructure |
A typical pattern:
- For each test function, start a transaction.
- Run the test inside this transaction.
- Roll back at the end, so the database is clean.
This makes integration tests more deterministic and easier to run in any order.
Testing Transactions and Constraints
Integration tests are perfect for checking:
- Foreign key constraints
- Unique constraints
- Transaction behavior
Example scenarios:
- Creating a child record with a non existing parent should fail.
- Creating two users with the same email should fail with a clean error.
- In a transactional operation, partial failure should roll back all changes.
You write an integration test that:
- Calls the endpoint or service that performs the transaction.
- Asserts the HTTP response is correct.
- Queries the database to check that data is exactly as expected, including no partial writes.
Testing HTTP APIs with Integration Tests
Since this course focuses on backend APIs, most integration tests will call HTTP endpoints.
Using a Test HTTP Client
Your framework will provide or support an HTTP client that can talk to your app in a test environment. The details differ, but the pattern is similar:
- Start the app with a test configuration.
- Use a client object, for example
client, to send HTTP requests. - Assert on response status, headers, JSON body.
Example shape in pseudocode:
def test_get_tasks_returns_list(client):
response = client.get("/tasks")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)For tests that change state:
def test_create_task_persists_in_db(client, db_session):
# Act
response = client.post("/tasks", json={"title": "Test task"})
assert response.status_code == 201
task_id = response.json()["id"]
# Assert in DB
task = db_session.get(Task, task_id)
assert task is not None
assert task.title == "Test task"Testing Error Handling
Integration tests are very useful for checking how errors are exposed to clients.
Examples:
- Sending invalid JSON should return
400 Bad Request. - Failing validation should return
422 Unprocessable Entitywith error details. - Accessing a protected resource without a token should return
401 Unauthorized.
You design tests such as:
def test_create_task_without_title_returns_422(client):
response = client.post("/tasks", json={})
assert response.status_code == 422
data = response.json()
# Exact structure depends on your framework
assert "errors" in dataThis ensures that clients of your API get predictable, documented errors.
Testing Authentication and Authorization
Integration tests can simulate:
- Authenticated requests with valid tokens.
- Requests with missing or invalid tokens.
- Requests with different roles or permissions.
Typical patterns:
- A fixture that logs in a test user and returns an access token.
- Using that token in
Authorizationheaders for subsequent requests.
Example structure:
def test_get_profile_requires_auth(client):
response = client.get("/me")
assert response.status_code == 401
def test_get_profile_returns_user_data(client, user_token):
response = client.get("/me", headers={"Authorization": f"Bearer {user_token}"})
assert response.status_code == 200
data = response.json()
assert data["email"] == "user@example.com"Working with External Dependencies
Backends rarely live alone. They send emails, call other services, and integrate with queues. Integration tests must address this.
Real Services vs Test Doubles
You often have two options for each external dependency:
- Use the real service
For example, a local Redis instance or a local message queue. - Use a test double
For example, an in memory fake implementation, or a mock.
A balanced strategy:
- For core infrastructure you control, such as a local PostgreSQL or Redis, you can often run the real thing in a test environment.
- For third party services, such as payment providers and external APIs, prefer fakes or mocks to avoid costs, rate limits, and instability.
Fakes vs Mocks in Integration Tests
Terminology:
- A fake is a lightweight implementation used only in tests, but behaves similarly to the real service.
- A mock is a test object that records or asserts calls and is usually created by a mocking library.
Integration tests often use fakes more than detailed mocks, because we want to test the interaction between many real components.
Example:
- Instead of sending real emails, use an email service that stores "sent emails" in memory.
- Integration tests can then assert that one email was "sent" to the right address.
Pseudo pattern:
def test_registration_sends_verification_email(client, fake_email_service):
response = client.post("/register", json={
"email": "new@example.com",
"password": "secret123"
})
assert response.status_code == 201
assert len(fake_email_service.sent_emails) == 1
assert fake_email_service.sent_emails[0].to == "new@example.com"This keeps tests deterministic and fast, while still checking that integration with the email service works logically.
Organizing Integration Tests
As your project grows, you need a clear structure for integration tests.
Separating Unit and Integration Tests
You can separate tests by:
- Directory:
tests/unit/tests/integration/- Naming:
- Unit tests:
test_*.py - Integration tests:
itest_*.py(or similar)
Then you can run them separately, for example:
pytest tests/unitfor fast checks.pytest tests/integrationbefore pushing or in CI.
Grouping by Feature or Module
Within tests/integration/, you can group by domain feature:
tests/integration/test_auth.pytests/integration/test_tasks.pytests/integration/test_orders.py
Inside each file, structure tests by endpoint or scenario:
# tests/integration/test_auth.py
def test_register_creates_user(...):
...
def test_register_duplicate_email_returns_400(...):
...
def test_login_success(...):
...This mirrors how your API is organized and makes tests easier to navigate.
Using Fixtures for Setup and Teardown
Integration tests often share setup logic, such as:
- Creating a test client.
- Creating test users in the database.
- Authenticating a user and getting a token.
You can use test fixtures (for example, pytest fixtures) to:
- Encapsulate common setup.
- Reuse it across many tests.
- Handle cleanup automatically.
Although we will not go deep into fixture code here, you will see patterns like:
clientfixture for a test HTTP client.db_sessionfixture for a database session.user_tokenfixture for an authenticated token.
Integration Testing in Continuous Integration (CI)
Integration tests are an important part of CI pipelines.
Where Integration Tests Fit in CI
A typical pipeline:
- Run linting tools.
- Run unit tests.
- Run integration tests.
- Build Docker images.
- Deploy to a test or staging environment.
You can choose:
- Run all tests on every push for small projects.
- Run unit tests on every push and integration tests on pull requests for larger projects.
Infrastructure in CI
To run integration tests in CI, you need infrastructure:
- A PostgreSQL or other database instance
- Possibly Redis or other dependencies
Common approaches:
- Use Docker services in CI configurations, for example a PostgreSQL container.
- Use tools like Docker Compose to start a full test stack.
- Initialize the test database at the start of the test job.
Your test code then uses TEST_DATABASE_URL pointing to the CI database container.
This allows integration tests to run in the same way locally and in CI, which increases reliability.
Common Pitfalls and Best Practices
Pitfalls
Here are common mistakes in integration testing:
| Pitfall | Problem |
|---|---|
| Using production resources | Risk of data loss, spam, costs |
| Tests depend on each other | Order dependent tests, flaky results |
| Too much mocking | Integration tests become similar to unit tests, lose their value |
| No cleanup of shared state | Database or cache polluted, tests interfere with each other |
| Oversized tests | Very long tests that cover too many cases, hard to debug and maintain |
| Ignoring non happy paths | Only success flow tested, errors in real use cause crashes |
Best Practices
Some useful rules of thumb:
Key rules for integration tests:
- Use a separate test environment and never touch production.
- Keep tests independent and repeatable. They should pass in any order.
- Focus on important flows and boundaries, not every small detail.
- Test both happy paths and failure paths for critical features.
- Automate running integration tests in your CI pipeline.
Additional tips:
- Start with a small set of high value integration tests for your core endpoints, such as authentication and main CRUD operations.
- Gradually expand coverage as your application grows.
- Keep integration tests focused and readable, even if they are longer than unit tests.
- When a bug is found in production that involves multiple components, consider writing an integration test that reproduces it, then fix the bug and keep the test as a regression check.
Summary
Integration testing verifies that multiple parts of your backend, such as HTTP endpoints, middleware, business logic, and the database, work together correctly.
You saw:
- How integration tests differ from unit tests.
- Which parts of a backend benefit most from integration testing.
- How to design good integration tests with clear Arrange, Act, Assert structure.
- Patterns for handling test databases and external dependencies.
- How to organize and run integration tests locally and in CI.
- Common pitfalls and best practices.
In later chapters, especially when testing FastAPI, you will apply these ideas with real code, HTTP clients, and test databases.
Views: 6
KAHIBARO