After a year of active development, most test suites show the same pattern: unit tests remain mostly sound, end-to-end tests are an acknowledged mess awaiting refactoring, and integration tests occupy an uncertain middle ground. Some pass consistently. Others are skipped with a note saying "fix soon." A handful fail intermittently, with no one having time to find the cause.
Integration tests go stale faster than unit tests. Knowing why changes how you write and maintain them.
Unit tests have one dependency surface
A unit test for a pure function has one dependency: that function. If its inputs and outputs remain unchanged, the test remains valid. It is coupled only to the function's contract, and pure-function contracts tend to stay stable because they encode business rules that change only when those rules do.
That coupling is direct and visible. You know what the test relies on, and it quickly signals when that dependency changes.
Integration tests have multiple dependency surfaces
An API endpoint integration test depends on at least its request and response schema, the database schema it queries, its authentication middleware, the logic shaping errors, and the behavior of every external service it calls.
A change to any surface can break the test even when the underlying behavior is correct. A database column is renamed. Authentication gains a required payload field. A third-party service changes its error format. The test fails, and the developer must trace the cause, which the error message alone often does not reveal.
With time, integration tests collect these surface breaks. Each takes a little developer time. Across dozens of tests and a year of ongoing product changes, that cost becomes substantial.
Why they are also the last to get updated
Unit tests stay close to a function. Change the function and your editor shows type errors or the test fails loudly. The feedback loop is short, and updating the test happens in the same mental context as making the feature change.
Integration tests are spread out. A database schema change can break three tests in separate files, written by different people and covering different subsystems. The developer who changed the schema may not have written them or even know they exist. CI reports the failures on its next run, often after the PR merges. By then, that developer has moved to another task.
The gap between who makes a change and whose tests fail is why integration tests age faster. The person best placed to repair the test is not the person who first sees the failure.
What makes integration tests more maintainable
Start by reducing the surfaces each test touches. A test that calls one endpoint through its public interface and resets the test database between runs has fewer failure modes than one that chains several endpoints and depends on state carried across calls.
Test factories help: rather than creating state through other endpoints, create it directly through the data layer during setup. This removes dependence on those endpoints working and makes the test's purpose clearer.
Contract testing is the more advanced option. Instead of testing the complete integration against a live dependency, verify that your interface matches an agreed schema, while the other side verifies its own implementation separately. This helps most with external services whose test-environment behavior you cannot control.
The layering decision that prevents most of the problem
The most important decision is not how to write integration tests, but how many to write and what each should cover.
Teams often use integration tests instead of clear API contracts. Without formal documentation of an endpoint's behavior in different conditions, a team may use tests to discover that behavior empirically. The tests then record those observations as expected results. When behavior changes for a valid reason, the tests fail because they target an observed output, not an explicit contract.
In integration failures from our early-access teams' pipelines, we most often see assertions about incidental behavior: one exact error string, a timestamp's precise format, or the order of items in an unordered collection. None describes the system's real contract. Each captures how the system happened to behave when the test was written.
Write integration tests against explicit contracts. If you cannot state the contract before writing the test, write the contract first. The test can then verify it, and implementation changes that preserve it need no test updates. Changes that violate it will fail for the right reason: the contract was broken, not merely the implementation changed.