Testing
Unit, component and end-to-end tests — what each is for, and how to write a suite people trust instead of delete.
Test Behaviour, Not Implementation
Tests coupled to internals break on every refactor. The team learns that red means "someone changed something" rather than "something is broken", and starts ignoring the suite. That is how test suites die.
The fix is to assert what a user would observe. Not "state.isOpen became true" but "the panel is now visible". The first is an implementation detail; the second is the actual requirement.
| Level | Covers | Speed | Have |
|---|---|---|---|
| Unit | One function in isolation | Milliseconds | Many |
| Component | A component with its markup and events | Fast | Many |
| End-to-end | A real journey in a real browser | Seconds | A few |
The shape is a pyramid for a reason: cheap tests catch most regressions, and slow ones catch the integration failures nothing else can. Inverting it — mostly end-to-end — gives a suite that is slow, flaky, and eventually skipped in CI.
Unit Tests
One function, no browser, no network. Ideal for the parts with real logic — a price calculation, a date formatter, a reducer, a validation rule.
test('applies no discount below the threshold', () => { const cart = { total: 49 }; // arrange const out = applyDiscount(cart); // act expect(out.total).toBe(49); // assert }); // the boundary is where bugs live — test both sides of it test('applies the discount exactly at the threshold', () => { expect(applyDiscount({ total: 50 }).total).toBe(45); });
Name the rule, not the function. 'applies no discount below the threshold' tells you what broke from the CI output alone; 'test applyDiscount 2' means opening the file.
Component Tests
Render a component, interact with it the way a person would, assert on what is now visible. This is where most frontend testing value sits: fast enough to run constantly, real enough to catch actual breakage.
Query the way a user finds things — by role, by label, by visible text. Those queries double as accessibility checks: if your test cannot find the button by its role and name, a screen reader cannot either.
| Prefer | Over | Because |
|---|---|---|
| By role and name | By CSS class | Classes are styling, and change freely |
| By label text | By input id | Checks the label is really associated |
| By visible text | By test id | Closer to what the user sees |
| A test id | A brittle CSS path | An explicit hook beats an accidental one |
End-to-End Tests
A real browser driving the real application. Slow and expensive, so spend them on the journeys that must never break — sign in, checkout, the core task your product exists for.
Their value is catching what nothing else can: the routing config, the build output, the actual network round trip, the third-party script that breaks everything.
| Flake source | Fix |
|---|---|
| Fixed sleeps | Wait for the condition, not a duration |
| Selectors tied to styling | Role, text, or an explicit test id |
| Tests sharing data | Each test creates and cleans up its own |
| Order dependence | Any test must pass alone |
| Real third-party calls | Stub at the network layer |
Run the full suite in CI on every pull request, and make it blocking. A suite that only runs on someone's laptop protects nobody.
Interview Questions
What does "test behaviour, not implementation" mean?
Assert what a user could observe — visible text, enabled controls, the resulting request — rather than internal state or private methods. Tests coupled to internals fail on every refactor, and the team stops trusting them.
Why is the testing pyramid that shape?
Unit and component tests are fast and stable, so you can afford many. End-to-end tests catch integration failures nothing else can, but they are slow and more prone to flake. Inverting it gives a suite too slow to run and too noisy to believe.
Why query by role rather than by class?
Classes are styling and change freely. Role and accessible name are the contract with the user and with assistive technology — and if the query cannot find it, neither can a screen reader.
How do you fix a flaky end-to-end test?
Find the race. Usually a fixed sleep instead of waiting for a condition, a selector tied to styling, or shared data between tests. If it cannot be made deterministic, delete it — a test nobody believes is worse than none.
Is 100% coverage a good target?
No. It measures lines executed, not behaviour verified, and chasing it produces tests written to touch code. Useful as a signal of what is untested, misleading as a goal.
What would you cover end-to-end?
The few journeys whose failure would be unacceptable — sign in, checkout, the core task. Everything else is cheaper and more reliable at the component level.