What Your Frontend Tests Are Actually For
How I test this Astro site across unit, component, and end-to-end layers, why I deliberately exclude some files from coverage, and what a green run can and cannot promise.
A tool told me this site had 41% test coverage, and for a moment I felt the pull every engineer knows: open the files with the lowest numbers and write tests until the percentage looks respectable. I want to talk about why I did not do that, and what I did instead, because the difference is the whole point of testing.
Coverage measures how much of the code your tests happen to run. It is a by-product of testing well, and if you go at the number directly you end up with tests that execute code without really checking it, a percentage that climbs, and a codebase that is no safer than before. So before I added a single test, I asked the question the number cannot answer: what are these tests actually for?
Why I write a test at all
Here is the definition I keep coming back to. A test exists so you can change code and find out, quickly and automatically, whether you broke something that matters. That is the whole job. The point is to make the next change safe, not to hit a percentage or to prove the code worked once.
That framing decides everything downstream. It tells you what deserves a test (logic that can break in ways a user would feel), what does not (markup that either renders or visibly does not), and how to write the test (assert the behaviour someone depends on, not the implementation that happens to produce it today). The coverage number is what falls out when you do that honestly. It is something I read to see where I am, and not something I try to push up on its own.
How I actually test this site
This repo is a small Astro site with React islands, and it is deliberately over-engineered as a place to practise good habits. Testing is layered, and each layer answers a different question.
Unit tests for the logic that can quietly be wrong
Vitest(opens in a new tab) covers the parts of the codebase where a subtle mistake would not announce itself. These are the files I went after first, because they carry real consequences:
-
The analytics consent gate. utils/analytics.ts(opens in a new tab) is the one place where a mistake does real harm, because it would send a visitor’s data somewhere they explicitly refused to allow. Nothing may reach Google Analytics unless analytics is configured, the script is present, and the visitor has granted consent. Advertising signals stay denied regardless. The failure that worries me is the branch nobody exercises by hand: consent withheld, but an event fires anyway. That is the kind of mistake that looks fine in review and slips straight through, so the test(opens in a new tab) walks every guard deliberately: no measurement id, no
gtag, consent withheld, consent granted. I did not write it for the line coverage. I wrote it so that if I refactor that file and a single event could ever leak without consent, a test goes red before a visitor would. -
The blog’s single source of truth. utils/blog-collection.ts(opens in a new tab) loads posts, drops drafts, sorts newest-first, and validates that no two posts collide on a slug or a taxonomy label. If that validation breaks, every blog page renders wrong at once. A test that feeds it a duplicate slug and asserts it throws is worth more than any amount of clicking around.
-
The error-prone little functions. The RSS feed (feed.xml.ts(opens in a new tab)) escapes the five XML entities by hand (
&,<,>,",'), and forgetting one produces a feed that breaks in a reader I will never personally open. The inline-markdown parser (renderInlineMarkdown.tsx(opens in a new tab)) has to nest~bold~inside*italic*correctly. These are small, pure, and exactly the shape of code that unit tests were invented for.
Testing components by what the user can do
Some logic only exists once a component is alive. The theme provider
(ThemeProvider.tsx(opens in a new tab))
reads a stored preference, falls back to the system setting, and keeps the dark class on <html>
in sync. The contact card de-obfuscates an email that I store reversed to deter scrapers, and copies
it to the clipboard. I test these with
Testing Library(opens in a new tab), and the discipline there is simple: query the way a
user would, by role, by label, by text, and assert what the user gets. Never reach for an internal
state variable. A test that is coupled to the implementation breaks when you refactor code that was
actually correct. That teaches you to ignore it, which is worse than having no test there at all.
End-to-end tests for the things only a browser knows
Playwright(opens in a new tab) drives a real browser against the built site, and axe(opens in a new tab) checks accessibility on the rendered pages, scoped to the WCAG(opens in a new tab) criteria. I treat accessibility as a behaviour I assert, the same as any other, rather than a box I tick at the end. This is the layer that catches what unit tests structurally cannot: the page loads, the island hydrates, the link goes where it should.
There is a detail in the blog e2e tests(opens in a new tab) I am quietly proud of. They assert that the blog pages behave (an index that lists articles, a post that renders its hero and table of contents) by navigating through the first available link. They never assert what any specific post says, or how many posts exist. So adding a post, or renaming and retitling this one, never breaks a single test. That is deliberate. A test that pinned the exact wording or the post count would be checking a snapshot of today’s content instead of the behaviour I actually care about, and tests that encode incidental facts like that are how a suite slowly rots into something nobody wants to touch.
What I deliberately do not unit-test
Here is the part that felt like cheating until I thought it through. A lot of this codebase is pure-presentational: the résumé sections, the banners, the testimonials, the markup that arranges data on a page with no branching logic of its own. I do not write unit tests for those, and I exclude them from the coverage measurement entirely, in both the Vitest config(opens in a new tab) and the Sonar config(opens in a new tab).
Excluding them keeps the number honest. Those components are already exercised by the end-to-end tests, and a unit test that renders one and asserts “it rendered” would add a green tick and zero confidence. Excluding a genuinely presentational file is a way of saying it is covered elsewhere, which is true. Writing a hollow test for it instead says it is covered here, which is a lie the coverage report will happily repeat back to you. The rule I hold myself to is a single decision with no escape hatch:
If a file has real logic, it gets a real test; if it is presentational, it gets excluded with the rest of its kind; there is no third option where it gets a fake test to lift the score.
The gates that keep it honest on every push
Tests only protect you if they run, and if their bar can only move one direction. So:
- Each package’s coverage floor sits just under its real number and only ever ratchets up. I am not allowed, by my own rule written into AGENTS.md(opens in a new tab), to lower a threshold to make a push pass. That single rule is what stops “temporarily” from becoming “permanently”.
- New code has to clear 80% coverage before it merges; SonarCloud enforces that on the diff.
- The whole suite runs locally before every push (via Lefthook(opens in a new tab)) and again in CI, so the feedback is fast and the verdict is the same in both places. Finding out in seconds, on my machine, is what makes me willing to run the suite constantly instead of saving it for the end.
The result of all this was that coverage went from 41% to the low-nineties. But notice the order of operations. I did not aim at ninety. I aimed at the logic that mattered, excluded what honestly did not belong in the measurement, and ninety is where it landed. If I had aimed at ninety directly, I would have hit it days earlier and learned nothing.
What a green run does not promise
I would be selling you something if I stopped there, so here is the honest other half.
A green test suite tells me production is probably fine. It cannot tell me production is fine. My tests run in jsdom(opens in a new tab) and a headless Chromium on my machine, so they say nothing about a flaky network, a three-year-old phone, or the browser extension that injects script into my page.
And coverage, the number this whole article started with, measures which lines ran during the tests, not whether the assertions checking them are any good. You can have 100% coverage and a suite that proves nothing, if the tests execute the code and then forget to expect anything meaningful. That is the trap of treating the percentage as the goal: it is fully satisfiable without writing a single useful test. So the number is worth knowing, but it makes a poor target.
The things tests genuinely cannot catch are worth naming, so you keep your guard up: the assertion you
did not think to write; the requirement you misunderstood, so your test faithfully verifies the wrong
behaviour; the race condition that only appears under real latency; and the visual regression that no
expect will ever see, only a human or a tool like Chromatic(opens in a new tab) looking
at pixels. Automated tests lift the worst case. They do not hand you the best one, and that still
comes from judgement and from watching the real thing run.
A short checklist
If you want to apply this to your own project, this is the order I would suggest:
- Before writing a test, name what could break and who would feel it. If the answer is “nothing a user notices”, you may not need the test. You may need to exclude the file.
- Test logic, behaviour, and contracts. Do not test markup that only renders.
- Query like a user. Assert what they get, never the implementation detail behind it.
- Make accessibility a behaviour you assert as you go, rather than an audit bolted on at the end.
- Exclude genuinely presentational files from coverage, and never fake a test to lift the number.
- Give coverage a floor that only ratchets up, and refuse to lower it.
- Read the percentage to see where you stand. Do not aim at it.
Closing thought
The reason I care about this goes beyond tidy numbers. I wrote previously about building guardrails so AI agents can contribute to this codebase safely, and tests are the sharpest guardrail of the set. But those same tests are what let me refactor a utility on a Friday afternoon and trust the result. I am not certain I got it right; I am certain that if I got it wrong, something will tell me before a visitor does.
That, in the end, is the point. The tests are here so I can keep changing this site and hear about it fast when I break something. The coverage number is just what is left over once I have done that honestly.
If you found this useful, there is more on the blog, and I am always happy to talk shop. You can reach me from the contact page.