For a few weeks, a bunch of posts on our company blog were broken and nobody knew.
Pages loaded fine. Server said 200 every time. But inside them there were blank boxes where images should've been, and internal links that went straight to 404s. Nothing threw an error, so nothing alerted anyone. We only found it during an SEO audit. Not from tests, not from readers. Readers just saw the blank boxes, clicked a dead link, and left.
Everything "worked". It just didn't do what it was supposed to.
That bug changed how I look at every "green" check, and the same pattern shows up anywhere a page talks to an API. Picture a "book a demo" form. You fill it in, click submit, get the little success state, and the network tab says 200. But a field got renamed on one side and not the other, so the endpoint politely accepted the request, dropped the data it didn't recognize, and said OK. The lead never reaches sales. No errors, no alerts, and every check that only looks at status codes stays green.
I'll use that form as the example for the rest of this post, because it's the kind of thing that's easy to test and easy to test badly.
What I mean by "functional"
I'm not a QA engineer. I work close to the product and the website, the part where real people show up, so I mostly see bugs from the user's side. And from that side the definition is pretty simple: functional testing is checking that the thing actually does what someone needs it to do, not just that it runs without crashing.
The demo form's job wasn't "accept a POST". Its job was "get this person a call with us". Everything between the click and a sales rep seeing the lead is part of the feature. If any piece of that chain drops the ball, the feature is broken, even if every single piece returns 200.
How it lines up against the other test types
Quick version, because you've seen the diagrams:
- Unit tests check one function or component on its own. Fast, cheap, and blind to anything outside their box.
- Integration tests check that two or more parts talk to each other. Service to database, frontend to API.
- Functional tests check behavior against a requirement, from the outside. Black box. "When a visitor submits the form, sales gets a lead."
- Non-functional tests check how well it runs: speed, load, security, accessibility.
My take: the line between integration and functional isn't about how many services the test touches. It's about where the assertion comes from. If it comes from the code ("the endpoint returned 200"), it's an integration check. If it comes from the requirement ("a lead exists and the visitor saw a confirmation"), it's functional. Same tooling, different question.
I'm not going to do the whole family tree of smoke, sanity, regression, UAT and friends, with one-line definitions nobody remembers. If you want the full map of what functional testing covers, types and process included, that write-up is a better reference than anything I'd squeeze in here.
Where these bugs come from (in my experience)
Contracts that drift. Frontend and backend agree on a shape, then one side changes. Nothing crashes, because most APIs are polite and ignore fields they don't know. Polite is the problem.
Flows that cross boundaries. The form lives on the website, the endpoint lives in the app, and the lead lands somewhere else again. Three owners. Each one tests their piece. Nobody tests the trip.
Assets and links that rot. This was our blog. Images and pages move, the posts keep pointing at where they used to be, and the page still loads fine the whole time.
"Done" was never written down. If nobody says what success looks like, the default becomes "no error". That's how a 200 quietly turns into the definition of working.
The mistake I made for too long
Treating "it loads and returns 200" as "it works".
A 200 response with a broken form or a missing image is still a broken page. I trusted "no errors" for way too long, mostly because checking status codes is fast and it feels like you're doing something.
The second one: assuming users would tell us. They don't. Nobody emails to say an image is missing. They leave, and the only trace is a session recording nobody's watching.
These quiet bugs are expensive in a boring way. Stripe's Developer Coefficient report (2018) found developers spend about 3.8 hours a week fixing bad code, out of an average 41.1-hour week. I'd bet a good chunk of that is bugs that sat live for weeks before anyone noticed, because by then you're fixing the code and cleaning up the data it lost.
What a functional test for this looks like
Here's what a functional test for that demo form could look like. Playwright with TypeScript. It checks what a visitor and the sales team care about, not just that a request went through.
import { test, expect } from '@playwright/test';
test('demo request actually reaches the team', async ({ page }) => {
await page.goto('/book-a-demo');
// the page loading proves nothing, check what a visitor sees
const brokenImages = await page.locator('img').evaluateAll(
(imgs) => imgs.filter((i) => !(i as HTMLImageElement).naturalWidth).length
);
expect(brokenImages).toBe(0);
await page.getByLabel('Work email').fill('sam@example.com');
await page.getByLabel('Company').fill('Acme');
const leadCall = page.waitForResponse((r) => r.url().includes('/api/leads'));
await page.getByRole('button', { name: 'Book a demo' }).click();
const res = await leadCall;
// a 200 isn't the goal, a saved lead and a visible confirmation are
expect(res.ok()).toBeTruthy();
expect((await res.json()).leadId).toBeTruthy();
await expect(page.getByText(/we'll be in touch/i)).toBeVisible();
});
A few notes:
- The image check is the blog lesson baked in. A
naturalWidthof 0 means the browser couldn't render it. Lazy-loaded images below the fold need a scroll first, otherwise you get a pile of false alarms. - Asserting on
leadIdand not justres.ok()is the whole point. Swap in whatever your endpoint actually returns. - Using
getByLabelandgetByRolefrom the Playwright locator docs means the test fails if the form stops being usable by its labels, which is kind of a functional check on its own. - Run it against staging with a test lead that gets cleaned up afterwards, not production. Nobody in sales wants 300 demo requests from
sam@example.com.
What we actually run today
On the engineering side, our team auto-generates API tests from real traffic and runs them in CI. Real requests get replayed against new builds, so the API is tested against what users actually send, not what we imagined they'd send. That kind of test is good at catching contract drift like a renamed field, because it's built from what real requests look like.
On the website and content side, a lot is still manual. I check pages myself after releases. Audit tools run for broken links and missing images. And I watch Microsoft Clarity recordings to see where people get stuck or rage-click.
The traffic-based tests and the manual checks catch different things, which is kind of the point. One tells me the API still behaves the way real requests expect. The other tells me a human can still get from landing on the page to actually getting what they came for.
For CI, keep the functional suite small and trusted. Google's testing team wrote back in 2016 that about 1.5% of all their test runs reported a flaky result, and almost 16% of their tests had some level of flakiness (Google Testing Blog). If Google can't avoid it, your 40-test suite won't either. Quarantine flaky tests the same day. A suite that cries wolf just trains everyone to hit "re-run".
The opinion people argue with me about
Session recordings of real users catch more functional bugs than half the test cases people write.
Most test cases test what the developer expects. Fill the field correctly, click once, assert. Real users paste emails with a trailing space, double-click submit, open the form in two tabs, hit back halfway through. The test suite didn't know any of that existed. The recordings did.
But that doesn't mean skip automation. It means use the recordings to decide what to automate next. Every weird thing a recording shows you is a candidate for a test case. And some things you'll only ever catch by using the product like a real person. Honestly, the "automate everything" crowd just hasn't watched enough recordings.
(Unrelated, but if you've never watched a recording of a stranger using your own site, do it once. It's humbling.)
If you're setting this up from scratch
- Write the requirement as a sentence a user would say. "When I book a demo, sales gets my details and I see a confirmation."
- Test that sentence end to end, black box. Assert on outcomes, not status codes.
- Happy path first, then the ways real people go off script: double submits, weird input, the back button, a slow network.
- Automate the stable flows that touch money or leads. Keep humans on the rest.
- Run it in CI on every merge, and watch a handful of real recordings regularly.
If you take one thing from this: a 200 tells you the server answered. It doesn't tell you the user got what they came for.
What's the sneakiest "everything returned 200" bug you've shipped, and what finally caught it?
Top comments (0)