Frontend Dev – Testing

August 21, 20266 min readUpdated 8/20/2026

Tests are not about proving code works today. You already know it works today — you just clicked it. Tests are about being able to change it in six months, in code you did not write, without being afraid. That is the whole return, and it is why the tests worth writing are the ones that describe behaviour a user cares about rather than how the code currently happens to be arranged.

Test what the user sees, not what the component holds

The single principle that decides whether a frontend test suite is an asset or a tax.

A test that reaches into a component and asserts its internal state fails the moment you rename a variable — even though nothing a user could notice has changed. A test that fills in the form and checks the page says "Welcome back" keeps passing through a rewrite, and fails only when the app genuinely breaks.

Do not assert onAssert on
Component state or propsText the user can read
CSS class namesRoles and accessible names
Which functions got calledWhat appeared, changed or disappeared
Implementation details of a childThe URL after a navigation

The useful reframing: if a refactor with no user-visible change breaks your test, the test was wrong.

The kinds of test, and what each is for

KindScopeBest forCost
UnitOne functionPure logic — money, dates, reducers, validationMilliseconds
ComponentOne component + its DOMA component's own states: loading, empty, errorFast
IntegrationSeveral components togetherA flow within a pageModerate
End-to-endReal browser, real app, often a real APIThe handful of journeys that must never breakSeconds each

Weight them by where the risk is. Pure logic is cheap to test and worth testing exhaustively — a pricing function should have a dozen tests. Journeys that lose money if broken deserve end-to-end coverage. Everything in between deserves judgement, not a target percentage.

What is not worth testing

  • That a component renders at all with no assertion. It passes forever and proves nothing.
  • Third-party libraries. They have their own tests.
  • Styling. A screenshot test on a page that legitimately changes weekly is a chore generator — though visual regression testing on a stable design system is genuinely useful.
  • Trivial pass-through components.
  • Coverage for its own sake. 100% coverage of getters proves nothing; the number is a smoke detector, not a goal.

Query the way a user would

How you find things in a test is what makes it robust or brittle. Prefer, in order: the role and accessible name, the label text, visible text, and only then a test id. A CSS class should be last and usually never.

  await page.getByLabel('Email').fill('customer@pizza.test');
  await page.getByLabel('Password').fill('pizza123');
  await page.getByRole('button', { name: 'Sign in' }).click();

Notice what that test is quietly also proving: the inputs have real labels, and the button is a real button with a real accessible name. If someone replaced it with a styled <div>, the test would fail — which is the right outcome, because they would also have broken it for every keyboard and screen-reader user.

This is the payoff from post 3 and post 10: accessible markup is testable markup, and they enforce each other.

What a real test looks like

test('a customer can sign in and out', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('customer@pizza.test');
  await page.getByLabel('Password').fill('pizza123');
  await page.getByRole('button', { name: 'Sign in' }).click();

  // The navbar swaps "Sign in" for the account menu.
  await expect(page.getByRole('button', { name: /Demo Customer/ })).toBeVisible();
  await expect(page.getByRole('link', { name: 'Sign in' })).toBeHidden();

Every line is something a person does or sees. No component, hook or store is named. The whole sign-in mechanism could be rewritten and this test would neither notice nor need editing.

Test the unhappy paths — that is where the value is

test('bad credentials show the API error and keep you on the page', async ({ page }) => {

Two assertions in that one: the message appears, and you are still on /login. "It failed but navigated anyway" is a real bug that only the second assertion catches.

And a test that encodes a security property rather than a feature:

test('an unknown email fails the same way — no account enumeration', async ({ page }) => {

Its comment in the source reads "Deliberately identical to the wrong-password message." Without this test, a well-meaning future change to "friendlier" error messages reintroduces account enumeration and nobody notices. This is the best argument for tests there is: they hold onto reasoning that would otherwise live only in someone's head.

The things that make tests flaky

A test that fails randomly is worse than no test, because the team learns to re-run it — and then learns to ignore a real failure.

CauseFix
Sleeping for a fixed timeWait for a condition. Modern tools auto-wait for an element; never sleep(500).
Ambiguous selectorsBe specific. The demo app scopes to getByRole('main') because its footer repeats the same text on every page.
Substring matchesexact: true. One spec notes: "Pepsi" is a substring of "Diet Pepsi".
Shared mutable stateIsolate, or serialise. See below.
Depending on test orderEach test sets up what it needs.
Real time, real randomnessFreeze the clock, seed the generator.

The demo app's Playwright config takes an unfashionable position on the shared-state one and explains it properly:

SERIAL, ON PURPOSE. These are integration tests against one running backend and one MySQL database. With parallel workers the admin tests create and hide products while the menu tests are counting them — "expected 14, received 15". That is not flakiness to retry away; it is shared mutable state.

The alternatives are a database per worker, or assertions loose enough not to notice interference. Both are worse for a demo: the first is a lot of machinery, the second removes the very precision that makes the tests worth having. The whole suite runs in about 20s.

Two lessons. Flakiness is usually a real problem wearing a costume — diagnose it rather than adding retries. And loosening an assertion to stop a failure throws away exactly the precision you were paying for.

Fail loudly when the setup is wrong

test.beforeAll(async ({ request }) => {
  const response = await request.get('http://localhost:8085/api/products').catch(() => null);
  if (!response?.ok()) throw new Error('The backend is not responding at http://localhost:8085.');
});

Without this, forgetting to start the API produces twenty confusing assertion failures. With it, you get one sentence telling you exactly what to do. Small, and it saves an hour every time somebody new clones the repo.

Mocking the API, or not

Mocked APIReal API
SpeedFastSlower
Error and empty statesEasy — just return a 500Hard to trigger on demand
Catches contract driftNo — the mock happily liesYes
SetupNoneA running backend and a database

The mock's weakness is the important one: if the backend renames a field, your mocked tests stay green and production breaks. A sensible split is mocks for component-level tests of loading, error and empty states, and a small number of end-to-end tests against something real for the journeys that matter. The demo app does the latter — which is why its suite catches a backend contract change.

Where tests actually run

A test suite nobody runs is documentation with a build step. Put it in CI on every pull request (post 12), keep the fast tests fast enough to run on save, and treat a failure on the main branch as the most urgent thing in the room.

The one thing to take from this post

Write tests that describe what a person does and what they then see. Find elements by their role and their label, not their class. Do that and your tests survive refactors, break only on real breakage, and quietly hold the app accessible as a side effect — which is a lot of value from one habit.

Next: Build Tooling and Deployment.