2026-05-01
Most developers know they should write tests. Fewer know which kind of test to write for a given situation. The testing pyramid gives you a framework: many unit tests at the base, fewer integration tests in the middle, and a handful of end-to-end (e2e) tests at the top.
Unit tests verify a single function or class in isolation. They're fast (thousands per second), cheap to write, and pinpoint failures precisely. If your function calculates shipping cost based on weight and distance, unit test it with a variety of inputs. Mock external dependencies — databases, APIs, file systems — so the test exercises only your logic.
Integration tests verify that components work together correctly. Does your repository layer actually persist to the database? Does your HTTP handler parse the request, call the service, and return the right status code? These tests are slower because they involve real infrastructure (or close approximations like testcontainers), but they catch an entire category of bugs that unit tests miss: misconfigured connections, wrong SQL, serialization mismatches.
End-to-end tests simulate real user flows — clicking through a UI, submitting forms, verifying redirects. They catch integration issues across your entire stack but are slow, flaky, and expensive to maintain. Use them sparingly for critical paths: login, checkout, payment.
A practical rule of thumb: aim for roughly a 70/20/10 ratio. For every 100 tests, write ~70 unit, ~20 integration, and ~10 e2e. This isn't dogma — it's a starting point.
When to break the pyramid: Not all code benefits equally from unit tests. If you're writing a thin CRUD API where the logic is the database interaction, a pile of unit tests with mocked repositories proves almost nothing. You'd be testing that your mocks return what you told them to return. In that case, invert the pyramid: lean heavily on integration tests that hit a real database, and skip most unit tests. This is sometimes called the testing trophy pattern.
Consider an order processing service:
One mistake to avoid: testing implementation details. If your test breaks every time you refactor internals without changing behavior, it's coupled too tightly. Test what the code does, not how it does it. Assert on outputs and side effects, not on which private methods were called.
