How we test Hardcount
Each code change should ship with a test at the right layer. This page says which test to write for a change, how to run each kind, and what is checked by hand before a release.
The four layers
Section titled “The four layers”| Layer | Tools | What belongs here |
|---|---|---|
| 1. Logic and money | go test: unit tests (-short) and integration tests against a real Postgres (internal/testutil) |
All money math, state changes, Row-Level Security (RLS), migrations, API contracts and architecture rules (the source tests in internal/arch) |
| 2. Static checks | golangci-lint, eslint, tsc -b, codegen drift (make gen) |
Code rules. A rule we can check by reading the code becomes a linter rule or a Go test, not a script |
| 3. Dashboard end to end | Playwright (@playwright/test) |
A smoke test over every page, plus a few key user journeys, against a real API with seeded data |
| 4. Live services | Release checklist, run by hand | Real Stripe test mode, real email, the LiteLLM callback and the invoice PDF. Run before each release tag |
Most tests belong in layer 1. Layers 3 and 4 stay small on purpose.
Which test to write
Section titled “Which test to write”| You changed | Write |
|---|---|
| Money, an invoice, payment, credit, dunning, subscription or tax state | A Go integration test against real Postgres. Follow the money-path playbook §5: test the collision, not only the happy path |
| Pure logic with no database (a calculation, a parser, a formatter) | A Go unit test, or a Vitest test in web-v2/tests/ for dashboard code |
| An API endpoint | A Go test that calls the handler and checks the status code and body |
| A rule about how code may be written | A golangci-lint rule in .golangci.yml, or a source test in internal/arch |
| A dashboard page or a user journey | A Playwright spec in web-v2/e2e/. New pages are added to the smoke list |
| Stripe, email or PDF behavior that a fake cannot prove | A line in the release checklist, plus a Go test for the logic around it |
A Playwright test checks that the screen shows what the API returned. Arithmetic stays in Go tests.
A lint waiver names the linter and says why, on the same line. A bare
//nolint fails the lint. For example:
| Waiver | The rule it waives |
|---|---|
//nolint:forbidigo // wall-clock: <reason> |
No bare time.Now() in packages whose time can come from a test clock |
//nolint:forbidigo // single-invoice-ok: <reason> |
No lookup of one invoice for a billing period in internal/subscription and internal/billing, because several invoices can pay for one period |
One source test in internal/arch checks dates: a calendar date must be
formatted in an explicit time zone (.In(loc)), or carry a same-line
//tz:ok comment that marks a real UTC case.
A row dated at an instant the contract names (a trial end, a dunning retry,
a cycle close) is tested on a test clock advanced two days past that
instant. A row dated at the advance instead then reads two days late. The
activity scenarios are in
internal/api/activity_parity_dates_integration_test.go.
Run each layer locally
Section titled “Run each layer locally”Start Postgres once: docker compose up -d postgres.
# Layer 1: Gogo test ./... -short # unit tests, no databasego test -p 1 ./... -short=false # unit + integration; test databases are created for you
# Layer 2: static checksmake lint # golangci-lintmake gen && git diff --exit-code # generated code matches api/openapi.yamlcd web-v2 && npx tsc -b && npm run lint # eslint, then knip (unused code)
# Dashboard unit tests (Vitest, web-v2/tests/*.test.ts)cd web-v2 && npm test
# Layer 3: Playwright (needs a running API and dashboard; see web-v2/e2e/README.md)cd web-v2 && npm run test:e2eUse npx tsc -b for type checks. tsc --noEmit checks nothing in this repo.
CONTRIBUTING lists every CI gate and its local command.
Playwright: how the end-to-end tests work
Section titled “Playwright: how the end-to-end tests work”The specs live in web-v2/e2e/ and run in Chromium against a real API and
the built dashboard (vite preview, which forwards /v1 to the API).
- Smoke:
smoke.spec.tsopens every dashboard page, and every public page signed out, at 1440px and 390px wide. A page fails if it logs a console error, an API call answers 400 or more, the page scrolls sideways, or its heading is missing. Each page is its own test, so a failure names the page. - Journeys:
journeys.spec.tssigns in, creates a customer and a subscription, advances a test clock to the renewal and opens the new invoice, opens the public invoice page, and grants credits.subscriptions.spec.tschanges a subscription’s plan, items, trial and collection, and cancels it, checking each change against the API.account-setup.spec.tscreates a price, meter and plan, installs a recipe, adds and ends a customer’s price override, and creates, assigns and makes default a dunning policy.invoices.spec.tsissues, settles, corrects and closes invoices: draft to finalized, credit notes, an offline payment, void and write-off. - Activity screenshots:
activity.spec.tsscreenshots the invoice and subscription Activity cards, a Dunning run and Sent emails, each on the real clock and on a test clock. They are for comparing a PR with main by eye. CI uploads them as theactivity-shotsartifact on every run (how to compare). One test checks text parity: the same operator steps on an invoice give the same Activity rows on both clocks, apart from times and the Recorded part. It also checks that the card’s times follow the account’s zone, not the browser’s. - Data: the tests make their data through the public API, with the
test-mode secret key from
hardcount-bootstrap. Each test uses its own unique names, so the suite can run many times against the same database. Use a database of your own anyway, so the tests never touch your dev data. - In CI: the
e2ejob runs on every PR. It is not a required check. On a failure, download theplaywright-reportartifact and open its trace withnpx playwright show-trace.
How to run them locally, step by step: Browser tests (Playwright).
Flaky tests
Section titled “Flaky tests”A flaky test passes and fails on the same code.
- CI retries a failed test once. A test that passes on the retry is reported as flaky, not as passed.
- Before you commit a spec, run it five times:
npx playwright test --repeat-each 5 <spec>. - Wait on the page or the network, never on a fixed sleep.
- Select by role and label (
getByRole,getByLabel), not by CSS class. - Fix or delete a flaky spec. Do not mark it as skipped and move on.
Checking a change by hand
Section titled “Checking a change by hand”Automated tests come first. When you also check something by hand, three habits have caught real bugs here:
- Look at the screen. Text and DOM checks cannot see layout bugs. A UI change is not done until you have looked at a screenshot of it, in the state a user would see.
- Check money at the provider. For a payment or refund, confirm the amount in the Stripe dashboard, not only in Hardcount.
- For a fix, see the test fail without it. Revert the fix, run the new test, watch it fail, then restore the fix. A test that passes either way proves nothing.
Before a release
Section titled “Before a release”Run the release checklist before each tag.
The golden-path demo (scripts/demo.sh) and the hardcount-doctor sweep also
run in CI on every PR. The doctor checks the money invariants over the rows
the golden path left behind.
The first-invoice tutorial runs in the same CI job.
cmd/hardcount-docs-run runs each code block the page marks
<!-- docs-run: command --> in one shell, in order, and checks the block
marked <!-- docs-run: output --> below it. Every code block on the page
needs one of the two markers, and every command needs an output block. A
code block the runner cannot read, such as one in a blockquote, is an
error. So is a marked block that GitHub does not show as a code block, such
as one inside <details> with no blank line before it. A unit test checks
that each command runs in at least one of the job’s passes. To run the page
yourself, start Hardcount on
port 8080 and save your test key in a file such as ~/hardcount-key.json, as
{"secret_key_test": "hc_secret_test_…"}. Then run:
go run ./cmd/hardcount-docs-run \ -replace deploy/compose/quickstart-credentials.json=$HOME/hardcount-key.json \ -replace acme-1=acme-$RANDOM docs/first-invoice.mdThe second -replace gives the customer a new external ID, so the page can
run again on the same database.
Step 8 only prints the invoice’s dashboard link. The link opens on the
quick-start stack, which serves the dashboard and the API on one address.
When the API runs on its own, as in CI, the dashboard is elsewhere: the
dashboard’s dev server (npm run dev in web-v2) is on port 5173.
Next: set up your machine: CONTRIBUTING.