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 on | Assert on |
|---|---|
| Component state or props | Text the user can read |
| CSS class names | Roles and accessible names |
| Which functions got called | What appeared, changed or disappeared |
| Implementation details of a child | The 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
| Kind | Scope | Best for | Cost |
|---|---|---|---|
| Unit | One function | Pure logic — money, dates, reducers, validation | Milliseconds |
| Component | One component + its DOM | A component's own states: loading, empty, error | Fast |
| Integration | Several components together | A flow within a page | Moderate |
| End-to-end | Real browser, real app, often a real API | The handful of journeys that must never break | Seconds 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.
| Cause | Fix |
|---|---|
| Sleeping for a fixed time | Wait for a condition. Modern tools auto-wait for an element; never sleep(500). |
| Ambiguous selectors | Be specific. The demo app scopes to getByRole('main') because its footer repeats the same text on every page. |
| Substring matches | exact: true. One spec notes: "Pepsi" is a substring of "Diet Pepsi". |
| Shared mutable state | Isolate, or serialise. See below. |
| Depending on test order | Each test sets up what it needs. |
| Real time, real randomness | Freeze 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 API | Real API | |
|---|---|---|
| Speed | Fast | Slower |
| Error and empty states | Easy — just return a 500 | Hard to trigger on demand |
| Catches contract drift | No — the mock happily lies | Yes |
| Setup | None | A 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.