What Is Integration Testing? How It Works, Types, and the Mocking Trap
An integration test that mocks its own seam passes without ever crossing it. See what integration testing checks and where a mock can safely sit.
Replace the database in an integration test with a mock and nothing turns red. The test still runs, still passes, and has stopped checking the one thing it existed for, whether your code and the database agree. What it checks now is that your code calls the mock the way you assumed it would.
Whether a test integrates anything comes down to where the mock sits, whatever the file is called. Here is how to place it, with tests you can run that turn red where a mock stays green.
What you’ll learn
- What integration testing checks, and the seams it targets
- Where a mock belongs, with four runnable tests that show what each placement catches
- How one integration test runs, with a database test you can copy and watch fail
- The four ways to sequence integration, and when each one bites
- What a green integration suite cannot tell you
What Is Integration Testing?
Integration testing checks that separately built parts of a system work once they are connected. It targets the boundaries between them, such as a service and its database or two services talking over an API. Unit tests prove each piece works alone. Integration tests prove the connected pieces agree.
Each side of a seam can be correct while the pair fails. Neither of these bugs exists until the two pieces run against each other:
- Precision: An order service prices to three decimal places, and the database column keeps two.
- Field names: A client reads
customer_idafter the provider renamed it tocustomerId.
A double doesn’t stop a test from counting. In Martin Fowler’s split, a narrow integration test doubles the service on the far side and a broad one needs every service live. The test stops integrating when the double replaces your own client code and the query or the HTTP call never runs.
Should Integration Tests Use Mocks or Real Dependencies?
Use the real dependency for the seam under test, and double only what lies past it. The quickest way to empty an integration test is to mock the dependency it exists to verify. The test then checks your assumptions about that dependency. When they are wrong, which is the bug you wanted to catch, it still passes.
Where the Mock Sits Decides What Runs
Mocking your own PaymentClient interface is almost like rehearsing a phone call with yourself. Every answer is the one you expected, because you wrote both sides. A stub at the wire, such as WireMock answering on a real port, makes your HTTP client do its real work, from building the request to parsing whatever comes back.
| Where the double sits | What of your code runs | Catches the provider renaming customer_id to customerId? |
|---|---|---|
In place of your own client (Mockito, unittest.mock, jest.fn()) | None of the integration code | No |
| At the wire (WireMock, MockServer) | HTTP client, serializer, headers, timeouts | Only if the stub matches the real provider |
| Nowhere (the real service in a container, or the vendor’s sandbox) | Everything on your side of the seam | Yes |
Mockito, unittest.mock, and Jest’s jest.fn() all live in that first row. When one turns up in a file named OrderIntegrationTest, check which seam it replaced, and whether it is the one the file is named after.
Four Tests That Show What Each Placement Catches
You can check that last column without Docker. In this script the provider has renamed customer_id to customerId, while your client and your stub both use the old name. Test 3 stands in for the real provider with a local server that sends the new name.
# Python 3 standard library only. Run: python -m unittest -v test_seam
import json, threading, unittest
from http.server import BaseHTTPRequestHandler, HTTPServer
from unittest import mock
from urllib.request import urlopen
class PaymentClient: # your integration code
def __init__(self, base_url):
self.base_url = base_url
def customer_id(self):
with urlopen(self.base_url + "/customer", timeout=5) as r: # real HTTP call
return json.load(r)["customer_id"] # still reads the old field name
def receipt_for(client): # the code that depends on the client
return f"Receipt for {client.customer_id()}"
def serve(body): # a real HTTP server on localhost that always answers with body
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps(body).encode())
def log_message(self, *args):
pass
server = HTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
return server
PROVIDER_NOW = {"customerId": "c_123"} # the provider renamed the field
OUR_STUB = {"customer_id": "c_123"} # the stub still sends the old name
class WhereTheDoubleSits(unittest.TestCase):
def receipt_via_http(self, body):
server = serve(body)
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
return receipt_for(PaymentClient(f"http://127.0.0.1:{server.server_port}"))
def test_1_mock_in_place_of_our_client(self):
client = mock.create_autospec(PaymentClient, instance=True)
client.customer_id.return_value = "c_123"
self.assertEqual(receipt_for(client), "Receipt for c_123")
def test_2_stub_at_the_wire(self):
self.assertEqual(self.receipt_via_http(OUR_STUB), "Receipt for c_123")
def test_3_what_the_provider_sends_now(self):
self.assertEqual(self.receipt_via_http(PROVIDER_NOW), "Receipt for c_123")
def test_4_stub_checked_against_the_provider(self):
self.assertEqual(sorted(OUR_STUB), sorted(PROVIDER_NOW))
We ran it on Python 3.14.0 on 26 October 2026. Trimmed output:
test_1_mock_in_place_of_our_client ... ok
test_2_stub_at_the_wire ... ok
test_3_what_the_provider_sends_now ... ERROR
test_4_stub_checked_against_the_provider ... FAIL
KeyError: 'customer_id'
AssertionError: Lists differ: ['customer_id'] != ['customerId']
FAILED (failures=1, errors=1)
The mock and the stale stub both pass. The run against what the provider sends now raises KeyError: 'customer_id', the failure you wanted before release. Test 4 compares the stub’s fields with the provider’s response and fails by naming both. Contract testing automates a much bigger version of that one check.
Placement matters more now that coding agents write a share of the tests. A study published in January 2026 found agents reaching for mocks more often than people do. Of the agents’ test commits, 36% added a mock, against 26% of those made without an agent.
The count includes unit tests, where a mock is normal, and the study doesn’t measure harm. The authors’ worry is tests that are easy to generate and weak at checking how real pieces interact.
When a Test Double Is Safe
The opposite failure is just as real. J.B. Rainsberger’s integrated tests scam essay targets tests whose result depends on more than one piece of non-trivial behavior, because their paths multiply faster than anyone can cover and the green they produce is false confidence. His fix, in a 2015 follow-up, keeps the doubles and adds a check on them. Collaboration tests show each client using a double correctly, and contract tests show the real service behaving the way clients expect.
Both positions land on the same condition. A double is safe when it sits past the seam you are testing and something keeps checking it against reality. Pact does that between services. It records the consumer’s expectations as a contract and replays them against the real provider, so a stub that drifts fails the build instead of a release.
Real dependency for the seam under test, a checked double for everything past it.
For the seam you care about most, run the real thing. Testcontainers makes a real database or message broker a few lines of test setup, as the next section shows. Which tool fits your stack is a separate question, and the integration testing software guide compares them by the boundary each one runs for real.
Green Integration Tests, Broken Product?
Bring your staging app. Watch Pie run its user flows end to end.
How Does Integration Testing Work?
An integration test runs two or more finished units against each other through their real interface and checks that data and control flow cross the boundary correctly. The runner is the same pytest or JUnit you already use for unit tests. What changes is the lifecycle around each test.
- Provision: Start the real dependency for the seam under test, often as a throwaway container, and stand in for everything past it.
- Seed: Load the rows or messages the test needs, and start every run from the same known state.
- Act: Drive the interface through the same call path production code uses.
- Assert: Check the return value and the side effects that crossed the seam, such as the row written or the event published.
- Tear down: Destroy the environment so the next test starts clean.
Step 1 is what moves the test up a size. Google’s engineering book sorts tests by the resources they use instead of calling them unit or integration tests. A test that starts its own database and talks to nothing beyond localhost is what the book calls a medium test.
The precision bug from the definition shows why the real database matters. Totals live in a column declared numeric(10, 2), while the application prices to three decimal places. An in-memory fake hands back exactly what it was given. Postgres rounds any value with more decimal places than the column’s scale, so the real seam does not.
# Needs Docker running, plus: pip install "testcontainers>=4.15" sqlalchemy psycopg2-binary
from decimal import Decimal
import sqlalchemy as sa
from testcontainers.community.postgres import PostgresContainer
def test_order_total_survives_the_round_trip():
with PostgresContainer("postgres:16") as pg: # provisioned here, removed on exit
engine = sa.create_engine(pg.get_connection_url())
with engine.begin() as conn:
conn.execute(sa.text(
"CREATE TABLE orders (id serial PRIMARY KEY, total numeric(10, 2))"
))
conn.execute(
sa.text("INSERT INTO orders (total) VALUES (:total)"),
{"total": Decimal("19.999")},
)
stored = conn.execute(sa.text("SELECT total FROM orders")).scalar_one()
assert stored == Decimal("19.999") # fails: Postgres stored 20.00
In a real suite the insert and the select would be your repository’s save and load methods, with the same container and the same assertion. Run it and it fails, which is the point. A fake would have passed it on every run.
What Are the Four Types of Integration Testing?
The four types of integration testing are orders of assembly. Each one spares you a different piece of scaffolding and bites at a different moment.
| Type | Order | Where it bites | Scaffolding |
|---|---|---|---|
| Big bang | Every module at once | On the first failure, which could sit at any seam, so you bisect by hand | None |
| Top-down | High-level modules first | Late, when the data and utility code you stubbed finally runs | Stubs for the lower modules |
| Bottom-up | Lowest modules first | Late, when the user-facing flow finally runs on top | Drivers for the upper modules |
| Sandwich / hybrid | Both ends toward a middle layer | All along, because stubs and drivers both need upkeep | Both |
Default to an incremental order. With one module added at a time, a new failure almost certainly lives in the seam you just connected, and it points at itself. In a service architecture the stub becomes a fake HTTP service, which brings you back to the placement table above.
Integration vs Unit Testing: What a Pass Proves
A unit test checks one function in isolation, and a component test, in the front-end sense, renders one UI component on its own. An integration test connects real modules at a seam, and an end-to-end test drives the assembled product through a user journey. The useful difference is what a green run lets you conclude.
| Unit | Component | Integration | End-to-end | |
|---|---|---|---|---|
| Runs for real | The unit’s own logic | The component’s markup and event handlers | Both sides of the seam under test | Everything you own, and any third party you don’t double |
| A pass proves | The logic is right in isolation | It renders and responds on its own | The two sides of one seam agree | The scripted flow completes |
| A failure points at | One function | One component | One seam | A step somewhere in the flow |
Naming gets fuzzy where the layers overlap. A contract test is a specialized integration test that checks only the shared interface between a service and its consumer. A broad integration test that spans several live services starts to resemble an end-to-end test. In the standard sequence of test levels, system testing follows component integration testing and checks the whole system against its requirements.
The middle layer also has a cost, and it can outgrow it. On a call in April, one customer described a suite that was almost all integration tests, past 800 of them. Every pull request took about 20 minutes to get through. The team was moving the integration run to once a day and covering each pull request with unit tests instead.
How to Write Integration Tests That Catch Real Bugs
A good integration test runs one named seam for real and gives the same answer on every CI run. Scope is what separates a useful integration test from a slow, flaky one, and five rules cover most of it.
-
One seam per test: Name the one boundary you are verifying, service to database or service to service, and test only that. A test that needs four real services to pass is an end-to-end test that will fail for four unrelated reasons.
-
Real dependency under test: If the test is about the database seam, use a real database. Mocking the dependency you set out to verify is how a test passes while the bug ships.
-
Doubles at the wire: Replace what you are not testing at the network edge, so your own client code runs. Back any stub of a service you depend on with a contract test.
-
No shared state: Give every test its own seeded data and leave nothing behind. State one test writes and another reads is a reliable route to non-deterministic failures. Every test should pass alone and in any order.
-
The right CI slot: Run integration tests in CI after unit tests and before end-to-end. In that slot they gate merges with seam-level feedback. When the full set gets too slow for every pull request, run the fastest seams on each one and the rest on a schedule.
What Integration Tests Miss When Every Seam Passes
A narrow integration test buys its speed by running one seam live and holding everything else fixed with doubles. Each one is a statement about one boundary. Nothing in a suite of them checks that the statements hold together, in the order a person triggers them. End-to-end testing covers that ground, and a different class of bug lives there.
- Ordering: The payment webhook arrives before the order row it updates has committed, and each seam passed on its own.
- Configuration: The checkout runs in live mode where the tests assumed test mode, and the payment stub never knew the page had modes.
- State across steps: A session expires between cart and checkout, a span no single-seam test covers.
- The screen itself: A button never fires the request your API tests proved works.
We hit the configuration case on a customer’s checkout. Test mode switched on through a URL parameter, and on one of our runs the page stayed live and rejected the test card. A stub of the payment API answers the same way every time. It could never have shown which mode the page was in.
Before release, the direct way to see bugs like these is to run the assembled app through a whole flow, with an end-to-end suite someone maintains or a person clicking through it.
Pie, an agentic testing platform, is a third way to make that run. Its agents map your web app, or your iOS and Android builds, and generate tests for the user flows they find. Those tests run against the assembled app the way a person would use it. Execution is vision-first, so you can author supported tests without maintaining selectors.

