Skip to content
Hardcount
Run it locally
Pre-1.0 · one maintainer · Stripe and cards only · no SDKs (client libraries) yet · no production users yet. Docs describe v0.9.1 (29 Oct 2026) · What’s new →

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.

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.

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.

Start Postgres once: docker compose up -d postgres.

Terminal window
# Layer 1: Go
go test ./... -short # unit tests, no database
go test -p 1 ./... -short=false # unit + integration; test databases are created for you
# Layer 2: static checks
make lint # golangci-lint
make gen && git diff --exit-code # generated code matches api/openapi.yaml
cd 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:e2e

Use npx tsc -b for type checks. tsc --noEmit checks nothing in this repo.

CONTRIBUTING lists every CI gate and its local command.

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.ts opens 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.ts signs 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.ts changes a subscription’s plan, items, trial and collection, and cancels it, checking each change against the API. account-setup.spec.ts creates 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.ts issues, settles, corrects and closes invoices: draft to finalized, credit notes, an offline payment, void and write-off.
  • Activity screenshots: activity.spec.ts screenshots 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 the activity-shots artifact 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 e2e job runs on every PR. It is not a required check. On a failure, download the playwright-report artifact and open its trace with npx playwright show-trace.

How to run them locally, step by step: Browser tests (Playwright).

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.

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.

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:

Terminal window
go run ./cmd/hardcount-docs-run \
-replace deploy/compose/quickstart-credentials.json=$HOME/hardcount-key.json \
-replace acme-1=acme-$RANDOM docs/first-invoice.md

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