Integration Testing

Expected level: Working

An integration test checks that several parts of a system work correctly together, for example your code with a real database, a message queue or an HTTP API. It finds problems that tests of isolated units cannot see.

Unit versus integration

A unit test replaces dependencies with test doubles and tests one thing. An integration test uses real collaborators, or realistic ones, and checks the combined behaviour. The terms are fuzzy. What matters is what a test proves, and how fast and reliable it is.

Unit test Integration test
Scope One function or class Several components
Dependencies Replaced Real or realistic
Speed Milliseconds Seconds
Failure points to A specific piece of logic A seam or a configuration

An example only an integration test catches

A unit test of OrderService uses a mocked repository that returns an order, and passes. In production the real query fails, because it selects customer_name while the column is called customer, or compares a date column with a string in a format the database rejects. Only a test that runs the real query against a real database sees this. The same goes for a JSON date format that the sender and receiver read differently.

Typical targets

  • Database access: queries, mappings and migrations, run against a real database engine of the same type as production. See SQL Fundamentals and Transactions.
  • HTTP APIs: start the service, send real requests and check status codes and response bodies. See API Testing Tools.
  • Calls to other services: use a recorded or simulated version of the other side, plus a contract test to check that the simulation still matches the real service.

Setting up integration tests

  • Cover each integration point with a few focused tests, and do not re-test all business rules through the database.
  • Use realistic infrastructure. Containers make it easy to start a throwaway database or queue for the test run. See Servers, VMs and Containers. An in-memory substitute can behave differently from the real thing.
  • Control the data. Each test creates what it needs and cleans up, or runs in a transaction that is rolled back. See Test Data Management.
  • Take configuration from the environment. Connection strings come from environment variables, and are never hard-coded to your machine.
  • Keep tests independent and repeatable, so they can run in CI. Do not depend on shared test servers that other people also change.
  • Expect flakiness from timeouts, ordering and slow startup. Wait for readiness instead of sleeping for a fixed time.

Try it

Write a test that saves a record through your data access code into a real local database (started in a container or as a local instance), reads it back and compares the values. Then run the test twice in a row and confirm it still passes. If not, find the leftover data that breaks the second run.

Common mistakes

  • Relying on mocks only, so unit tests pass while the real query fails.
  • Re-testing all business rules through the database.
  • Depending on shared test servers that other people also change.
  • Hard-coding connection strings to your own machine.

Check yourself

  • Why can a unit test with a mocked repository pass while the real system fails?
  • Why prefer a real database engine over an in-memory substitute?
  • How do you keep integration tests from leaving data behind?

Related topics

Terms

Further reading