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.

Read the full series →

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 (typeof is number, zero stays zero) passed on subtotal * rate and 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 !== 120

node:test is built in and Stable in Node 22.

Speed, cost, confidence, shapesLesson 0 →

KindSpeedConfidenceFalse alarms from
UnitmsNarrowTests tied to the build
Integration10 to 50 msPieces fitTest data, shared state
End-to-end1 to 10 sA real user pathTiming, network, browser
ShapeMost tests areFits
PyramidUnitPure logic, libraries
TrophyIntegration (static checks at base)UI apps, API-heavy apps
HoneycombOne service with its neighboursMany 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 _items array broke on a harmless refactor, the public total() 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.

Neednode:assert/strictVitest
Same valueequal(a, b)toBe
Same contentdeepEqualtoEqual
Throwsthrows(fn, {message})toThrow('x')
Rejectsawait rejects(p)await ...rejects.toThrow()

Test doublesLesson 1 →

DoubleDoesUse when
StubReturns a canned answerControlling an input (time, a user)
MockRecords calls; test asserts on themThe effect is the point: email sent once
SpyWraps a real function, still runs itWatching real code
FakeSmall 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 await or return async assertions. A missing await on assert.rejects gave 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 in beforeEach.

Component tests (Testing Library)Lesson 2 →

FamilyMissing elementUse for
getByThrowsMust be there now
queryByReturns nullChecking it is absent
findByWaits, then throwsAppears later (async)
  • Query order: getByRole with name, getByLabelText, text, getByTestId last. A failing role query often means a real a11y bug.
  • Use userEvent.setup() before render, and await every user.*. Prefer it to fireEvent.
  • Vitest has no global afterEach: add afterEach(() => 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(...); waitFor for 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 calls listen. supertest binds 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 strategyWatch out
Transaction rollbackCode must use the same connection
Truncate before each testDelete in foreign-key order
Private DB or schema per test or workerSetup cost: migrate once per worker

Auth, contracts, webhooksLesson 3 →

Auth caseExpect
No or bad token401
Valid token, someone else's resource404 or 403, data unchanged
Normal user on admin route403
Your own resource2xx
  • 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 waitForTimeout or { force: true }.
  • Each test gets a fresh context; the server data is yours to isolate. Log in once with storageState and gitignore .auth/.
  • page.route mocks third parties and rare errors, not your own API in main journeys. Set trace: 'on-first-retry'.

Flaky testsLesson 4 →

CauseFix
Time (sleeps, real clock)page.clock.setFixedTime, retrying assertions
Data (shared account)Unique user or ids per test
OrderingEvery 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
  1. Reproduce: --repeat-each=20, then fewer or more --workers.
  2. Open the trace at the first step that differs.
  3. Name the cause from the five.
  4. 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 →

  1. Red: one small failing test, for the right reason.
  2. Green: the simplest code, even a fake (return 0).
  3. 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 <= 0 needed an add('0,5') test) or dead code (a redundant if). 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: askWhy
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 →

QuestionShort 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.