Keentune

Testing curriculum

27 chapters
·
217 concepts
·
free
Everything the adaptive question bank can teach and test in Testing, from foundations through advanced practice. Work through it in order, or start practising and let the questions find your level.
New here? Read the Testing guide
A free 18-minute primer — the mental model, the mistakes beginners make, and what to practise first.
A. What a test is, and how a suite is built
a test names one behavior and fails loudly the moment it stops holding
set up state, perform exactly one action, assert the observable outcome
it is a pure alias of test; the choice is readability, never semantics
describe groups tests and scopes hooks; it runs no assertions itself
two unrelated assertions in one test can't tell you which behavior broke
a test never observed red may be asserting nothing at all
*.test.js, *.spec.js and __tests__/ are discovered by default, via testMatch
a good failure names the expected value, the received value, and the subject
same input, same result: no dependence on the clock, the network, or test order
B. Matchers and equality
toBe uses Object.is, so two structurally equal objects are never toBe each other
toEqual compares recursively and ignores properties whose value is undefined
also checks undefined keys, array sparseness, and that the class matches
Object.is distinguishes +0 from -0 and treats NaN as equal to itself
toBe(0.3) fails on 0.1 + 0.2; toBeCloseTo takes a digit precision
toBeNull, toBeUndefined, toBeDefined, toBeTruthy, toBeFalsy and what each admits
identity membership versus deep-equality membership in a collection
toMatch takes a regex or a substring; toEqual on a string demands the whole value
pass a function, not its result, or the throw escapes before the matcher sees it
a string is a substring match, a regex a pattern, a class an instanceof check
.not inverts any matcher, and a negative assertion passes far too easily
C. The expect API in depth
expect.any, expect.anything and expect.stringContaining match *inside* a structure
assert a subset of an object's keys without pinning the fields you don't care about
assert membership without pinning order or length
expect.assertions(n) proves the async branch you meant to exercise actually ran
guards a test whose only assertion lives inside a callback that may never fire
.resolves/.rejects unwrap the promise before applying the matcher, and must be awaited
expect.extend returns {pass, message}; the message is what makes the failure readable
a hard assertion aborts at the first failure, hiding the rest of the diff
a bare loop assertion hides which iteration failed unless the message carries the index
D. Setup, teardown and isolation
per-test setup versus once-per-file, and the shared-state risk beforeAll buys
outer beforeEach runs before inner; afterEach unwinds inside-out
cleanup in the test body is skipped when the test fails
a hook declared inside a describe wraps only that block's tests
a module-level object mutated by one test silently leaks into the next
before the test framework is installed versus after, and what each can touch
a hook that neither returns nor awaits its promise races the test it precedes
every test must pass in any order; randomizing order is how you expose hidden coupling
an unclosed server, socket or interval keeps the runner alive after the last test
E. Asynchronous tests
a test that forgets to return or await passes before its assertion has run
an async test function is awaited by the runner, which is why await is enough
done must be called exactly once; calling it twice or never both fail the run
taking done *and* returning a promise is rejected by the runner
await expect(p).rejects.toThrow() asserts nothing if the await is missing
wrapping an assertion in try/catch can convert a real failure into a pass
an unawaited rejected promise surfaces later, failing an unrelated test
awaiting an already-resolved promise lets queued .then callbacks run before you assert
the default 5 s limit, and raising it as the third argument to test
a fixed sleep is either flaky or slow; poll for the condition instead
F. Test doubles — the taxonomy
a value passed only to satisfy a signature and never actually used
a stub answers a call with a fixed value; it makes no assertion
a spy observes the real behavior without replacing it
a mock asserts *how* it was called, which couples the test to the interaction
a simplified real implementation, e.g. an in-memory repository
assert the resulting state, or assert the calls made; they fail differently
mocking the unit under test means the test proves nothing about production code
replace I/O and third parties, never your own domain logic
the two schools disagree about collaborators, not about isolation
G. Mock functions and call inspection
jest.fn() records every call and returns undefined until told otherwise
mock.calls[i][j] is the j-th argument of the i-th call
mock.results records, per call, whether it returned or threw and with what
a queued ...Once value is consumed by one call, then the default takes over
replaces the body outright; mockImplementationOnce for a single call
sugar for an implementation returning a resolved promise, and its rejected twin
jest.spyOn wraps an existing method and by default still runs the original
clear the recorded calls, reset the implementation, or restore the original method
matches *some* call with deeply-equal arguments, which is not the same as the last call
H. Module mocking and dependency seams
jest.mock is hoisted above the imports, so its factory cannot close over a later variable
the second argument replaces the module wholesale, including its default export
automocking preserves the export shape and replaces every function with a mock
a __mocks__ file next to the module, or at the project root for a node_modules package
a package manual mock applies without jest.mock; a user module still needs it
spread jest.requireActual to keep the untouched exports real
jest.resetModules makes the next require construct a fresh module instance
a destructured ESM import cannot be reassigned, so spy on the namespace or inject instead
passing the collaborator in removes the need for module surgery entirely
I. Fake timers and controlling the clock
installs a controllable clock in place of setTimeout, setInterval and Date
moves the fake clock forward and fires every callback that came due
drains the whole queue, and throws on an interval that reschedules forever
fires only what is already scheduled, so recursive re-scheduling cannot loop
advancing the clock does not run microtasks; you must await between steps
the ...Async advance methods yield to the microtask queue between callbacks
freezing the clock is what makes a date-dependent assertion deterministic
leaving fake timers installed hangs a later test that awaits real I/O
fake timers are how you assert a debounce or throttle without sleeping
J. Snapshot testing
a snapshot is a regression net; it never proves the output is right
the .snap file is committed and must be read in the diff like any other code
-u rewrites every snapshot, and blind updating turns the test into a rubber stamp
toMatchInlineSnapshot keeps the expected value next to the assertion
under --ci a missing snapshot fails instead of being silently written
deleting a test leaves a stale entry the runner reports but does not remove
expect.any(Date) inside a snapshot tolerates a field that legitimately changes
a thousand-line snapshot is unreviewable and passes by inertia
a serializer controls how a value prints, which is what keeps the diff legible
K. Runner configuration and environments
node versus jsdom, and the "document is not defined" error that names the wrong one
TypeScript/JSX source must be transformed before the runner can load it
node_modules is untransformed by default, which is why an ESM-only package explodes
maps path aliases, and stubs CSS or asset imports that the runner cannot parse
keeps build output and fixtures out of discovery
once per run in its own context versus once per test file in the test context
one command, several configurations, so node and jsdom suites share a run
no layout engine, so getBoundingClientRect returns zeros and nothing truly paints
testTimeout raises the default 5000 ms for every test rather than one at a time
L. The CLI, filtering and watch mode
-t filters by test name; a positional argument filters by file path
serial execution for debugging, or when tests share one external resource
worker count trades memory for wall time, and CI containers usually need it capped
test.only focuses a file's run, and a committed .only silently disables the rest
test.todo records intent without a body and reports separately from a skip
stops the run after the first failing suite, trading signal for minutes
--watch needs version control to compute which files changed
the flag that turns "Jest did not exit" into the name of the handle holding it
M. Coverage — the metrics and their limits
line, statement, branch and function coverage measure genuinely different things
branch coverage catches the untaken else that line coverage happily hides
code counts as covered by a test that asserts nothing about it
full coverage with weak oracles still ships defects
global and per-file thresholds fail the run when the number drops below the bar
hold the line on newly changed code instead of chasing a global percentage
source instrumentation versus the engine's own counters, and why the numbers disagree
mutate the source and see whether a test dies; it measures assertion strength, not reach
a surviving mutant that cannot change behavior, and why detecting them is undecidable
full branch coverage still exercises a tiny fraction of branch *combinations*
N. Vitest — the Vite-native runner
describe/it/expect match Jest, so the mental model transfers unchanged
helpers must be imported from vitest unless globals: true is set
vi.fn, vi.mock, vi.spyOn and vi.useFakeTimers mirror the jest object
tests run through the app's own Vite pipeline, so aliases and plugins already apply
no transpile-to-CommonJS step, which changes how and when module mocking can intercept
import.meta.vitest blocks live beside the code and are stripped from the build
several environments in one run, the Vitest analogue of Jest projects
it.concurrent overlaps tests within a file, making shared state a live hazard
a docblock comment overrides the environment for a single file
O. Testing Library — querying like a user
role first, then label, then text; a test id is the last resort
getBy* throws when nothing matches, so it doubles as an existence assertion
the only correct query when you are asserting that something is absent
findBy* returns a promise and retries until it matches or times out
getAllBy/queryAllBy return arrays; the singular form throws when two elements match
queries the accessible role and name, which is what assistive technology actually exposes
the name comes from aria-labelledby, then aria-label, then content
the query that fails when a form control has no real label, which is the point
matching trims and collapses whitespace by default, and exact controls case
a test id couples the test to markup no user can perceive
P. Async DOM testing and user interaction
the callback is retried until it stops throwing or the timeout expires
a waitFor body that never throws resolves on the first tick and asserts nothing
anything inside runs repeatedly, so side effects must stay out
asserts a disappearance, and fails if the element was never there to begin with
an update outside act means the assertion ran against a half-rendered tree
the DOM is unmounted between tests; without it, a query matches the previous test's render
userEvent dispatches the whole event sequence a real interaction produces
since user-event v14 every interaction returns a promise that must be awaited
reaching into state or props instead of rendered output tests the how, not the what
Q. Playwright locators and auto-waiting
a locator describes how to find an element and resolves at the moment of the action
before acting, Playwright waits for visible, stable, enabled and receives-events
role and label locators survive refactors; a CSS descendant chain does not
a locator resolving to two elements is an error, not a silent first-match
scope inside a parent, or filter({ hasText }) to disambiguate a repeated row
index selection bakes an ordering assumption into the test
text matching is substring and whitespace-normalized unless you ask for exact
locators pierce open shadow roots, but an iframe needs an explicit frame locator
a hard-coded wait is the single most common source of end-to-end flake
R. End-to-end assertions and fixtures
expect(locator).toBeVisible() retries; expect(await locator.isVisible()) does not
a retrying assertion carries its own timeout, separate from the test timeout
the full normalized text versus a substring, and which one over-specifies
pulling textContent first turns a retrying check into a one-shot race
test.extend hands each test a freshly built resource and disposes it afterwards
expensive setup shared across one worker's tests rather than the whole run
grouping actions into steps is what makes the trace and the report readable
expect.soft records the failure and lets the test continue collecting evidence
screenshot assertions need a per-platform baseline and an explicit pixel tolerance
S. Flakiness — causes and cures
a flaky test is one whose result changes with no change to the code under test
leaked state between tests is the most common cause; the fix is isolation, not a retry
real clocks, timezones and DST make an assertion that only fails on some days
asserting on the order of a collection whose order was never guaranteed
asserting mid-transition; disabling animations in test config removes the whole class
a real third-party request belongs in a contract or smoke test, never in a unit test
a retry can turn a genuine production race into a permanently green build
pull the flaky test out of the signal, but with a name and a deadline attached
repeating or stress-running a test is how you surface a one-in-fifty failure
two workers sharing a port, a temp file or a database row collide nondeterministically
T. Test strategy — what to test, and where
many fast unit tests, fewer integration tests, fewest end-to-end tests
for UI code, static analysis plus integration tests carry most of the confidence
a suite dominated by end-to-end tests is slow, flaky and expensive to diagnose
one unit, no I/O, measured in milliseconds, failing for exactly one reason
real collaborators across a boundary, such as code plus an actual database
a handful of revenue-critical journeys, not every field-level validation
consumer and provider verify a shared contract instead of standing up both systems
assert observable outcomes so an internal refactor cannot break the suite
a test is maintained forever, so a low-value test is a net liability
a fast post-deploy sanity check versus the suite that guards previously-fixed bugs
U. TDD and test design
fail first, make it pass in the simplest way, then clean up under the net
code that is hard to test is almost always code that is badly coupled
implementing more than the current test demands throws away the feedback
a second example is what forces you to generalize instead of hard-coding
never change behavior and structure in the same step
start from the acceptance test and mock inward, or build the domain core first
"returns zero for an empty cart" beats "test cart" in every failure report
reproduce the defect as a failing test *before* fixing it, or the fix is unverified
pin the current behavior of legacy code before you are allowed to change it
V. Property-based, generative and fuzz testing
assert an invariant over generated inputs instead of one hand-picked case
a generator describes the input domain the runner is allowed to sample from
on failure the runner reduces the input to a minimal reproducible counterexample
decode(encode(x)) equals x is the easiest useful property to find in real code
compare the fast implementation against a slow, obviously-correct reference
something true of every output, such as a sort preserving length and multiset
record the seed, or a reported failure cannot be reproduced
feed hostile random input and assert only that nothing crashes or corrupts state
W. Running tests in CI
files are distributed across worker processes while tests within a file stay serial by default
split the suite across machines, then merge the shard reports into one result
each worker has its own process and module registry, so globals are not shared between them
--ci disables snapshot writing and interactive prompts so a run is reproducible
a machine-readable reporter feeds the CI UI while a human reporter serves triage
retain traces, videos and screenshots only for failures, or storage costs explode
a recorded timeline of DOM, network and actions is how you debug a failure you cannot reproduce
bailing early saves minutes but hides every failure after the first
run only the tests affected by the diff, with a full run gating the main branch
X. Reusable fixtures
a fixture gives a test known prepared data or state, with setup kept reusable and explicit
Y. Parameterized cases
test.each runs one assertion shape over a table of cases while reporting every case separately
Z. Systematic concurrency testing
control and bound scheduler choices so relevant thread interleavings can be explored and a failing schedule replayed
place synchronization at a suspected race window so the test proves both workers occupy the unsafe schedule
AA. Symbolic execution
symbolic inputs explore feasible branch paths precisely, but the execution tree grows with the path space
Keentune is not affiliated with or endorsed by the organizations whose documentation informs these maps.
All about Testing practice
Also on your phone
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.
© 2026 SportaApp LLC