Beyond Unit Tests: The Testing Types Nobody Taught You in School
Every computer science curriculum I know of teaches unit tests, and most bootcamps teach them too, arrange, act, assert, mock your dependencies, aim for good coverage. What almost nobody teaches, at least not until you learn it the hard way in a production incident, is that unit tests are just one tool in a much bigger kit, and several of the most valuable testing strategies I use today I picked up entirely from being burned in production, not from any course.
Contract tests: the gap between services that unit tests cannot see
The first time I got hurt by this gap, we had a frontend team and a backend team working against a shared API, both sides had excellent unit test coverage, both sides were green on every CI run, and yet a backend change that renamed a field from user_id to userId broke the frontend in production for six hours before anyone noticed, because the frontend's mocked API responses in its unit tests still used the old field name and nobody had updated them.
Contract tests exist specifically to close this gap. The idea, popularized by tools like Pact, is that the consumer of an API defines the shape of the request and response it expects, and that expectation gets verified against the actual provider, independently of whether either side's own unit tests pass. A minimal Pact-style consumer test looks something like this on the frontend:
const interaction = {
state: 'a user with id 123 exists',
uponReceiving: 'a request for user 123',
withRequest: { method: 'GET', path: '/users/123' },
willRespondWith: {
status: 200,
body: { id: 123, userId: 123, email: like('[email protected]') },
},
};
That contract gets published somewhere both teams can see it, and the provider side runs a verification step in its own CI pipeline that replays the interaction against the real API and confirms the shape matches. If the backend team renames a field, their own CI now fails on the contract verification step before the change ever merges, instead of the failure surfacing as a mysterious frontend bug six hours after a Friday deploy. You do not need a heavyweight framework to get partial value here either, even a lightweight shared JSON schema that both sides validate against in CI catches a large fraction of these breaks for a fraction of the setup cost.
Snapshot tests: useful, but only if you actually look at the diffs
Snapshot testing gets a bad reputation, and I think it is deserved in about half of the cases where teams use it, because the failure mode of snapshot tests is that they train developers to reflexively hit update snapshot without reading what changed, at which point the test has stopped testing anything and has become an expensive no-op. I have watched entire snapshot suites get rubber-stamped through a CI approval because nobody had time to actually diff a two-hundred-line snapshot file.
Where snapshot tests earn their keep is on genuinely structural output that should rarely change and where a diff, when it does happen, is meaningful and readable. Generated API documentation, CLI help text output, the shape of a complex configuration object after a transformation pipeline. For these, a snapshot test catches accidental structural drift far more reliably than an equivalent hand-written assertion would, because you would need to hand-write dozens of individual field checks to get the same coverage a single snapshot gives you for free.
The rule I hold myself to now is that a snapshot test needs a comment explaining why the snapshot exists and what an unexpected diff would mean, and any snapshot update needs an actual human read of the diff in the pull request, not just a re-run of the update command. If a team cannot commit to that discipline, I would rather they write fewer, more explicit assertions than lean on snapshots that nobody actually reads.
Visual regression tests: catching the bugs that pass every functional test
The category I was latest to adopt, and now would not ship a consumer-facing product without, is visual regression testing. The premise is simple: render a component or a full page, take a screenshot, and compare it pixel by pixel, or perceptually, against a known-good baseline. Tools like Playwright's built-in screenshot comparison or Chromatic make this straightforward to wire into CI.
test('pricing page renders correctly', async ({ page }) => {
await page.goto('/pricing');
await expect(page).toHaveScreenshot('pricing-page.png', {
maxDiffPixelRatio: 0.01,
});
});
What sold me on this category was a CSS refactor where a shared utility class change caused a modal's close button to render off-screen on mobile viewports, but only on mobile, and every functional test we had passed cleanly because the button was still in the DOM, still clickable in a headless test that does not care about viewport rendering, and still had the correct click handler attached. Nothing in a traditional unit or integration test suite was ever going to catch that, because the bug was purely about where pixels ended up, not about logic. The visual regression suite caught it in about four seconds during CI, well before it reached a real user staring at an unreachable close button on their phone.
The honest tradeoff with visual regression tests is flakiness from font rendering differences across environments and legitimate visual changes generating false positive failures that need a human to approve. I manage this by running visual tests in a consistent, containerized browser environment rather than relying on whatever fonts happen to be installed on a CI runner, which eliminated most of the flakiness we had in our first attempt at this a few years ago.
The actual lesson
None of these three replace unit tests, and I still write plenty of them for pure logic, especially anything with tricky edge cases or branching. But unit tests, almost by construction, test units in isolation, and a huge fraction of real production bugs live precisely in the seams between units, between services, between a design system change and a specific page's layout, between what your code returns and what another team's code expects to receive. Once you start categorizing bugs by which seam they lived in rather than which function they lived in, it becomes obvious which testing strategy would have actually caught each one, and that is a much more useful exercise than debating ideal coverage percentages in the abstract.
The other category I have started paying more attention to, though I did not give it its own section above because we are still early in adopting it seriously, is property-based testing, where instead of writing specific example inputs you describe the properties an output should always satisfy and let a framework generate a wide range of inputs, including adversarial edge cases you would never have thought to write by hand, to try to break that property. We found a genuine bug in a date-range overlap calculation this way, an edge case involving a range that started and ended on the exact same millisecond, that had survived years of hand-written unit tests simply because nobody had ever thought to write that specific case. None of these categories replace each other, and I do not think there is a tidy hierarchy where one is objectively more important than the rest. The real skill is recognizing which seam a given piece of code actually lives on, and reaching for the testing strategy built for that seam instead of defaulting to whichever one you already know best.
Written by AIDToolStack Editorial
The AIDToolStack editorial team reviews developer tools, AI products, and MCP servers with hands-on testing and honest assessments. We use the tools we review every day in production.
References & Further Reading
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.