Cheat sheet · Testing From Zero: What, Why and How
Testing From Zero cheat sheet
How to test: what to test, unit tests and doubles, component, API and browser tests, flaky tests, and how to know your tests are any good. Aim for the most confidence for the least cost.
blogs · jp-blogs.vercel.app/cheatsheets/series/testing
What a passing test provesLesson 0 →
- A green test proves only that the cases you wrote worked today. Tests find bugs, they cannot show there are none.
- A test can be wrong (bad expected value) or weak (runs code, checks almost nothing).
- Two weak tests (
typeofis number, zero stays zero) passed onsubtotal * rateand gave 100% coverage. - Watch every test go red once, for the right reason. A test never seen failing may be unable to fail.
node --test --test-reporter=spec
# finds *.test.js, *-test.js, *_test.js
# ✖ adds 20% tax to 100
# 20 !== 120node:test is built in and Stable in Node 22.
Speed, cost, confidence, shapesLesson 0 →
| Kind | Speed | Confidence | False alarms from |
|---|---|---|---|
| Unit | ms | Narrow | Tests tied to the build |
| Integration | 10 to 50 ms | Pieces fit | Test data, shared state |
| End-to-end | 1 to 10 s | A real user path | Timing, network, browser |
| Shape | Most tests are | Fits |
|---|---|---|
| Pyramid | Unit | Pure logic, libraries |
| Trophy | Integration (static checks at base) | UI apps, API-heavy apps |
| Honeycomb | One service with its neighbours | Many small services |
Tip: Shapes are models, not laws. All agree: few E2E tests, and match your tests to where the risk is.
What to test, what to skipLesson 0 →
- Test: business rules, edge cases (empty, zero, one, max), error paths, every bug you already had, paths users cannot lose.
- Skip: other people's code (test your use of it), pure wiring, exact styling, private details, generated code.
- Ask: if this fails, will I know something real is broken? If I would just update the test, do not write it.
- Test behaviour, not construction: a test of a private
_itemsarray broke on a harmless refactor, the publictotal()test did not.
Anatomy of a unit testLesson 1 →
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
describe('applyDiscount', () => {
it('takes the percent off', () => {
const r = applyDiscount(2000, 25);
assert.equal(r, 1500);
});
});Arrange, act (once), assert. One behaviour per test: no "and" in the name.
| Need | node:assert/strict | Vitest |
|---|---|---|
| Same value | equal(a, b) | toBe |
| Same content | deepEqual | toEqual |
| Throws | throws(fn, {message}) | toThrow('x') |
| Rejects | await rejects(p) | await ...rejects.toThrow() |
Test doublesLesson 1 →
| Double | Does | Use when |
|---|---|---|
| Stub | Returns a canned answer | Controlling an input (time, a user) |
| Mock | Records calls; test asserts on them | The effect is the point: email sent once |
| Spy | Wraps a real function, still runs it | Watching real code |
| Fake | Small working version (in-memory repo) | Realistic behaviour, no slow system |
- Prefer the real thing, then a fake, then a stub, a mock last. Inject dependencies as arguments.
- Double the edges: network, database, clock, randomness, email. Not your own pure logic.
- A hand-written mock can drift from the real API: the test stays green and production breaks.
Time, async and isolationLesson 1 →
it('calls once after quiet', (t) => {
t.mock.timers.enable(
{ apis: ['setTimeout'] });
const fn = mock.fn();
const d = debounce(fn, 300);
d('a'); d('b'); d('c');
t.mock.timers.tick(300);
assert.equal(fn.mock.callCount(), 1);
});Vitest: vi.useFakeTimers(), vi.advanceTimersByTime(), vi.setSystemTime(). Always restore.
- Always
awaitorreturnasync assertions. A missingawaitonassert.rejectsgave a green test for a false claim. - Isolation: each test passes alone, shuffled, in parallel. Module-level state (a
Set) failed when one test ran alone. - Find order bugs with
-t "name"and--sequence.shuffle. Reset state inbeforeEach.
Component tests (Testing Library)Lesson 2 →
| Family | Missing element | Use for |
|---|---|---|
getBy | Throws | Must be there now |
queryBy | Returns null | Checking it is absent |
findBy | Waits, then throws | Appears later (async) |
- Query order:
getByRolewithname,getByLabelText, text,getByTestIdlast. A failing role query often means a real a11y bug. - Use
userEvent.setup()beforerender, andawaiteveryuser.*. Prefer it tofireEvent. - Vitest has no global
afterEach: addafterEach(() => cleanup())or you get "found multiple elements". - Skip big snapshots, state and CSS layout (jsdom has no layout).
Network mocking and async UILesson 2 →
beforeAll(() => server.listen(
{ onUnhandledRequest: 'error' }));
afterEach(() => { cleanup();
server.resetHandlers(); });
afterAll(() => server.close());
server.use(http.get(url, () =>
HttpResponse.json([{ id: 1 }])));MSW answers the request, so the component code is the real code. One handler per test.
- Test all states: loading, success, empty, error. Use
await screen.findByRole(...);waitForfor non-element conditions. - Forms: validation messages (
role="alert",aria-invalid), no request when invalid, exact request body, server error then retry. - An unhandled request can hide behind your error UI and still pass. Watch for
[MSW] Error.
Integration and API testsLesson 3 →
- Integration = two or more of your own real parts. Fake only what you do not own and cannot run cheaply.
createApp({ db, queue, clock })returns the app and never callslisten.supertestbinds a free port. Real socket:listen(0).- Assert status, body, stored rows, published messages, and non-effects on failure.
- Use the production database engine. SQLite can pass where Postgres fails.
| DB strategy | Watch out |
|---|---|
| Transaction rollback | Code must use the same connection |
| Truncate before each test | Delete in foreign-key order |
| Private DB or schema per test or worker | Setup cost: migrate once per worker |
Auth, contracts, webhooksLesson 3 →
| Auth case | Expect |
|---|---|
| No or bad token | 401 |
| Valid token, someone else's resource | 404 or 403, data unchanged |
| Normal user on admin route | 403 |
| Your own resource | 2xx |
- Contracts: OpenAPI schema check (
ajv) for the provider; consumer-driven (Pact) for known callers. Extra fields are safe, removing one breaks. - Webhooks: wrong signature is 401, valid is stored, same event twice is stored once. Sign the raw body bytes.
- Queues: fake with the same interface; assert published and not published. Test the worker separately.
- Speed: build once per file, migrate once per run, clean by rollback, small seeds, factories not one big seed.
End-to-end with PlaywrightLesson 4 →
await page.getByLabel('Todo').fill('x');
await page.getByRole('button',
{ name: 'Add' }).click();
// retries until true. No sleep.
await expect(items).toHaveCount(1);Forget await before expect(locator) and the check never runs.
- One E2E test per business journey (login, pay), not per feature. 5 to 20 is common.
- Locators: role, label, text, test id last. Strict mode errors if more than one match: be specific.
- Auto-waiting: visible, stable, enabled, receives events. Never
waitForTimeoutor{ force: true }. - Each test gets a fresh context; the server data is yours to isolate. Log in once with
storageStateand gitignore.auth/. page.routemocks third parties and rare errors, not your own API in main journeys. Settrace: 'on-first-retry'.
Flaky testsLesson 4 →
| Cause | Fix |
|---|---|
| Time (sleeps, real clock) | page.clock.setFixedTime, retrying assertions |
| Data (shared account) | Unique user or ids per test |
| Ordering | Every test sets up its own state |
| Animation (opacity 0.999) | toHaveCSS retries, or disable animations |
| Network (CI slower) | Wait for the result, mock unstable parties |
- Reproduce:
--repeat-each=20, then fewer or more--workers. - Open the trace at the first step that differs.
- Name the cause from the five.
- Fix the cause, then repeat to prove it.
Watch out: Retries, longer timeouts and silent test.skip hide flakiness. A test that needed a retry gets a ticket.
TDD and property testsLesson 5 →
- Red: one small failing test, for the right reason.
- Green: the simplest code, even a fake (
return 0). - Refactor with all tests green.
fc.assert(fc.property(fc.nat(100000),
(cents) => {
assert.equal(
parseMoney(formatCents(cents)),
cents);
}));
// Counterexample: [1]Shrinking finds a small failure. Save the seed. Keep an example test per bug.
- Good properties: round trip, idempotent, never crashes.
- A bug fix starts with a failing test that reproduces it.
Mutation testing, coverage, reviewLesson 5 →
- Stryker changes code (
<to<=). A failing test kills the mutant; a passing run means it survived. Score = killed / total. - A survivor means a missing test (
n <= 0needed anadd('0,5')test) or dead code (a redundantif). Read each one. - An assertion-free test gave 100% coverage and a 37.5% mutation score. Use coverage to find gaps, never as a target.
- Run mutation on core modules or nightly: cost is mutants times test time.
| Review test PRs: ask | Why |
|---|---|
| Would it fail if the code were wrong? | Mutation in your head |
| Did I see it fail? | Bug fixes show red first |
| Behaviour or internals? Right layer? | Refactor safety |
| Deterministic and independent? | No sleeps, clock, shared data |
Interview one-linersLesson 0 →
| Question | Short answer |
|---|---|
| Green suite means no bugs? | No. It means the written cases passed. |
| Stub vs mock? | Stub feeds an answer; mock asserts on calls. |
| getBy vs queryBy vs findBy? | Throws now; null for absent; waits for async. |
| Mock fetch? | Use MSW at the network layer instead. |
| Why not port 3000 in tests? | Collisions. Use listen(0) or supertest. |
| Fix a flaky test? | Find the cause (time, data, order, animation, network). |
| Is 100% coverage good? | It shows what ran, not what was checked. |
A cheat sheet is a reminder, not a teacher. Each card links to the lesson that explains it step by step.