A test is a claim, written in code, that some behavior holds. Two failures account for nearly everything that goes wrong with a suite. A test can pass while the code is broken, because it never really checked — a forgotten await, an assertion that could not fail, a line counted as covered merely for running. Or it can fail while the code is fine, because it was pinned to an internal detail somebody was entitled to change. Almost every piece of testing advice is an attempt to avoid one without falling into the other.
Each chapter opens with the short version. Tap one to read the detail.
What to test, and where
~2 min
The useful question is never "is this tested" but "what defect would this catch, and what will it cost me every week?" Push confidence downward: many fast tests over logic, fewer across real boundaries, a handful of end-to-end journeys. A suite shaped the other way up is slow, flaky and hard to diagnose.
Every test is code you maintain forever — read during incidents, updated during refactors, blamed when it fails for the wrong reason. A test that cannot fail for a real reason is not neutral; it is a liability that also costs runtime.
That cost is why the shape matters. Tests over pure logic are fast and pinpoint a cause. Tests spanning a real boundary — your code plus an actual database — catch integration mistakes unit tests structurally cannot. End-to-end tests exercise everything and are the slowest and least specific. Weight the cheap, precise ones heavily; reserve the expensive ones for journeys that earn them.
The other half is what you assert on. Playwright's guidance is to verify what an end user can experience and avoid what they never see — a function's name, whether a value is an array, the class on an element — and to only test what you control, mocking third parties rather than depending on them.
Concretely, two tests of the same feature:
expect(formatPrice).toHaveBeenCalledWith(1200); // breaks on a rename — a refactor, not a regression
expect(screen.getByText('$12.00')).toBeTruthy(); // breaks only when a user would see the wrong price
The first also stays green when formatPrice returns the wrong number.
Rule: before writing a test, name the specific defect it would catch; if you cannot name one, do not write it.
What a test is, and what an assertion proves
~3 min
A test passes when its function returns without throwing — so a test with no assertions passes, and one you have never watched fail may be checking nothing. Picking between toBe, toEqual and toStrictEqual matters; writing an assertion capable of failing matters far more.
Runners find test files by name — anything inside a __tests__ folder, or a file ending in .test.js or .spec.js, and the pattern is configurable. describe groups tests and scopes hooks, test declares one, and it is a plain alias of test chosen for readability.
A test body is just a function. The runner calls it; if it returns without throwing, that is a pass. Nothing checks that you asserted anything:
test('totals a cart', () => {
const total = subtotal([{ price: 3 }, { price: 4 }]);
// no expect() at all
});
// ✓ totals a cart <- green, and it verifies nothing
Arrange, act, assert keeps a test honest: set up state, perform exactly one action, assert the outcome you care about. Two unrelated assertions in one test mean a failure cannot tell you which behavior broke. And determinism is non-negotiable — same input, same result, independent of the clock, the network and the order tests run in.
Assertions are the only thing that can throw, so they are where all the value sits. toBe compares with Object.is, meaning two structurally identical objects are never toBe each other. toEqual walks the structure and deliberately ignores keys whose value is undefined:
Floats want toBeCloseTo, because expect(0.1 + 0.2).toBe(0.3) reports Received: 0.30000000000000004.
Now the same mistake wearing a disguise. .not inverts any matcher, and a negative claim is satisfied by nearly every value there is — including the ones your own typos produce:
const user = { name: 'Ada' };
expect(user.nmae).not.toBe('Grace'); // ✓ passes: undefined is not 'Grace'
The typo made it pass, and the same assertion survives the code under test being deleted. Prefer positive assertions on exact values, reaching for expect.objectContaining({ id: 1 }) to pin only the fields you care about rather than a whole response. Where a check runs inside a callback, expect.assertions(n) proves it ran at all.
Rule: watch every new test fail once — break the expected value or break the code — before you trust its green.
Isolation: why it passes alone and fails in the suite
~2 min
Hooks exist so cleanup runs even when a test fails — teardown written at the bottom of a test body is skipped the moment an assertion throws. Outer hooks wrap inner ones, and every describe body runs before any test does, which is where shared state usually leaks in.
Put cleanup in the test body and it only happens on the happy path:
test('releases the connection', () => {
open += 1;
expect(1).toBe(2); // throws here
open -= 1; // never reached — the resource leaks into the next test
});
An afterEach hook runs regardless. That is why hooks exist; it is not a style preference.
The nesting is worth knowing cold. Outer setup runs before inner setup, and teardown unwinds inside-out:
One detail causes more confusion than the rest: describe bodies all execute during collection, before any hook or test runs. A value computed directly inside a describe is therefore built once, before the first beforeEach, and every test in that block shares it. Mutate it in one test and you have silently coupled the others.
beforeAll makes the same trade knowingly — one expensive setup shared across the file, at the price of state surviving between tests. Use it for things nobody mutates, like a connection, and build anything a test writes to in beforeEach.
The check that exposes all of this is order independence: every test should pass in any order, so randomizing the order surfaces hidden coupling instead of letting it accumulate.
Rule: if a test passes alone and fails in the suite, look for state one test wrote and another read — start with beforeAll and anything built at describe level.
Asynchronous tests, and controlling the clock
~2 min
The runner only waits for what you hand it. Forget to return or await, and the test finishes before the assertion runs — reporting green. Fake timers make a delay instant, but advancing the clock does not run queued promise callbacks.
A test is done when its function returns. Return a promise or make the body async and the runner waits; do neither and the assertion is orphaned:
test('loads the user', () => {
fetchUser().then((u) => expect(u.name).toBe('WRONG')); // no return
});
Under Jest 29 that reports ✓ green, and only afterwards prints Cannot log after tests are done. Did you forget to wait for something async in your test? — the failure arrives out of band, after the suite was declared passing. expect.assertions(1) converts it into an ordinary failure: Expected one assertion to be called but received zero assertion calls.
The trap wears a second hat with rejections. expect(p).rejects.toThrow() builds an assertion that has not run yet; the await in front is what makes it happen at all.
Fake timers replace the timer functions with a clock you advance by hand, which is how you test a debounce without sleeping. The part that bites: advancing the clock fires timer callbacks but does not drain the microtask queue.
delay(1000).then(() => seen.push('resolved'));
jest.advanceTimersByTime(1000);
// seen is still [] — the timer fired, the .then has not run
await Promise.resolve();
// seen is ['resolved']
One await between advancing and asserting is the fix.
Two more. Draining every timer at once throws Aborting after running 100000 timers, assuming an infinite loop! when a callback reschedules itself, so use the variant running only what is already pending. And restore real timers afterwards, or a later test awaiting genuine input hangs until it times out.
Rule: hand every promise to the runner, and after advancing a fake clock, await once before you assert.
Test doubles: what to replace, and what it costs
~2 min
Replace what you do not control — the network, the clock, a third-party service — and stop there. A double standing in for your own logic makes the test pass by construction. Asserting how a function was called, rather than what came out, couples the test to the implementation you were free to change.
The vocabulary separates different risks. A stub answers with canned data and asserts nothing. A spy records calls while the real thing still runs. A fake is a working simplified implementation, like an in-memory repository. A mock carries expectations about the interaction itself.
That last one is the expensive choice. Asserting resulting state survives a refactor; asserting which calls were made fails the moment someone reorganizes internals without changing behavior. Prefer state verification unless the call itself is the behavior — that an email was sent, that you did not charge the card twice.
jest.fn() records every call and returns undefined until told otherwise. Queued values are consumed in order, then the default takes over:
jest.spyOn differs in a way that is easy to miss: it wraps an existing method and still calls the original by default, so it is a spy until you give it an implementation. The cleanup trio follows — clearing wipes recorded calls but keeps the implementation, resetting also discards the implementation, restoring puts the original method back. Restoring only works for something created as a spy.
Module mocking is the seam of last resort. jest.mock is hoisted above the imports, so it applies even when written below them, and its factory cannot close over a variable declared later:
const fakeSend = jest.fn();
jest.mock('./mailer', () => ({ send: fakeSend }));
// ReferenceError: The module factory of `jest.mock()` is not allowed
// to reference any out-of-scope variables. Invalid variable access: fakeSend
Names prefixed with mock are exempted, which is a workaround rather than a design. The design is to pass the collaborator in as an argument — that removes the need for module surgery entirely, and usually improves the code.
Rule: mock at the boundary of your system; if you are mocking a module you own, ask whether a parameter would do instead.
Measures that feel like proof, and are not
~2 min
Coverage counts lines executed, never behavior verified — a test that asserts nothing at all can report 100%. A snapshot detects change, not correctness, and its first run always passes, so all of its value sits in the diff you review.
Point coverage at a function, call it a few times, assert nothing meaningful, and the report is unambiguous:
Every branch ran. Nobody checked a single returned value. Line and statement coverage answer "did this execute", function coverage "was this entered", branch coverage — the strongest of the four — "was the untaken path taken too". None of them looks at your assertions.
So use it as a floor, not a target. Coverage is excellent at finding code no test touches; it says nothing about the code it marks green. Holding the line on newly changed code beats chasing a global percentage, which mostly buys assertion-free tests written to move a number. To measure whether the assertions are any good, mutation testing is the technique: change the source deliberately and see whether a test dies. Vitest defaults to the v8 provider, with Istanbul available as an option and documented as equivalent since Vitest 3.2.
Snapshots have the same shape of problem. The first run writes the file and passes — a snapshot test cannot fail the first time — so the real assertion is whatever you failed to notice in review. The .snap file is therefore source code: commit it, read its diff properly. Under --ci a missing snapshot fails instead of being written silently, which stops a new one sneaking in green.
The failure mode is blind updating. Re-running with -u after a red build rewrites every snapshot, converting the test into a rubber stamp. Thousand-line snapshots make that near-inevitable, so keep them small; use property matchers like expect.any(Date) for fields that legitimately change.
Rule: judge a suite by what breaks when you introduce a real bug, never by a percentage — and read every snapshot diff as if it were code.
The runner: environment, configuration and the Jest/Vitest split
~2 min
Most "it works in my app but not in tests" problems are environment or transform problems rather than test problems. The environment defaults to Node, so document is not defined means you asked for the wrong one, and dependencies are left untransformed by default.
Jest's testEnvironment defaults to node. Touch the browser globals there and you get ReferenceError: document is not defined — the fix is switching environment, not changing your test. That environment is a simulation with no layout engine, though: getBoundingClientRect() returns zeros and offsetHeight is 0, so size, position and visual overlap cannot be tested there at all.
Transforms cause the next tranche. Source is compiled before the runner loads it, and node_modules is excluded from that by default — which is why a dependency shipping only modern module syntax explodes with a syntax error the moment you import it. Narrow the exclusion rather than fighting the error. Module mapping sits alongside, redirecting path aliases and stubbing imports the runner cannot parse, like stylesheets and images.
Two setup slots exist and are not interchangeable: one runs before the test framework is installed, the other after — and only the second can see expect and the hook functions, so extra matchers and global hooks belong there. The per-test timeout defaults to 5000 ms.
Vitest is the same mental model with different plumbing. It reads your existing Vite configuration, so aliases and plugins already apply and the transform class of problem largely evaporates. Vitest does not make describe, it and expect global: globals defaults to false, described as a deliberate choice for explicitness, so you import them or opt in. The vi namespace mirrors the jest object for mocking, spying and fake timers.
On the command line, one distinction saves real time: a bare argument filters by file path, -t filters by test name. Running serially helps when tests share an external resource or you are debugging. And a committed test.only silently disables the rest of its file, which is worth a lint rule.
Rule: when a test fails in a way the app never does, suspect the environment and the transform pipeline before the code.
Component tests: query the way a user would
~2 min
Query by role and accessible name first and test ids last, so a refactor that changes nothing a user perceives cannot break the test. getBy throws, queryBy returns null, findBy retries — and waitFor only retries while its callback throws.
Testing Library ranks queries by how closely they match what a person perceives: role and accessible name first, then label, placeholder and visible text, with a test id as the escape hatch. A test id is markup nobody can see, so it survives redesigns — and equally, it keeps passing when the element became invisible or unreachable.
The variants encode different questions. getBy throws when nothing matches, so it doubles as an existence assertion. queryBy returns null, making it the only correct choice for asserting absence. findBy is asynchronous and retries until it matches or times out, defaulting to one second. All three throw when more than one element matches, which is a feature — an ambiguous query is a bug waiting for the second element. Real messages read Unable to find an accessible element with the role "checkbox" and Found multiple elements with the role "button" and name "Save".
Querying by role pulls accessibility into the test for free. getByRole('textbox', { name: 'Email address' }) only finds the input if the label is genuinely associated with it, so an unlabeled control fails — real information, not a testing obstacle. Text matching trims and collapses whitespace, so markup formatting does not break a match.
Then the asynchronous half, holding the sharpest trap here. waitFor retries its callback only for as long as it throws — a callback returning false instead resolves on the first attempt. Measured against a condition that would never become true: the returning version ran once in 7 ms and passed; the throwing version ran six times across 344 ms and failed correctly. Put an assertion inside, never a boolean, and keep side effects out since the body runs repeatedly.
Since user-event v14 every interaction returns a promise, so an unawaited click asserts against a screen mid-update.
Rule: describe the element the way you would to a person; if you cannot, that is usually an accessibility gap rather than a testing problem.
End-to-end: locators, auto-waiting and retrying assertions
~2 min
A Playwright locator is a description, not an element — it re-finds the element each time you act, and the action waits until it is usable. The assertion that retries and the assertion that does not look nearly identical, and choosing wrong is the classic source of end-to-end flake.
Locators are live. Each time one is used for an action, an up-to-date element is located on the page, and Playwright waits for it to be actionable before touching it. Between those two properties, the ordinary reasons for sleeping disappear — which matters, because a fixed sleep is simultaneously the slowest and least reliable way to wait.
Locators are also strict: an operation matching more than one element throws rather than quietly taking the first. Resolving that with nth() bakes in an ordering assumption, and the docs warn plainly that the page may change and the locator then point at a completely different element. Filtering by contained text, or scoping inside a parent, expresses what you meant. Prefer role and label locators over a descendant chain of classes, which breaks on styling changes; text matching normalizes whitespace unless you ask for exact. Locators pierce open shadow roots, while an iframe needs an explicit frame locator.
The assertion split is the thing to get right. Web-first assertions on a locator re-evaluate until they pass or the timeout expires — five seconds by default, separate from the test's own timeout. Generic matchers do not retry at all. So the moment you read a value out first, you convert a patient check into a one-shot race:
await expect(page.getByRole('status')).toHaveText('Saved'); // retries
expect(await page.getByRole('status').textContent()).toBe('Saved'); // one shot
The second line is the documented recipe for a flaky test. expect.soft is the useful exception: it records a failure and lets the test continue, so one run collects several problems.
Fixtures share setup without hooks. test.extend builds a value, hands it to the test, and tears it down afterwards — reusable across files, created only when a test asks for it, and available per-worker when setup is expensive.
Rule: never read a value and then assert on it — assert on the locator and let the assertion do the waiting.
Flaky tests, and running the suite for real
~2 min
A flaky test is one whose result changes with no change to the code under test. Retries turn the build green and the signal worthless. The causes are nearly always leaked state, real time, or two workers competing for one resource.
Playwright reports a test that failed on the first run and passed on retry as flaky, and that category — not the pass count — is the number worth watching. Retries are configurable and genuinely useful for absorbing infrastructure noise, but they cannot distinguish that from a real race in your product. A race losing one run in fifty becomes a permanently green build, and an incident later.
The recurring causes are a short list. State leaked between tests, which is an isolation problem rather than a timing one. Real clocks, timezones and daylight-saving boundaries, producing an assertion that only fails on certain days. Assuming an order a collection never guaranteed. Asserting mid-animation. Calling a live third-party service.
Parallelism adds the last cause. Test files are distributed across worker processes; within a file, tests run serially unless you opt in. Workers cannot communicate and each holds its own isolated context, so the only things they contend over are external — a port, a temp file, a database row. Two tests both seeding a user with the address test@example.com is the canonical case: run serially it passes forever, run in parallel one of them loses. Give each test its own data rather than sharing a fixture row. Playwright discards a worker after a failure so the next test starts clean.
At scale, shard the suite across machines and merge the reports. A continuous integration (CI) flag makes a run reproducible, which includes refusing to write new snapshots. Retain traces, videos and screenshots only for failures — a recorded timeline of actions, network and page state is how you diagnose a failure that will not reproduce locally, and keeping them for green runs just costs storage.
Rule: treat a retry as a bug report with a deadline, not a fix — quarantine a flaky test with an owner rather than leaving it to erode trust in every other result.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.