- What counts as an integration test here?
- Keep scope realistic (but not huge)
- External dependencies (DB / network / queues)
- Making integration tests stable (no flakiness)
- Recommended structure (AAA still applies)
- Minimal example outline (pseudocode)
- Where integration tests live
- Pre-PR checklist
- Related guides
This is a how-to guide for writing integration tests in this repo (practical “how”).
For definitions and choosing the right test type, see testing.md.
For isolated logic tests, see unit-test.md.
An integration test checks that multiple pieces work together correctly in a realistic setup.
✅ In scope (common examples)
- Service code + real database (or a DB running locally/in a container).
- API handler/controller + persistence layer (still not “full browser clicking”).
- Data mapping across layers (request → domain → DB and back).
- Producing/consuming messages using a real broker (or a good local equivalent).
❌ Not integration tests (usually)
- Pure business logic in isolation (that’s a unit test).
- Full user journeys across the whole system (that’s end-to-end testing).
- Tests that depend on shared environments or manual steps.
Tip
Quick hint: if a bug could come from wiring/config/schema/IO (how things connect), it often needs an integration test.
Integration tests should be “realistic, but focused.”
- Prefer integrating one boundary at a time (example: service + DB), instead of turning every test into “the whole system.”
- Keep the number of moving parts small (fewer services = fewer random failures).
- Use real-ish config (migrations, schemas, env vars), but avoid depending on shared dev/staging systems.
Aim for dependencies that start for the test run and can be deleted after (throwaway = safe and repeatable):
- Containers (for DB/queues).
- Docker Compose (for multiple local services).
- In-memory versions only if they behave close enough to production (and you note the differences).
Consistent data setup makes tests easier to read and maintain.
- Migrations + seed data each run (seed data = a small starting dataset).
- Factory helpers (helpers that create objects with sensible defaults).
- Small example datasets (fixtures = pre-made test data; keep them small so they’re easy to understand).
Pick the simplest option that stays reliable:
- Transaction rollback per test (wrap each test in a DB transaction, then roll it back).
- Truncate tables between tests (delete rows so each test starts clean).
- Fresh database/schema per test run (very reliable and simple to reason about).
- Prefix test data with unique IDs (so parallel tests don’t clash; sometimes called “namespacing” test data).
- Calling real third-party services.
- Using shared dev databases/queues that other people/tests also use.
- Requiring humans to clean up state.
If you must simulate an external service, prefer a local stub server (a local fake API) and document it as a deliberate choice.
Integration tests usually get flaky because of time, waiting, and shared state.
- Prefer deterministic checks (assert what’s stored in the DB or what message was emitted, not “it probably happened eventually”).
- Avoid
sleep(5)style waits; if you need to wait, poll with a clear timeout (wait up to X seconds, checking every Y ms). - Each test should create its own data (no “this test assumes another test ran first”).
- Assume tests may run in parallel (use unique identifiers; isolate resources).
- Keep failure output helpful (clear assert messages; capture logs from containers/services when possible).
- Don’t over-assert on unimportant details (details that aren’t part of the actual behavior you care about, like row order or internal IDs), unless that detail is the point of the test.
Integration tests should still follow Arrange / Act / Assert, but “Arrange” includes environment setup.
- Arrange: start dependencies, run migrations, seed data, configure clients.
- Act: call the boundary you’re testing (service method, API handler, repository).
- Assert: verify durable effects (DB rows, emitted messages) and returned outputs.
Use this as the baseline shape for most integration tests.
Test: Creating a user stores the right fields
Arrange:
- Start DB (container/compose/local)
- Apply migrations
- Create repository/service with DB connection
- (Optional) seed prerequisite data
Act:
- Call createUser(email)
Assert:
- Query DB for the user row
- Assert stored fields match expectations
- Assert constraints are enforced (e.g., unique email)
arrange:
db = startDatabase()
migrate(db)
repo = UserRepository(db)
act:
userId = repo.insert(email="a@example.com")
assert:
row = db.query("select * from users where id = ?", userId)
expect row.email == "a@example.com"
expect row.created_at is not null
Current repo layout:
backend/app/tests/integration/– backend integration testsfrontend/tests/integration/– frontend integration testsbackend/app/tests/conftest.pyandfrontend/tests/utils.tsx– shared test helpersdocker-compose.dev.yml– local dependency setup for the development stack
Keep helpers small and straightforward (so contributors can understand them quickly).
- I wrote an integration test because the behavior crosses boundaries (not just pure logic).
- The test runs locally in a fresh setup (no shared DB/queues required).
- Setup and cleanup are automated (no manual steps).
- The test is stable (no random sleeps; if waiting is needed, it’s bounded by a timeout).
- Test data is small and clearly tied to the assertions.
- Failures are easy to debug (clear assertions; useful logs when it fails).