Here’s a controversial take: most unit tests are a waste of time. Not the ones for pure functions or complex algorithms — keep those. But for the vast majority of application code — API handlers, React components, database queries — unit tests test the wrong thing. They test mocks. They test that your code calls other code in the right order. They don’t test that your software actually works.
An integration test for an API endpoint sends a real HTTP request, hits a real database, runs real middleware, and asserts on the real response. When it passes, you know the endpoint works. When a unit test with five mocks passes, you know the handler called the right mock with the right arguments — which tells you almost nothing about what happens in production.
“The more your tests resemble the way your software is used, the more confidence they can give you.” — Kent C. Dodds
The Case Against Mock-Heavy Unit Tests
Think about what happens when you unit test a typical API handler. The handler calls a validation library, a user service, a payment service, and a notification service. To isolate the handler, you mock all four. Now you have 15 lines of mock setup for 3 lines of actual logic.
What did you actually prove? That the handler calls userService.findById with the right argument. That it passes the result to paymentService.create. That it calls notificationService.send afterward. You tested the wiring — not the behaviour.
Here’s the deeper problem: mocks encode assumptions about how dependencies behave. Your mock returns a clean user object. But what if the real database returns null for a deleted user? Your mock returns a successful payment. But what if the real service throws a validation error for that currency? Every mock is a lie you’re telling your test suite — a small one, usually, but lies compound.
Now imagine the integration test instead. You create a real user in the database, send a real HTTP request with real auth headers, and assert on the real response body and the real database state afterward. If you refactor the handler’s internals — change the order of operations, extract a helper, switch from callbacks to async/await — the test stays green. It only breaks when the actual behaviour changes.
| Approach | What It Proves | Fragility | Refactor Safety |
|---|
| Unit test with mocks | Handler calls dependencies in the right order | Breaks on any internal change | Low — coupled to implementation |
| Integration test | The endpoint works end-to-end | Breaks only when behaviour changes | High — tests the contract |
If your test file has more lines of mock setup than actual assertions, that’s a strong signal you’re testing implementation details, not behaviour. Consider replacing those unit tests with a single integration test.
The Testing Diamond
The traditional testing pyramid says: lots of unit tests at the base, fewer integration tests, and a handful of E2E tests at the top. For modern full-stack TypeScript applications, I’ve found a diamond shape works far better.
| Layer | Proportion | What It Covers | Why This Ratio |
|---|
| E2E (Playwright) | ~10% | 5–10 critical user journeys | Expensive to write and maintain — reserve for highest-value paths |
| Integration | ~60% | API endpoints, components with real data, DB queries | Best confidence-to-cost ratio by far |
| Unit | ~20% | Pure functions, utilities, complex calculations | Cheap and fast, but limited confidence for app code |
| Static (TypeScript + ESLint) | ~10% of effort | Types, patterns, formatting | Nearly free once configured — invest heavily |
The diamond is widest at integration because that’s where confidence peaks relative to effort. Integration tests cover real behaviour without the brittleness of full browser E2E, and without the false confidence of mocked unit tests.
Making Integration Tests Fast
The most common objection to integration tests is speed. “They’re too slow for CI.” They don’t have to be. Here are the patterns that keep suites fast.
| Technique | What It Does | Impact |
|---|
| Test containers | Spin up a Postgres instance per test run using Testcontainers. Starts in ~3 seconds, runs in memory, destroyed when done. | Eliminates external DB dependency; consistent across environments |
| Transaction rollback | Wrap each test in a database transaction and rollback after. Nearly instant cleanup vs truncating tables. | Cuts suite time by 40–60% compared to table truncation |
| Factory functions | Create test data programmatically with sensible defaults and overrides instead of static fixtures. | Tests are self-contained, readable, and won’t break when the schema changes |
| Parallel execution | Run test files in parallel (Vitest’s default). Transaction isolation prevents interference between files. | Linear speedup proportional to available CPU cores |
| Selective mocking | Only mock true external services (payment gateways, email providers). Never mock your own code. | Keeps tests realistic while avoiding network calls to third parties |
Start a single test container for the entire suite, not per file. A cold Postgres container takes ~3 seconds. Across 50 test files, that’s 2.5 minutes of pure container overhead you can eliminate.
When Unit Tests DO Matter
I’m not a unit test nihilist. There are clear cases where they’re the right tool. The key is understanding the boundary.
| Use Unit Tests When… | Use Integration Tests When… |
|---|
| The function is pure (input → output, no side effects) | The code involves I/O — database, network, file system |
| The logic has many edge cases that need exhaustive coverage | The value is in components working together, not in isolation |
| You’re testing an algorithm — sorting, parsing, rate limiting | You’re testing an API contract — request in, response out |
| The function is a shared utility used across the codebase | The code has middleware, auth, or validation in the chain |
| Speed matters and the function can be tested in <1ms | The function’s real dependencies are part of what you’re verifying |
// A perfect candidate for unit testing: pure, complex, many edge cases
describe('calculateTax', () => {
it('applies 10% GST for domestic transactions', () => {
expect(calculateTax({ amount: 10000, country: 'AU' }))
.toEqual({ net: 10000, tax: 1000, total: 11000 });
});
it('exempts tax for international transactions', () => {
expect(calculateTax({ amount: 10000, country: 'US' }))
.toEqual({ net: 10000, tax: 0, total: 10000 });
});
});
The pattern is simple: if it’s pure logic with complex branches, unit test it. If it involves I/O, side effects, or system integration, write an integration test.
Testing Error Paths
The most valuable integration tests aren’t the happy paths — they’re the error paths. What happens when the database is down? When the external API returns a 500? When the request body is malformed?
test('returns 422 for invalid payment amount', async () => {
const user = await factories.user.create();
const response = await request(app)
.post('/api/payments')
.set('Authorization', `Bearer ${generateToken(user)}`)
.send({ amount: -100, currency: 'AUD' });
expect(response.status).toBe(422);
expect(response.body.errors[0].field).toBe('amount');
});
I’ve seen teams require every API endpoint to have at least one error path integration test. The question to ask: “What happens when this dependency fails?” Error handling code gets the least manual testing, which makes it the most valuable code to automate tests for.
The testing philosophy is straightforward: test the things users and systems interact with. HTTP endpoints. Rendered components. Published events. Database state. Everything else is an implementation detail you should be free to refactor without fear.