Testing Strategy
Testing Strategy Testing is introduced gradually so readers understand what each test protects. The runnable companion is projects/testing-fixtures . It intentionally has no database or secret requirement, so a learner c
Testing is introduced gradually so readers understand what each test protects. The runnable companion is projects/testing-fixtures. It intentionally has no database or secret requirement, so a learner can run the first useful test suite immediately.
Test layers and ownership
| Layer | Protects | Typical tool | Keep it focused on |
|---|---|---|---|
| Unit | Pure transformations and validation | Vitest | Inputs, outputs, and boundary values |
| Component | User-visible React states | Vitest + Testing Library | Accessible roles, labels, loading/error/empty states |
| HTTP integration | Route contracts | Vitest + Supertest | Method, URL, status, JSON shape, validation, auth |
| Database integration | Persistence and query behavior | Vitest + MongoDB Memory Server | Real indexes, serialization, isolation, cleanup |
| End to end | One or two critical journeys | Playwright | Browser-to-API behavior, not every implementation detail |
Use the cheapest layer that can catch the regression. A component test should not repeat every API implementation test, and an end-to-end test should cover a journey rather than every edge case.
Minimum test shape
Every important test should make clear:
- Arrange: establish the initial condition and fixture data.
- Act: perform a user interaction or HTTP request.
- Assert: check the observable result, including status and response shape where relevant.
Name tests as contracts: shows an error when loading tasks fails is more useful than calls fetchTasks.
Practical commands
From the repository root:
cd projects/testing-fixtures
npm install
npm test
npm run test:coverage
npm run test:watch
The fixture's npm test runs React/Vitest/MSW and Express/Supertest tests together. For a learner project, use equivalent scripts such as:
npm run lint
npm run typecheck
npm test -- --run
npm run test:e2e
Fixture layout
projects/testing-fixtures/
.github/workflows/ci.yml
src/client/TaskList.jsx
src/client/task-api.js
src/server/app.js
test/client/handlers.js
test/client/setup.js
test/client/TaskList.test.jsx
test/client/accessibility.test.jsx
test/client/task-api.test.js
test/server/app.test.js
verification/learner-checklist.md
package.json
vitest.config.js
React, Vitest, and MSW
Render components with Testing Library and query by role, label, or visible text. Do not assert private state or component instances. MSW intercepts fetch at the network boundary, which keeps loading, success, empty, and failure states deterministic without mocking the module under test.
The fixture uses setupServer(...handlers) in test/client/setup.js, resets handlers after every test, and closes the server after the suite. Override one response for a failure case with server.use(http.get(...)); never let a test call a real API.
Cover at least:
- loading indicator before the request resolves;
- successful list and empty list;
- server failure with a retry or useful message;
- submit controls disabled while saving;
- keyboard interaction and a visible, labelled error.
The fixture demonstrates loading, successful data, dependency failure, an API response shape, authentication rejection, and an automated accessibility check. It does not include an empty-state, submit/save, retry, or keyboard-interaction flow; add those tests when the corresponding UI exists.
Express tests
Export an Express app, not a listening server. Supertest can then create requests without binding a port:
The fixture exports learnerToken as a random process-local test credential; import it from the fixture instead of committing a reusable bearer token.
const response = await request(app)
.get('/api/tasks')
.set('Authorization', `Bearer ${learnerToken}`); // import the fixture's process-local test token
expect(response.status).toBe(200);
expect(response.body).toEqual({ tasks: expect.any(Array) });
Test 2xx success, malformed JSON or validation (4xx), missing/incorrect auth (401/403), not-found behavior, and dependency failure (500). Assert the public response contract, not the order of internal function calls. Keep app.listen() in a separate production entry point.
MongoDB and authentication strategy
Use three levels:
- Unit-test password/token helpers with fixed inputs and fake time. Never assert a real password or commit a secret.
- Run repository/model integration tests against
MongoMemoryServer(or a disposable MongoDB service in CI). Start one instance per worker or suite, use a unique database name, clear collections inbeforeEach, and stop it inafterAll. - Run a small authenticated API suite against the real repository: register/login, session or token cookie, authorized read/write, another user's forbidden resource, logout, and expired/invalid credentials.
For cookie sessions, set secure: false only in the test environment, use an isolated session store, and inspect cookies through Supertest's agent. For JWT, inject a test signing key through the environment and test expiry with fake time. The default fixture uses in-memory app data, so learners can understand route/auth contracts before adding MongoDB.
Accessibility checks
Use semantic HTML and keyboard-first queries. Add axe-core to component tests for automated rules, but treat it as a supplement: also tab through the flow, check focus after errors/modals, verify labels and names, test reduced motion where relevant, and inspect color contrast in the browser. Fix violations rather than disabling a rule; document any intentional exception.
CI and delivery
The fixture's CI workflow runs on pushes and pull requests using Node 24 and npm ci. A learner project should run install, lint, typecheck, unit/integration tests with coverage, and build. Add the browser job and disposable MongoDB service only when those layers exist. Upload coverage and Playwright reports as artifacts on failure. CI must use test-only credentials, never production databases, and fail on test, type, lint, or build errors.
Learner verification artifacts
Complete verification/learner-checklist.md for each project iteration. Record the command, date, commit, result, and known gaps. A passing command is evidence of a run, not proof that an untested risk is safe; explicitly list skipped browser, database, performance, and security checks.
What to test
Test successful behavior, empty behavior, malformed input, authorization boundaries, and dependency failure. Do not test implementation details when a user-visible or API-visible contract is available.
Test doubles: mock, stub, and spy
These words describe different controls, not interchangeable synonyms:
- A stub returns a known value so the test can reach a branch. Example:
clock.now()returns a fixed instant. - A spy records calls to an existing function while preserving its behavior, or wraps a stub to verify a meaningful side effect.
- A mock programs expectations about a collaborator, including its return value and sometimes required calls. Overusing mocks can hide broken wiring.
Never mock the unit under test. Prefer a real pure function, an in-memory repository, or MSW at the HTTP boundary. Stub time and randomness, spy on meaningful side effects, and mock only an external boundary whose real behavior is unavailable or too expensive. Assert the result first; assert calls only when the call is part of the contract.
Contract and property testing
Example-based tests prove named scenarios. Contract tests prove that a provider and consumer agree on method, URL, fields, status, envelope, and error codes. Keep a shared fixture for POST /api/tasks: the Express test validates it, and the client MSW handler returns the same shape. This catches a renamed data field without requiring a browser test.
Property-based tests prove an invariant across generated inputs. For parseTaskId, a property is that every accepted value is a positive safe integer, while whitespace, zero, decimals, signs, and unsafe integers are rejected. Generate many strings, but keep a readable regression example for every discovered bug.
Isolation and determinism checklist
- Each test creates its own user, task, database name, or fixture; do not depend on test order.
- Reset handlers, fake timers, mocks, DOM, storage, and environment after each test.
- Use a disposable database or transaction strategy and close clients in teardown.
- Use unique ports and credentials in parallel workers; never use production or a shared developer database.
- Control time, randomness, locale, and network responses when they affect assertions.
- Fail on unhandled requests and clean up servers, listeners, subscriptions, and temporary files.
- Repeat the suite and use shuffled/parallel mode when supported.
Delivery and operational evidence
A practical CI pipeline is: npm ci -> lint/typecheck -> unit tests -> integration/contract tests -> build -> isolated E2E -> deploy preview -> smoke test -> approved production promotion. Do not promote if a required check is skipped. Upload coverage, test output, browser traces, and build metadata as artifacts; coverage is a risk signal, not a quality target by itself.
After deployment, run health/readiness, one unauthenticated denial, one disposable authenticated create/read flow, and a refresh. Check status, response envelope, request ID, cookie flags, and that no real data was touched. Watch error rate, latency, readiness, and logs during the canary window. Define rollback before release: revert to the last known-good artifact when smoke tests fail, agreed thresholds regress, data integrity is threatened, or the new version cannot be diagnosed quickly. Verify rollback with the same smoke test and record the incident.
