Integration testing verifies that two or more components of a system work correctly when connected to each other, using real (or realistic) versions of the dependencies involved rather than mocks. Where a unit test checks a function's own internal logic in isolation, an integration test checks the seam between pieces: an application talking to a real database, a service calling a real (or very close to real) external API, two internal services exchanging a message over a shared queue. It answers a question a unit test structurally cannot: does this actually work when the pieces are connected, not just when each piece is imagined to behave a certain way on paper.
Integration testing exists because the boundaries between systems are where a specific, common class of bugs lives. A function can have flawless internal logic and still fail the moment it talks to a real database, because the query doesn't match the actual schema, the connection pool runs out under load, or a type conversion behaves differently than the mock assumed it would. Integration tests catch exactly this kind of bug, the kind unit tests are blind to by design, because a mock only ever behaves the way the person who wrote it assumed the real thing would.
The mechanism is running real dependencies instead of fakes, but doing it in a controlled, repeatable way. Instead of mocking a database call, an integration test runs against an actual database, often a disposable one spun up in a container just for that test run, seeded with known data. Instead of mocking an external API, it might call a sandbox version of that same API, or a local mock server configured to behave just like the real one. The goal is realism without the full cost and fragility of running an entire end to end system. That makes this layer a deliberate middle ground, not a compromise nobody wanted.
By 2026, integration testing has gotten easier to do well, mostly because of tooling that makes spinning up real, disposable dependencies fast and cheap. Container-based test databases, in-memory-compatible versions of common data stores, and API sandboxes from most major providers mean a team can run a real database in a test pipeline in seconds instead of provisioning a shared environment a dozen other tests depend on too. This has turned integration testing from something painful and rare into something teams run on every merge without much friction, closing a gap that used to sit awkwardly between fast unit tests and slow end to end suites.
This page covers what integration testing actually verifies, how it differs from both unit and end to end testing, where teams often draw the boundary wrong, and how to build integration tests fast enough to run constantly and honest enough to catch real problems. Some bugs only exist at the boundary between two real systems, and testing each system alone will never find them. Once a team accepts that, it can decide, deliberately, which boundaries in its architecture carry enough risk to deserve this kind of direct verification, instead of assuming every mock is telling the truth.
An "integration" here is any point where your code hands off to, or receives something from, a system it doesn't fully control: a database, a cache, a message queue, a file system, another internal service, a third-party API. The test verifies that the handoff works correctly under real conditions, not that the logic on either side of the handoff is correct, since that's the unit test's job. The dividing line is deliberately about control: anything the code under test doesn't fully own is a candidate boundary worth testing directly.
A simple example makes the distinction easy to remember. Say a service needs to save a customer record. A unit test would check that the function correctly builds the SQL query or the object it intends to save, given certain inputs, with the actual database call mocked out entirely. An integration test would run that same save operation against a real database and confirm the record actually persists correctly, that the schema accepts the data types being sent, that constraints and indexes behave as expected, and that reading it back returns what was written. Both tests check the same feature. They check entirely different failure modes within it.
This distinction matters because the failure modes at each layer are genuinely different. A unit test failure usually means the logic is wrong. An integration test failure usually means the logic is fine but the interaction with the real system isn't working the way it was assumed to, a mismatched column type, a missing index causing a timeout, a queue consumer that isn't acknowledging messages the way the producer expects. Neither test type substitutes for the other, because they check fundamentally different things, and a team with only one of the two is blind to whichever category of bug the missing layer would have caught.
Not every dependency in a system needs equal integration test coverage. A database the application reads and writes constantly, with complex queries, deserves thorough coverage, because there's a lot of surface area for schema mismatches and query bugs. A logging library that just writes lines to a file usually doesn't need the same scrutiny, because the interaction is simple and the risk of a subtle mismatch is low. Judgment about where the real risk sits should drive how much coverage a given boundary gets, not a blanket rule applied the same way to every dependency regardless of how much it actually matters.
A quick way to prioritize: rank each boundary by how often it changes and how bad a silent failure there would be. A payment gateway integration that changes rarely but would cost real money if it silently failed belongs near the top of the list, right alongside a database schema that a dozen engineers modify every sprint. A read-only integration with a slow-moving internal analytics service can usually wait, because even a subtle bug there tends to surface as a wrong number on a dashboard, not a broken checkout.
Container technology has probably done more than any other single tool to make integration testing practical at real scale. A test can spin up a real instance of a database, a message broker, or a cache in a container, seeded with known data, run the test against it, and tear it down afterward, all within the same test run and without touching a shared environment anyone else depends on. This removes the two biggest historical pain points of integration testing: slow setup and unpredictable shared state. Libraries built specifically for this purpose have made it standard practice across most modern language ecosystems to launch a real, disposable database as part of a test suite's setup, instead of a fragile hand-rolled script half the team doesn't fully understand.
For external, third-party dependencies that can't be run locally at all, sandbox environments and mock servers fill a similar role. Many payment processors, communication platforms, and cloud providers offer sandbox versions of their APIs specifically for this, behaving like the real service but without real transactions or real side effects. Where no sandbox exists, tools that record and replay real API responses let a team capture what the real dependency actually returns once, then replay that recorded behavior in every subsequent run, keeping tests fast and deterministic without needing live network access every time. This recorded-response approach also protects a test suite from a third party's own downtime or rate limits, which would otherwise make an integration suite as unreliable as the external service it depends on.
Contract testing has emerged over the past several years as a specific, valuable technique for integrations between services a team controls internally, especially in a microservice architecture. Rather than standing up every dependent service for every test, each service publishes a contract describing what it expects to send and receive, and consumer and provider tests get checked independently against that shared contract. This catches a whole class of integration bugs, a field renamed on one side without the other side being updated, without needing every service running together for every test. It scales much better than full integration testing across every service pair as the number of services grows, since the number of pairwise combinations to test explodes fast in a system with more than a handful of services.
Test data management deserves the same discipline at this layer as at the end to end layer, arguably more, since integration tests tend to run far more often in a normal development cycle. Seeding a fresh, known dataset before each run, rather than relying on whatever data happens to already exist, keeps tests deterministic and prevents the slow data rot that makes a shared test database less trustworthy the longer it goes unmaintained. Teams that automate this seeding as part of their test harness, instead of relying on manual setup steps someone has to remember, see far fewer mysterious, hard-to-reproduce failures over time. I've seen a single unowned seed script cause three separate "flaky test" investigations before anyone traced it back to the actual cause.
The most frequent failure pattern is duplicating unit test coverage at a higher, slower layer. A team writes a unit test suite covering every branch of a discount calculation, then writes an integration test that runs the same set of scenarios again against a real database, gaining almost nothing beyond confirming the save and retrieve mechanics work, at a fraction of the speed. Over time this duplication adds up to a slow integration suite that mostly re-proves things already proven cheaply elsewhere, without anyone deciding on purpose that the extra runtime was worth it.
Another common pattern is testing against shared, mutable environments instead of isolated ones. When integration tests run against a shared staging database that other tests, other developers, or scheduled jobs also touch, failures start showing up that have nothing to do with the code under test: a record another test created and never cleaned up, a schema migration mid-deploy, a connection pool exhausted by an unrelated process. These failures erode trust in the suite just as thoroughly as flaky end to end tests do, for the same underlying reason: shared, unpredictable state that nobody fully controls or fully understands.
A third pattern is scope creep, where an integration test meant to check one boundary quietly grows to cover several, becoming a de facto end to end test without anyone deciding that on purpose. A test that starts by checking a database save operation gradually picks up an API call, then an authentication check, until it's slow, hard to diagnose when it fails, and duplicating coverage that belongs at a different layer entirely. This tends to happen one small addition at a time, which is exactly why it's hard to notice until the test has already become a maintenance burden.
The fix for all three patterns is the same discipline: define clearly what boundary each integration test exists to check, keep it scoped to exactly that boundary, and isolate its environment so failures mean something specific. Teams that revisit their integration suite periodically with this lens tend to catch scope creep and duplication before the suite becomes another slow, low-trust layer nobody wants to touch, which is a much cheaper habit than rewriting the suite from scratch once it's already unwieldy.
A five-minute test of your own suite's health: open the last ten integration test failures and ask, for each one, whether the failure told the person who saw it exactly which boundary broke. If the answer was usually yes, the suite is scoped well. If the answer was usually "not really, I had to dig through three services to find out," scope creep has probably already set in, even if nobody labeled it that way at the time.
Integration testing earns its place, in nearly every codebase, at every real boundary in a system: every distinct way the application talks to a database, every external API it calls, every message queue it produces to or consumes from. Each represents a place where the two sides could disagree about the shape of data, the timing of operations, or the guarantees being made, and each deserves at least one test exercising the real interaction rather than assuming it works. This is especially true for boundaries that changed recently or that involve a service outside the team's own control, since those are exactly the places mismatched assumptions tend to pile up.
It's generally a poor use of integration testing to re-verify business logic unit tests already cover reasonably well. If a discount calculation has ten unit tests covering every edge case with mocked inputs, running those same ten scenarios again through a real database adds slowness and maintenance cost without adding much confidence, because the calculation logic itself isn't what's in question at this layer. The integration test for that same code path should focus narrowly on whether the calculated result gets saved and retrieved correctly, not on re-proving the math is right. Teams that lose sight of this distinction often end up with an integration suite that takes far longer to run than it needs to, for very little additional confidence gained.
It's also clearly not a substitute for end to end testing when the actual risk is about a full user journey rather than a single boundary. An integration test might confirm that an order saves correctly to the database and that a payment API call succeeds independently, but it won't tell you whether the full checkout flow, using both of those pieces together with a real UI, actually works for a real user. That's a different question, answered by a different layer, and trying to answer it with integration tests alone leaves a real gap a business ends up discovering the hard way, usually in production.
Where integration testing has an unusually large and outsized role to play is in systems where most of the actual complexity lives in coordinating between services rather than in self-contained logic, like a thin orchestration service or a data pipeline connecting several external systems. There isn't much to unit test in a system like that, and a strong integration test suite, covering each real connection point thoroughly, may reasonably carry more of the overall test burden than in a typical application with more internal business logic. It's one of the clearest examples of why the test pyramid works better as a starting assumption to check against a system's actual risk profile than as a rule to apply blindly regardless of what kind of system it is.
The starting point, before writing a single test, is mapping out the real boundaries in the system deliberately: every database, every external service, every queue, every file system interaction, and deciding which carry enough risk and enough traffic to deserve dedicated integration coverage. This mapping exercise alone often surfaces gaps a team didn't realize existed, boundaries nobody had thought to test directly because the surrounding unit tests felt like enough. Doing this once a quarter, or whenever a significant new dependency is introduced, keeps the map current instead of letting it quietly go stale.
Isolating each test's dependencies from every other test's dependencies is worth real investment. Running integration tests against containerized, disposable databases rather than a shared staging database eliminates an entire class of false failures caused by other tests or other people's changes leaving unexpected state behind. The upfront cost of setting up this kind of isolated tooling pays for itself quickly once a team stops debugging test failures that have nothing to do with the code being tested, and stops losing an afternoon every few weeks chasing a failure that turns out to be someone else's leftover test data.
Keeping each integration test scoped narrowly to the one boundary it's checking, rather than letting it balloon into a mini end to end test, keeps the suite fast and its failures easy to interpret. A well-scoped integration test for a database save operation checks that the save and retrieval work correctly. It doesn't also check the API layer, the authentication flow, and the UI rendering on top of it, because bundling all of that into one test makes failures much harder to diagnose and the test much slower to run. Reviewing new integration tests with an eye specifically for scope, not just correctness, is a cheap habit that prevents this kind of drift before it compounds.
Running the integration suite on every single merge, rather than reserving it for a nightly job, is achievable once the tooling is fast enough, and it's genuinely worth the investment because it catches boundary bugs before they compound with other unrelated changes. A suite of well-isolated, containerized integration tests can often run in a few minutes, slow compared to a unit test suite but still fast enough to be part of the normal development loop instead of something developers wait hours for. Teams that push this suite to a nightly-only cadence usually do so because it's grown slow or unreliable, which is worth treating as a signal to fix the suite rather than accepting a longer feedback delay as inevitable.
Integration testing verifies that two or more components of a system, an application and a database, say, work correctly together using real or realistic dependencies rather than the mocks a unit test would use. The actual connection between them is what gets checked, not just each side's logic in isolation.
Unit testing isolates a single piece of code from everything it depends on using mocks. Integration testing deliberately uses real or realistic dependencies to confirm the connection between two components actually works as expected, instead of assuming the mocked behavior matches reality.
Integration testing checks a specific boundary, an app talking to a database, for instance. End to end testing verifies an entire user journey across every layer of the system, including the UI, at once. Integration tests are narrower in scope and generally faster and more stable as a result.
Container technology is widely used to spin up real, disposable databases and message brokers for test runs, while sandbox APIs and contract testing tools help verify integrations with external or internal services without needing every dependency running live. Recorded API responses are another common technique for third-party services that don't offer a sandbox at all.
No, generally not. Integration tests should focus on whether the real interaction with a dependency works correctly, not on re-verifying calculations or business rules unit tests already cover more cheaply and precisely at a fraction of the runtime cost.
Contract testing is a technique where services publish an agreed-upon contract for what they send and receive, and consumer and provider tests are checked against that contract independently, catching integration mismatches without needing every service running together. It scales much better than full pairwise integration testing as the number of services in a system grows, since the number of possible pairs grows quickly with each new service added.
A shared environment accumulates unpredictable state from other tests, other teams, and other deploys, causing test failures unrelated to the code being tested. A disposable environment starts from known, consistent data every time, making failures far easier to trust and to diagnose.
There's no fixed number, but a reasonable target is at least one test covering each distinct boundary type: each database query pattern, each external API call, each queue interaction, rather than trying to mirror every unit test scenario at this layer. Adding more beyond that mostly adds runtime without adding proportional confidence.
No. Integration tests confirm individual boundaries work, but only an end to end test confirms that an entire user journey, using several of those boundaries together with a real interface, actually produces the correct result for a real user. Both layers exist for a reason, and neither substitutes fully for the other.