Your integration suite keeps its job. A failing integration test names the seam that broke. A failing user flow shows the step where a person’s path stopped, even when every seam is green. You want both answers.
Keep the Mock at the Edge
An integration test earns its place at one boundary, run for real. Mock that boundary and the suite passes on your assumptions alone.
Run a real database for the data seam. Put the stub for the API you don’t own on a real port, and let a contract check keep it honest. Hold each test to one seam, so a failure needs no bisecting.
Every double in this post stands in for a service, and a seam suite never has a stand-in for the person using the app. Pie’s agents play that part. They run your user flows on web, iOS, and Android and check what lands on the screen.
Every Seam Green? Test the Flow.
Keep your integration suite. Hand the user flows to Pie.
Frequently Asked Questions
Integration testing checks that separately built parts of a system work correctly once they are connected. A unit test proves one piece works on its own.
An integration test runs two real pieces against each other and checks that they agree at the boundary between them.
A unit test checks one function or class with its dependencies replaced by fakes. It runs fast and points at a single unit.
An integration test wires real components together, such as a service and its database, and catches the mismatches between them that unit tests cannot see.
Top-down integration tests the high-level modules first and replaces the lower modules they call with stubs that return canned data.
Bottom-up starts with the lowest-level modules and calls them from drivers, small harnesses that stand in for the code above. Sandwich integration runs both directions at once.
Integration testing checks that connected parts agree at their boundaries, usually one seam at a time, with doubles for whatever lies past the seam.
System testing comes later in the standard sequence of test levels and checks the whole assembled system against its requirements.
Use the real dependency for the boundary you are testing and double only what lies past it, ideally at the network edge so your own client code runs.
A mock in place of the seam under test only checks your assumptions about it.
A test that saves an order through your repository into a real Postgres database and reads it back is an integration test of the data seam.
If the column rounds 19.999 to 20.00, the test fails, while a mock of the repository would have passed it.
Run them in CI after the unit tests pass and before the slower end-to-end suite, ideally on every pull request so a broken seam never merges.
If the full set gets too slow for that, run the fastest seams on each pull request and the rest on a schedule.
No. Your integration tests keep checking the seams. Pie runs the user flows that cross them on web, iOS, and Android, and you can author supported tests without maintaining selectors.
A failing integration test names the broken seam fastest. A failing Pie flow shows where a person's path stopped.