Testing
Integration Tests Carry the Load Now
September 22, 2026
The first time I reviewed a large agent-generated pull request, the test suite was green and the feature was broken. Not subtly broken — the endpoint returned 200 with an empty body. Every unit test passed, because every unit test asserted against a mock that the same agent had written in the same pass.
That was the moment the pyramid stopped making sense to me as a default. Not because unit tests are bad, but because the thing they verify — that a module behaves as its author imagined — is now the cheapest thing in the system to produce and the least interesting thing to confirm.
In this post:
- Why "which layer is more important" is the wrong question
- The strongest version of "delete your unit and e2e tests," and where it breaks
- What actually makes an integration suite fast in CI
- How integration tests become the acceptance gate in a spec-driven loop
Define the layer before arguing about it
"Integration test" means at least five different things depending on who is talking, so here is the vocabulary I'll use for the rest of the post.
| Layer | Boundary under test | What it proves | Typical cost |
|---|---|---|---|
| Unit | One module, in-process, no I/O | A pure transformation is correct | Under 10ms |
| Integration | Your process plus real adjacent infrastructure | A contract holds end to end inside your system | 50ms to 500ms |
| End-to-end | The deployed system through a real client | The wiring you don't own works | Seconds to minute |
By integration I specifically mean: the real HTTP layer, the real router and middleware, the real database with real migrations applied, the real serialization path. Fakes are permitted only at boundaries you don't own — a third-party payment API, an email provider. The moment you mock your own repository, you are writing a unit test with extra ceremony.
The axis is confidence per unit of maintenance
Every test has two costs: writing it once, and surviving every refactor afterward. The second cost dominates, and it is entirely determined by what the test is coupled to.
A unit test with mocks is coupled to structure — which classes exist, which methods they expose, in what order they're called. Structure changes constantly. An integration test is coupled to a contract: a route, a status code, a response shape, a database invariant. Contracts change deliberately, with a migration and a conversation.
This is why mock drift is the defining failure of the unit layer. Consider a service that must reject orders for out-of-stock items:
// order-service.test.ts — a unit test that cannot fail for the right reason
const inventory = {
findBySku: vi.fn().mockResolvedValue({ sku: "A-1", available: 0 }),
};
it("rejects an order when stock is unavailable", async () => {
const service = new OrderService(inventory);
await expect(service.place({ sku: "A-1", qty: 1 })).rejects.toThrow(
OutOfStockError,
);
expect(inventory.findBySku).toHaveBeenCalledWith("A-1");
});This test is green forever. It stays green when the real query reads stock_on_hand instead of available, when a migration renames the column, when the repository starts returning null for unknown SKUs, and when someone adds a reservation table that the service forgets to write to. It asserts that OrderService calls a function that no longer exists in that shape.
The integration version is barely longer and fails for all of those reasons:
// orders.integration.test.ts — real route, real Postgres, real migrations
it("returns 409 and reserves nothing when the SKU is out of stock", async () => {
await seedInventory(db, { sku: "A-1", stockOnHand: 0 });
const res = await fetch(new URL("/orders", baseUrl), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sku: "A-1", qty: 1 }),
});
expect(res.status).toBe(409);
expect(await res.json()).toMatchObject({ code: "out_of_stock" });
expect(await reservationsFor(db, "A-1")).toEqual([]);
});Note what the second test does not mention: no class name, no method name, no call order. The agent that wrote OrderService can delete it, rename it, split it into three modules, or replace the ORM entirely, and this test still means exactly what it meant yesterday. That property — indifference to internal structure — is the whole argument.
Claim 1: more confidence than unit, less effort than e2e
Mostly true, and worth restating for a better reason than "it's in the middle."
Integration is the deepest point in the stack you can still test deterministically. Below it, you're asserting on implementation details. Above it, you inherit every source of nondeterminism you don't control: browser timing, network flake, CDN caches, third-party auth redirects, a staging environment someone was deploying to while your suite ran.
The real cost of end-to-end tests isn't wall-clock time. It's that they are diagnostically weak. A red e2e run tells you "checkout is broken." A red integration test tells you POST /orders started returning 500 because the reservations insert violates a new constraint. One of those is a bug report; the other is a location.
So yes — integration first. But because it maximizes failure localization per unit of nondeterminism, not because effort sits between two extremes.
Claim 2: you no longer need unit or e2e tests
This is the claim I would block in review, and it's worth taking seriously first, because the strongest version of it is largely correct.
The strongest version. In a typical service codebase, most unit tests are tautologies. They restate the implementation in assertion form, they're the first thing deleted during a refactor, and they're the easiest thing in the world for an agent to satisfy without producing working behavior — if the test asserts a mock was called, the shortest path to green is to call the mock. Meanwhile the e2e suite is where teams burn their afternoons: a long tail of flaky journeys, retried three times, quarantined, and eventually ignored. If you deleted both and kept a well-built integration suite, most teams' actual defect-detection rate would improve.
I agree with all of that. The conclusion still doesn't follow, for three reasons.
Combinatorial logic belongs where it is defined
Some behavior is a pure function of its inputs, and its risk lives in the number of branches, not in the plumbing. Tax rules, proration, date arithmetic across DST boundaries, parsers, state machine transitions, permission resolution.
// Pure logic, exhaustively covered, ~8ms for the whole table
describe.each([
{ region: "DE", subtotal: 10_000, vatExempt: false, expected: 11_900 },
{ region: "DE", subtotal: 10_000, vatExempt: true, expected: 10_000 },
{ region: "US-CA", subtotal: 10_000, vatExempt: false, expected: 10_725 },
{ region: "JP", subtotal: 10_000, vatExempt: false, expected: 11_000 },
// ...36 more rows
])("totalFor($region, exempt=$vatExempt)", (tc) => {
it(`is ${tc.expected}`, () => {
expect(totalFor(tc)).toBe(tc.expected);
});
});Forty rows through the HTTP layer with a database round trip per row is somewhere between 20 seconds and two minutes, and the failure output tells you a request returned the wrong number rather than which rule misfired. Worse, you'd need to construct forty valid orders to exercise forty tax branches, so the test file becomes mostly fixture scaffolding. The rule isn't "avoid unit tests," it's test behavior at the boundary where the behavior is defined. For totalFor, that boundary is the function signature.
This is also where property-based testing and invariant checks live, and those are only practical in-process.
End-to-end tests cover what integration structurally cannot
Your integration suite runs against configuration that is not production configuration. It doesn't exercise the real bundle, the real cookie attributes, the CSP header, the auth provider's redirect chain, the CDN's cache key, the reverse proxy's header rewriting, or the environment variables that only exist in the deployed project. Those are exactly the things that break on a Friday deploy, and no amount of integration coverage sees them.
You don't need two hundred e2e tests. You need somewhere between five and fifteen: sign in, the primary revenue path, and whatever page has hurt you before. Run them against a real preview deployment, treat every flake as a bug in the test or the app rather than a fact of life, and keep them out of the inner loop.
A suite with only one layer localizes nothing
If every test goes through HTTP, a broken date helper fails two hundred tests across nine files and you bisect by hand. A handful of unit tests over shared primitives acts as a fast, precise tripwire underneath the integration suite. That's a diagnostic argument, not a coverage argument, and it's the one people forget.
The honest version of the claim
It's a re-weighting, not a deletion. If I had to put numbers on it for a typical service, roughly 20% unit, 70% integration, 10% end-to-end — while noting that ratios are an output of good decisions, never a target to manage toward. The decision rule that generates them:
| Layer | Belongs here | Does not belong here |
|---|---|---|
| Unit | Pure functions, combinatorial rules, parsers, invariants | Anything requiring a mock of code you own |
| Integration | Route contracts, persistence, authorization, transactions, error mapping, jobs | Exhaustive enumeration of pure branches |
| End-to-end | Auth flows, deploy and env wiring, one revenue path per surface | Business rule coverage, anything with a cheaper failure mode |
Claim 3: it reduces CI time
Only if you engineer it. Left alone, this claim is false, and it's the one that gets teams into trouble.
The steelman is real: deleting a 3,000-test mock suite and a 200-case e2e suite in favor of 600 integration tests usually does cut total wall-clock time, mostly by removing the retried e2e tail. But a naive integration suite — one container per test file, migrations replayed before every test, await sleep(500) sprinkled through the async assertions — is comfortably slower than what it replaced.
The speedup comes from specific mechanics.
Start infrastructure once per worker, not once per test. Boot the container in global setup, run migrations exactly once, then snapshot the result as a template.
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import type { StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import type { GlobalSetupContext } from "vitest/node";
// vitest.global-setup.ts
let container: StartedPostgreSqlContainer;
export async function setup({ provide }: GlobalSetupContext) {
container = await new PostgreSqlContainer("postgres:16-alpine").start();
const uri = container.getConnectionUri();
await runMigrations(uri); // once for the entire run
await createTemplateDatabase(uri, "app_template");
provide("pgUri", uri);
}
export async function teardown() {
await container.stop();
}
declare module "vitest" {
interface ProvidedContext {
pgUri: string;
}
}Reset state with cheap operations. Cloning from a template database is close to instant because Postgres copies files rather than replaying your migration history:
CREATE DATABASE test_worker_3 TEMPLATE app_template;One gotcha that will cost you an hour: Postgres refuses to use a template while any session is connected to it, so nothing — including your migration tool's lingering pool — may hold a connection to app_template when a worker clones it.
Between tests inside a worker, truncate rather than recreate:
TRUNCATE orders, order_items, reservations, inventory RESTART IDENTITY CASCADE;Transaction-per-test with a rollback in afterEach is faster still, but only works when the code under test shares your test's connection. With a pool, or with code that commits explicitly, it silently stops isolating anything — which is worse than being slow.
Parallelize with isolated databases per worker, one database per Vitest worker rather than a shared one guarded by locks. Serialized integration tests are the actual source of the "integration tests are slow" reputation.
Delete every sleep. Poll for the condition with a deadline instead. A suite full of fixed waits pays its worst case on every green run.
Don't boot the world for cross-service behavior. When the interaction is between services, a consumer-driven contract test verifies both sides against a shared pact without a compose file of nine containers. Reserve real multi-service startup for the two or three flows where the interaction itself is the risk.
Shard in CI, and consider trading parity for speed there. Testcontainers gives you identical local and CI behavior, which is worth a lot. If container startup dominates your CI minutes, a managed service container is faster and the parity loss is usually acceptable as long as the image tag matches.
# .github/workflows/test.yml
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx vitest run --shard=${{ matrix.shard }}/4Budget it explicitly rather than hoping: p95 under 300ms per integration test, full suite under ten minutes on four shards, zero retries allowed to pass. When a number goes red, treat it as a broken build, because a suite nobody waits for is a suite nobody runs.
Claim 4: spec-driven development is the real argument
This is the strongest of the four, and it's the reason the other three matter now rather than five years ago.
In a spec-driven loop, a written specification is the source of truth, the agent produces an implementation from it, and something has to decide whether the result satisfies the spec. That verification step is the entire loop. Everything else is generation, and generation is now cheap.
Specs are written in behavior. They say things like:
### AC-3: Out-of-stock orders are rejected
Given inventory for SKU `A-1` is 0
When a client POSTs `/orders` with `{ sku: "A-1", qty: 1 }`
Then respond `409` with code `out_of_stock`
And no reservation row is createdThat maps one-to-one onto an integration test. It maps onto nothing in the unit layer, because the spec says nothing about which classes exist — and it shouldn't. Name the test after the criterion so the mapping survives:
it("AC-3: rejects out-of-stock orders without reserving inventory", async () => {
// ...
});Three consequences follow, and they're what actually changes how I set up a repository now.
Unit tests over generated internals are an active liability. They freeze structure the agent invented and should be free to replace. When the next iteration restructures that module, the tests fail for no behavioral reason, and the agent's cheapest path to green is to rewrite the tests. You've built a ratchet that produces churn instead of confidence.
The tests must be written from the spec, before generation, and treated as the artifact of record. If an agent authors both the implementation and its acceptance tests in one pass, the tests measure the agent's interpretation rather than your specification — which is precisely how you get a green suite over a broken endpoint. Keep acceptance tests outside the agent's write scope for that task, or at minimum review them as the deliverable and the implementation as disposable.
The suite has to be fast enough to run inside the agent's loop. This is where Claim 3 stops being a CI concern and becomes an architecture concern. An agent that can run the relevant integration tests in fifteen seconds converges on working behavior. One facing a six-minute suite either stops running it or burns the iteration budget waiting — and then you're back to reviewing unverified diffs by hand.
The pleasant side effect is that specs, tests, and review all collapse onto the same vocabulary. When a reviewer asks "does this do what we agreed," the answer is a named test rather than an opinion about code structure.
When this advice does not apply
Architecture advice without boundary conditions is marketing, so:
- Libraries and SDKs with no I/O. Your public API is the contract. Unit tests are contract tests there, and there's no integration layer to speak of.
- Algorithmic and numeric code. Correctness lives in branches and edge cases. Test it in-process, exhaustively, and add property-based tests.
- Regulated or safety-critical systems. If you owe an auditor branch-coverage evidence, coverage targets are a requirement, not a smell.
- Frontends. The useful pair is component tests plus a few Playwright journeys. The "integration" analogue is rendering a real tree with real state management and faking only at the network boundary.
- Distributed systems. The failures that hurt are timing, partial failure, and retry storms. Those need fault injection and load, not a larger integration suite.
Takeaways
- Choose layers by confidence per unit of maintenance, not by position in a pyramid. Integration wins because it couples to contracts instead of structure.
- Mock drift is the failure mode: a mocked unit test asserts that your code calls a function you also invented, which is exactly the assumption agent-written code breaks.
- Keep unit tests for behavior defined at a function boundary — combinatorial rules, parsers, invariants — and as fast tripwires that localize failures.
- Keep five to fifteen end-to-end tests. They're the only evidence that the wiring you don't own works.
- Integration suites are not free. They get fast through containers per worker, template-database resets, real parallelism, no sleeps, and contract tests instead of booting every dependency.
- Under spec-driven development, acceptance criteria compile into integration tests. Write them from the spec before generation, keep them out of the implementing agent's scope, and keep them fast enough to run in the loop.
Next in this series: how to migrate an existing mock-heavy suite without a rewrite freeze — what to delete first, what to convert, and how to keep the build green while the shape changes.