How to Test Integration Between Services: A Step by Step Guide
A practical walkthrough for testing integration points, with worked test cases, where each test belongs in CI, and the mistakes that make these suites slow.
Yuvan Sundrani · 15 min read
autosana.ai

To test integration between two parts of a system, you identify the seam between them, decide whether the other side will be real or simulated, write a test that drives your side and asserts on what crosses the boundary, and run it on a cadence that matches how expensive it is.
This is the practical companion to the concept. If you want the definitions, the types and the narrow against broad distinction, start with what integration testing is. This guide assumes you have decided to write one and want to know what goes in it.
Step 1: Find the seam
An integration point is anywhere your code hands something to code it did not write, or receives something back.
In a typical application the list is shorter than people expect:
- Your service to its database
- Your service to a message queue or event bus
- Your service to another internal service
- Your service to a third-party API
- Your frontend to your own backend
- Your mobile app to your backend
Write the list down before writing any tests. Most suites that feel incomplete are not missing tests, they are missing a seam nobody ever enumerated. Six seams with two tests each is better coverage than one seam with forty.
Step 2: Decide real or simulated, per seam
This single decision sets the cost of everything that follows.
| Seam | Usually Real | Usually Simulated |
|---|---|---|
| Your own database | Yes, in a container | |
| Your own message queue | Yes, in a container | |
| Another internal service | Sometimes | Often, with a contract test behind it |
| Third-party API | Rarely | Almost always |
| Payment provider | Sandbox at release only | For everything else |
The rule that holds up: use the real thing when you own it and can start it from scratch, and simulate it otherwise.
Running a real database per test run stopped being difficult. Testcontainers provides throwaway, lightweight instances of databases, message brokers and anything else that runs in a Docker container, created and deleted around the test, so every run starts from a known state. All it needs is Docker.
For the seams you do not own, WireMock Cloud simulates environments with statefulness and production-realistic failures, which is the part a vendor sandbox will never give you on demand.
Step 3: Write the test for the seam, not for the feature
This is where most integration tests go wrong, and it is worth being blunt about it.
A test that logs in, navigates three screens, submits a form and checks a confirmation message is not an integration test. It is a slow end-to-end test wearing the wrong label, and it will fail for a dozen reasons unrelated to the seam.
An integration test should have one boundary in it. Call the thing that crosses the boundary, directly, and assert on the result.
A worked integration test
An order service calls a payment provider. Here is what belongs at each level.
Not an integration test: a browser test that adds an item, checks out, and sees a success page. Too many seams, fails for too many reasons.
An integration test: call OrderService.submit() with a basket, against a simulated payment provider configured to return a decline, and assert three things. The order status becomes payment_failed. No shipment record is created. The decline reason is stored.
One boundary, three assertions, no UI, and it runs in under a second.
Step 4: Write the cases that actually find defects
Teams write the success case and stop. The success case is the one that already works.
For any seam, the cases worth having are:
| Case | What It Catches |
|---|---|
| Success | The happy path, and it is the least valuable test here |
| Rejection | A valid call the other side refuses, such as a declined card |
| Malformed response | The other side returns something your parser did not expect |
| Timeout | The other side never answers, and you discover there is no timeout set |
| Slow response | It answers after four seconds and something upstream has already given up |
| Retry and duplication | You retry and the other side processes twice |
| Auth failure | A token expires mid-flight |
The timeout case earns its place more often than any other. A seam with no configured timeout looks perfect in every test until the day the other side hangs, and then it takes your service down with it. Simulated providers can hold a response open deliberately. Real ones will not do it when you ask.
Step 5: Keep the data under control
An integration test that depends on a record somebody created last month is a scheduled failure.
Three rules:
Create what you need inside the test. Not in a shared fixture, not in a seed script somebody ran once.
Make it unique per run. A generated identifier per run means twenty parallel workers never collide.
Delete it in the same code that created it. Teardown that lives in a separate job is teardown that gets switched off the first time it breaks.
This is the single largest source of so-called flakiness in integration suites. A r/devops thread on the best approach to a staging database is mostly teams discovering that a shared environment makes every suite pointed at it unreliable.
Step 6: Put each test where it belongs in CI
Different costs, different cadences.
| Test Type | Runs | Budget |
|---|---|---|
| Narrow integration, simulated dependencies | Every commit | Seconds |
| Integration against containerised real dependencies | Every commit or every pull request | A minute or two |
| Contract tests against real providers | Daily or on provider change | Minutes |
| Broad integration across real services | Before release | As long as it takes |
The failure mode here is predictable. Put broad integration tests on every commit and within a month somebody adds --ignore-failures to keep the pipeline green, after which the tests exist and protect nothing.
Step 7: Keep the doubles honest
Every simulated dependency encodes a belief about how the real thing behaves, and beliefs drift.
Contract tests are what stop that drift. They check that the response your double returns is still the response the provider actually sends, and they run against the real provider on a slow cadence so your fast tests never have to.
Without them you get a suite that passes reliably against a fiction, which is worse than having no tests, because it produces confidence. A r/QualityAssurance thread asking whether contract testing or integration testing alone is enough, or whether a team needs both, runs through the trade in practice.
Common mistakes
Testing the feature instead of the seam. The clearest symptom is an integration test that needs a logged-in user.
One shared test environment. Every test inherits every other team's state and nobody can reproduce a failure.
Only the happy path. The declines, timeouts and malformed responses are where the money is.
Doubles with no contract tests. Fast, green, and quietly wrong.
Too many integration tests and too few unit tests. A r/ExperiencedDevs thread on a project with zero unit tests and thousands of integration tests drew 93 votes and 135 comments, and the consistent report is a suite that is slow, fragile and expensive to change.
Expecting integration tests to cover the journey. They do not and are not meant to. A product where every seam is verified can still have a checkout no user can finish, which is the question end-to-end testing exists to answer and why agent-based platforms such as Autosana sit at the layer above this one.
A checklist you can run today
- List every seam in the service you are working on.
- For each, decide real or simulated, and write the reason next to it.
- Containerise the dependencies you own so each run starts clean.
- Write the rejection, timeout and malformed-response cases before the success case.
- Generate test data inside the test and delete it in the same place.
- Assign each test a cadence and hold it to a time budget.
- Add a contract test behind every double you created.
If you get through steps one and two honestly, most of the value is already captured, because almost every slow integration suite is slow from a decision nobody made deliberately.
Frequently asked questions
How do you test integration between two services?
Identify the boundary, decide whether the other service will be real or simulated, then call your side directly and assert on what crosses the boundary. Keep the user interface out of it, keep it to one boundary, and cover rejection and timeout cases rather than only the success path.
What is an example of an integration test?
Calling an order service with a basket while a simulated payment provider returns a decline, then asserting that the order is marked failed, no shipment is created and the decline reason is stored. One boundary, several assertions, no user interface, and under a second to run.
What should an integration test case include?
The seam being tested, the state of the other side, the call being made, and the assertions on what came back and what changed. The most valuable cases are rejection, malformed response and timeout, because the success case is the one already known to work.
How often should integration tests run?
Match the cadence to the cost. Narrow tests against doubles run on every commit. Tests against containerised real dependencies run on every commit or pull request. Contract tests run daily. Broad tests across real services run before release.
Why are integration tests flaky?
Usually shared state rather than timing. Several runs against one environment read each other's data, or a test depends on a record created by an earlier test. Creating data inside the test, making it unique per run and deleting it in the same code removes most of it, and retries remove none of it.
Do integration tests replace end to end tests?
No. Integration tests prove each seam holds. End-to-end tests prove the journey completes across the real product. A system can pass every integration test and still have a checkout that no customer can finish, because no integration test ever looks at the whole path.
.png)