This guide covers how tests are organized in this repo, how to run each kind, and how to write a new one that follows the patterns already in use. It's aimed at anyone adding a component, a page, or a flow and needing to know "what test do I write, and what does it look like here."
For the automated accessibility gate specifically (axe rules, keyboard
tests, what's scanned, what still needs a manual screen-reader pass), see
docs/accessibility-testing.md — this guide
covers accessibility testing mechanics (how to write an a11y test) while
that one covers accessibility testing coverage (what's scanned and why).
| Layer | Tool | Where | Run with |
|---|---|---|---|
| Component tests | Jest + React Testing Library | co-located __tests__/ next to the component, or *.test.tsx beside it |
npm run test |
| End-to-end / integration | Playwright | e2e/ |
npm run test:e2e |
| Accessibility (axe + keyboard) | Playwright + @axe-core/playwright |
e2e/accessibility/ |
npm run test:e2e:a11y |
There's also a visual regression suite (npm run test:e2e:visual, under
e2e/visual/) and an API contract check (npm run test:contracts,
comparing generated types against the backend's OpenAPI spec) — both exist
today but aren't covered in depth here since they're not something you
typically hand-write a new test for; see their existing specs for the
pattern if you need to add a route to either.
Two conventions are both in active use in this repo — match whichever your
target file already uses, or use __tests__/ for new component
directories:
components/<area>/__tests__/<name>.test.tsx— e.g.components/common/__tests__/confirmation-dialog.test.tsx,components/proofs/__tests__/wizard-steps.test.tsx.app/<route>/page.test.tsxnext to the page it tests — e.g.app/status/page.test.tsx,app/proof-types/__tests__/page.test.tsx(theapp/tree has both conventions in different routes).- A top-level
tests/directory for suites that don't map 1:1 to a single component:tests/api/,tests/components/,tests/contracts/,tests/exports/,tests/qr/, each with fixtures intests/fixtures/.
npm run test # run once
npm run test -- --watch # watch mode
npm run test -- path/to/file # a single file or glob
npm run test -- --runInBand # what CI actually runsConfig lives in jest.config.mjs (built on next/jest, testEnvironment: "jsdom", @/ path alias) and jest.setup.ts (imports
@testing-library/jest-dom matchers, and polyfills TextEncoder/
TextDecoder — jsdom doesn't provide them, and anything that imports
lib/validation/qr-payload.ts, directly or transitively, needs them for
byte-length checks on proof IDs).
Every test file in this repo starts with the jsdom environment pragma,
even though the global config already sets testEnvironment: "jsdom" —
keep doing this for consistency with the rest of the suite:
/**
* @jest-environment jsdom
*/
import { render, screen } from "@testing-library/react";
import { MyComponent } from "../my-component";
describe("MyComponent", () => {
it("renders with basic props", () => {
render(<MyComponent title="Test" />);
expect(screen.getByText("Test")).toBeInTheDocument();
});
});Real example worth reading end to end for the house style:
components/common/__tests__/confirmation-dialog.test.tsx. It covers
rendering with default/custom props, variant styling
(toHaveClass("bg-rose-600")), initial focus management
(toHaveFocus()), click handlers, an Escape-key handler, a disabled/
processing state, and ARIA attributes (aria-modal, aria-labelledby,
aria-describedby) — that spread is a good checklist for any interactive
component: happy path, variants, keyboard, disabled state, ARIA.
Components that call the backend go through lib/api/client.ts's
apiClient(). Mock the module, not fetch — it's simpler and keeps the
test decoupled from apiClient's retry/timeout internals:
import { apiClient } from "@/lib/api/client";
jest.mock("@/lib/api/client", () => ({
apiClient: jest.fn(),
bearer: (token: string) => ({ Authorization: `Bearer ${token}` }),
}));
const mockedApiClient = apiClient as jest.MockedFunction<typeof apiClient>;
beforeEach(() => {
mockedApiClient.mockReset();
});
it("shows a loading state while the request is in flight", async () => {
let resolve!: (value: MyResponse) => void;
mockedApiClient.mockReturnValue(new Promise((r) => { resolve = r; }));
render(<MyComponent />);
// ...trigger the request...
expect(await screen.findByRole("status")).toBeInTheDocument();
resolve(myFixtureResponse);
await waitFor(() => {
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});See components/verification/__tests__/verify-proof-form.test.tsx and
components/proofs/__tests__/create-proof-flow.test.tsx for full working
examples of this pattern, including asserting on which specific
apiClient path was called when a component hits more than one endpoint
(match on options.path inside mockImplementation).
For a page that reads window.fetch directly instead of going through
apiClient (the health-check hook, lib/health-check.ts, is the one
current example), mock global.fetch instead — see
app/status/page.test.tsx for the full pattern, including faking timers
to test the poll interval and the abort-on-timeout path.
Most components never touch @stellar/freighter-api directly — they call
through CreateProofFlow's internal getFreighterAddress() /
signFreighterMessage() helpers, which dynamically import() the
package on demand. For a unit test that needs an authenticated session
without exercising the wallet-connect UI at all, skip Freighter
entirely and seed localStorage directly — CreateProofFlow reads its
session from there on mount:
const SESSION_KEY = "earnproof.session";
beforeEach(() => {
window.localStorage.setItem(
SESSION_KEY,
JSON.stringify({
token: "test-token",
user: { id: "user-1", walletAddress: "GABC...TEST", walletHash: "wh_test", role: "worker" },
}),
);
});
afterEach(() => {
window.localStorage.removeItem(SESSION_KEY);
});See components/proofs/__tests__/create-proof-flow.test.tsx for this in
context. If you specifically need to unit-test the wallet-connect flow
itself (not just code that runs after a session exists), you'd need to
mock the @stellar/freighter-api module the same way the e2e suite mocks
the extension's postMessage protocol (see the next section) — there's
no existing unit-test example of that in the repo today, so reach for the
Playwright fixtures below instead for anything that actually exercises
wallet connect end to end.
ProofErrorBoundary and VerifyErrorBoundary
(components/common/proof-error-boundary.tsx,
components/common/verify-error-boundary.tsx) are tested by rendering a
child that conditionally throws:
function Bomb({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) throw new Error("boom");
return <div>Content</div>;
}
it("catches a thrown error and renders the fallback instead of crashing", () => {
render(
<ProofErrorBoundary>
<Bomb shouldThrow={true} />
</ProofErrorBoundary>,
);
expect(screen.getByText("Proof creation hit a problem")).toBeInTheDocument();
});React logs caught errors to console.error itself (dev-mode noise, not
a real test failure) — silence it with a beforeAll/afterAll pair around
the whole suite, then use jest.spyOn(console, "error") inside the one
test that actually asserts on the boundary's own log call. See
components/common/__tests__/proof-error-boundary.test.tsx for the full
pattern, including asserting role="alert" / aria-live="assertive",
that focus moves to the error heading (flush a microtask first — the
boundaries set focus from componentDidCatch, not componentDidUpdate,
so it lands one microtask after the fallback commits: await Promise.resolve(); before asserting toHaveFocus()), and that clicking
"Try again" re-renders children once the underlying error condition is
gone.
e2e/*.spec.ts— top-level flows:wallet-auth.spec.ts,proof-creation.spec.ts,public-verification.spec.ts.e2e/fixtures/— shared building blocks (below).e2e/accessibility/— the axe + keyboard suite, seedocs/accessibility-testing.md.e2e/visual/— screenshot regression, its own config (playwright.visual.config.tsif present, or documented ine2e/visual/README.md).
npm run test:e2e # full e2e suite (e2e/*.spec.ts)
npm run test:e2e:a11y # accessibility suite (e2e/accessibility/)
npm run test:e2e:visual # visual regression (e2e/visual/)
npm run test:e2e:visual:update # regenerate visual baselinesplaywright.config.ts builds the app and runs next start against it
(not next dev) specifically so Fast Refresh/Turbopack recompilation
never remounts a component mid-test — see the comment in that file if a
spec is flaking around a click that "should" have landed.
NEXT_PUBLIC_API_URL points at an intentionally-unreachable synthetic
loopback address; every request to it is intercepted by the ApiMock
fixture, so no spec ever depends on a live backend.
e2e/fixtures/test.ts extends Playwright's base test with two fixtures
every worker-flow spec needs — import from there, not from
@playwright/test directly, for any spec that needs either:
import { test, expect } from "./fixtures/test";
test("does the thing", async ({ page, freighter }) => {
await freighter(); // installs the Freighter mock for this test
// apiMock is auto-installed for every test (`auto: true`), so
// /auth/challenge, /payments, etc. are already intercepted here.
});apiMock(e2e/fixtures/api-mock.ts) — a stateful in-memory double of the EarnProof backend, installed viapage.route(). It answers/auth/challenge,/auth/verify,/payments/sync,GET /payments,PATCH /payments/:id/classification,POST /proofs/minimum-income, andGET /proofs/:id/verifyfrom synthetic fixture data (e2e/fixtures/synthetic-data.ts), tracksauthRequests/verifyRequestsfor specs that want to assert on what was called, and lets a spec swap the verify outcome mid-test viasetVerifyOutcome()("valid" | "expired" | "revoked" | "invalid"etc., matchingSYNTHETIC_VERIFY_RESPONSES). Any request that doesn't match a known route/method 404s with an explicit "unmocked route" body instead of hanging — if a spec times out waiting on a network response, check here first for a route this fixture doesn't yet handle.freighter(e2e/fixtures/freighter-mock.ts, wired through thefreighterfixture intest.ts) — answers the Freighter extension'swindow.postMessageprotocol (FREIGHTER_EXTERNAL_MSG_REQUEST/FREIGHTER_EXTERNAL_MSG_RESPONSE, matched bymessageId) the way a real unlocked wallet would, since no real extension is installed in the test browser. Call it with no args for the happy path (a fixed synthetic testnet address + a static, non-cryptographic "signature" string), or{ denyAccess: true }to simulate the user rejecting the connection — seee2e/wallet-auth.spec.tsfor that case.e2e/accessibility/fixtures/has its own, near-identicalmock-freighter.ts/mock-api.tspair rather than sharing the ones above — the accessibility suite predates/parallels the main e2e fixture set. If you're adding a route to one API mock and it also needs scanning for accessibility, check whether it needs adding to both.
e2e/fixtures/pages.ts wraps each page's locators behind a small class
(e.g. ProofCreationPage, exposing .connectButton, .syncButton,
.paymentCheckbox(index), .createProofButton, etc.) so specs read as
actions/assertions, not raw selectors. e2e/fixtures/flows.ts layers
multi-step sequences on top — connectAndAuthenticate(page) navigates to
proof creation and completes the wallet-connect handshake, since nearly
every proof-creation spec needs an authenticated session as its starting
point:
import { test, expect } from "./fixtures/test";
import { connectAndAuthenticate } from "./fixtures/flows";
test("syncs payments and only allows selecting eligible income rows", async ({ page, freighter }) => {
await freighter();
const proofPage = await connectAndAuthenticate(page);
await proofPage.syncButton.click();
await expect(page.getByText("Payments synced.")).toBeVisible();
});See e2e/proof-creation.spec.ts in full for more of this pattern,
including a documented workaround for a real (pre-existing, flagged)
responsive-layout bug — worth reading as an example of how to handle a
spec that surfaces an app bug you're not there to fix: comment the root
cause precisely, use the minimal workaround ({ force: true }) needed to
still exercise the intended behavior, and link the follow-up rather than
silently masking it.
Covered in depth in
docs/accessibility-testing.md: the axe
rule set, which routes are scanned (e2e/accessibility/routes.ts — add a
route there to have scans.spec.ts pick it up automatically), the
dynamic-states suite that scans loading/error/success states beyond the
initial HTML, and the keyboard-interaction suite that drives real
page.keyboard automation for things axe can't verify (tab order, skip
links, focus restoration). That doc also lists what still needs a manual
screen-reader pass and isn't (and can't be) automated.
The short version for writing a new a11y test: use runAxeScan(page, testInfo, { routeName }) from e2e/accessibility/fixtures/axe.ts — it
scans WCAG 2.0/2.1 A+AA rules, attaches the full JSON report to the test
run, and throws a formatted error naming the route, rule ID, and affected
DOM nodes for any critical/serious violation (minor/moderate
violations are recorded but don't fail the test, so the gate stays
actionable).
- E2e/Playwright:
e2e/fixtures/synthetic-data.tsis the single source of truth for worker addresses, payment records, proof IDs, credential hashes, and verify-response bodies used across the whole e2e suite — reuse constants from there (SYNTHETIC_WORKER_ADDRESS,SYNTHETIC_PROOF_ID,SYNTHETIC_CREDENTIAL_HASH, etc.) rather than hardcoding new magic strings in a spec, so a spec's assertions stay traceable to what the mock actually returned. - Jest/unit: fixtures live under
tests/fixtures/as plain JSON, imported directly — e.g.tests/fixtures/qr/image-states.jsonmodels syntheticBarcodeDetectoroutcomes (blurred/rotated/multiple-codes/ oversized) since raster QR decoding isn't available under jsdom; seetests/qr/verify-scan.test.tsxfor how it's consumed (import imageStates from "@/tests/fixtures/qr/image-states.json"). Add new fixture JSON here rather than inlining large literal objects in a test file when more than one test (or one future test) will want the same shape. - Every fixture in both systems is explicitly synthetic — no real wallet
keys, signatures, or backend data. Comments in
synthetic-data.tsand the mock modules call this out; keep that invariant when adding new fixtures.
- Component test? Put it in
__tests__/next to the component (or<name>.test.tsxbeside it, matching the existing sibling files in that directory). Start with the@jest-environment jsdompragma. MockapiClient(notfetch) unless the component readsfetchdirectly. If it renders an async/loading state, assert the loading UI appears before resolving the mock and disappears after. - New page or multi-step flow? Add a Playwright spec under
e2e/. Reusee2e/fixtures/test.ts'sapiMock/freighterfixtures rather than writing newpage.route()calls inline. If the flow needs an authenticated session, reuse or extendconnectAndAuthenticateine2e/fixtures/flows.ts. - New route? Add it to
e2e/accessibility/routes.tsso it's covered by the automated axe scan with zero extra harness work. If it has a meaningful loading/error/success state beyond the initial HTML, consider adding it toe2e/accessibility/dynamic-states.spec.tstoo. - New interactive component (dialog, form, disclosure)? Cover: happy path render, every documented prop/variant, keyboard interaction if applicable (Enter/Space/Escape), a disabled/loading state if it has one, and its ARIA attributes (role, aria-live, aria-busy, aria-describedby, etc. — whatever the component actually sets).
- New error boundary or fallback UI? Follow
proof-error-boundary.test.tsx's pattern: a component that conditionally throws, assert the fallback replaces the crashed subtree, assert the error is logged without propagating, assertrole="alert"/aria-live, assert focus moves into the fallback, and assert retry actually recovers once the underlying condition clears. - Run
npm run lintandnpm run test -- <your file>before opening a PR. If you touched anything undere2e/, also run the relevantnpm run test:e2e*script locally — CI will run it, but Playwright failures are much faster to debug locally with--headedor the trace viewer than from a CI artifact.
- No current unit-test example mocks
@stellar/freighter-apidirectly at the module level (only the e2e suite mocks the extension'spostMessageprotocol) — see "Mocking Freighter (unit-test level)" above. e2e/accessibility/fixtures/duplicatese2e/fixtures/'s Freighter and API mocks rather than sharing them; consolidating is a reasonable follow-up but out of scope here since it'd touch both suites' fixture wiring at once..github/workflows/ci.ymlhas a YAML key collision worth knowing about if a job here seems to "not run": theaccessibility:job key is immediately overwritten by thevisual-regression:key right below it, so the accessibility job never runs as its own CI job today — its steps only execute merged into thevisual-regressionjob (which is why that job's steps start with "Run accessibility scans" before getting to visual regression).npm run test:e2e:a11ystill works correctly when run directly (locally or from another job); only the standaloneaccessibilityCI job entry is affected. Worth a dedicated fix; flagged here so it isn't mistaken for intended behavior while reading the workflow file.