Overview
This page shows you how to run Litestore's tests and decide where a new test belongs.
Litestore's tests make sure that money, stock and checkout keep working when you change the code. This page shows you how to run them, what the important ones check, and where a new test should go.
Litestore has two test lanes. The unit lane needs no database. The integration lane runs against a real local Postgres database.
The split is on purpose. Unit tests should be cheap enough that you can run them all the time while you work. Integration tests are for behavior you can only prove with a real database.
Litestore currently has 154 *.test.ts files. 124 of them are in lib/__tests__/. The rest are next to the modules they test, and three of those are in test/integration/.
Running the tests
bun run testThis runs Vitest in watch mode.
bun run vitest runThis runs the unit suite once. verify and the pre-commit hook use this command.
bun run test:integrationThis runs the integration lane.
bun run test runs Vitest, not bun test. The tests depend on the Vitest configuration, so Bun's own test runner won't give you the same result.
Unit lane
vitest.config.ts leaves out these paths:
test/integration/**
**/.claude/**It sets SKIP_ENV_VALIDATION=1 and uses the node environment. It also points server-only at test/server-only-stub.ts.
As a result, you can run the unit suite without Postgres, Redis, S3 credentials or any other infrastructure.
Integration lane
vitest.integration.config.ts points the tests at this database:
postgresql://postgres:postgres@localhost:5432/litestore_testYou can set TEST_DATABASE_URL to use a different one.
The integration config also sets:
fileParallelism: falseThe suites share one database and truncate or change shared tables. If the files ran at the same time, one test could break another.
Each integration test gets 30 seconds instead of the default 10.
Contract tests
A contract test checks a behavior that other parts of Litestore are allowed to rely on. Litestore has contract tests at two levels.
Application invariants
lib/__tests__/invariants.test.ts runs a fixed set of amounts, rates and tax settings through the real calculation code.
It checks rules such as these:
subtotal + tax === total- inclusive tax does not change the charged total
- exclusive tax adds tax
calculateExclusiveTaxis non-negativecalculateExclusiveTaxis monotonicsum()does not depend on operand ordersum()throws when currencies do not match
The inputs are fixed. The test doesn't generate random input.
These tests are small, but checkout, pricing and payments all depend on the rules they check. A test of one example can miss a broken rule. These tests try many inputs, so they catch a whole kind of failure.
Database guarantees
The integration lane checks rules that Postgres itself enforces.
test/integration/db-guarantees.test.ts creates a paid order. Then it tries to write data the database must refuse, such as a refund larger than the order allows or stock movement numbers that don't add up.
The test doesn't care which application function refused the write. What it proves is that the database will not accept the invalid data.
test/integration/inventory-transactional.test.ts and test/integration/price-resolution.test.ts cover the other cases that need a database.
Contract tests protect promises, not implementations
There are fewer of them and they run slower. Never delete one to make a build pass.
Tests must run the production code
Don't copy the function you're testing into the test file.
If you do, the test can pass while the real app is broken:
production code
↓
not imported
test file
↓
local copy of production logic
↓
assertions passThis happened in the old payment-webhook.test.ts. It defined its own handlePaymentSuccess and handlePaymentFailure and then tested those copies for 679 lines.
We deleted that test instead of fixing it.
A test has to import the production code and run it. A copy of the logic inside a test is just a second implementation.
For the same reason, the critical-test check looks at imports. It doesn't just look for a test file with a matching name.
Critical-path coverage
Some code is especially risky to leave untested. Litestore has a separate check for it:
scripts/check-critical-tests.tsThe check requires at least one *.test.ts to import every source file in these paths:
lib/checkout/lib/payments/lib/orders/lib/pricing.tslib/channel.ts- the
cart,tax,orders,inventory,shipping, andcollabsservices server/admin/{refunds,returns,orders,shipping,analytics}/server/web/{checkout,tax,shipping}/
Type, index and payload files are left out when they contain no logic to test.
Why the check looks at imports
A file name tells you very little.
A file called refunds.test.ts might never import the refund code. It could test a helper, a mock or a local copy.
So the critical-test check asks a different question:
Does a test actually import this production file?
That catches the case where the repository seems to have a test, but the test never runs the production module.
Known exceptions
Critical files that already had no test are listed in:
scripts/discipline-baselines/critical-tests.txtThe list currently has 47 lines. It records old gaps, so the check could be turned on without pretending everything was already covered.
The check fails when you add a new critical file without a test.
It also fails when you delete the only test that imports an existing critical file.
The list should get shorter over time. Don't add new exceptions to it.
Rendering tests
Litestore has no rendering tests right now. The repository has:
- no
*.test.tsxfiles - no
@testing-librarydependency - no snapshot assertions
- no
toMatchSnapshotcalls
This is a deliberate choice, not a migration someone left half done.
A snapshot tells you that the markup changed. It can't tell you whether the new UI is right, and you would have to update it every time the design changes.
When a component's behavior can be written as data or configuration, Litestore tests that data directly.
For example, lib/blocks/composition.test.ts checks that facet bindings map to separate URL parameters. It also checks that those parameters parse back into the filter shape the block expects.
Litestore also doesn't enforce a line-coverage percentage.
vitest.config.ts has a coverage block that uses the v8 provider, but @vitest/coverage-v8 isn't installed. Until it is, lint:critical-tests is what makes sure critical code has tests, and there's no percentage to reach.
Running some of the tests
To run one file:
bun run vitest run lib/__tests__/refund-cap.test.tsTo run one directory:
bun run vitest run lib/__tests__To run in watch mode:
bun run testTo run the integration tests:
bun run test:integrationWhen you work on one area, run its file or directory first. You don't need to wait for the full verification after every small change.
Before a pull request
Run the normal local check:
bun run verifyThe steps run in a set order so that cheap failures show up before slow ones:
biome check .lint:disciplinelint:cachelint:deadlint:flexlint:theme-boundarieslint:no-as-anylint:critical-testslint:event-completenesslint:nested-soft-deletelint:public-feed-visibilitylint:nested-hidden- the 13-script
lint:cleanlinessaggregate vitest runtypecheck
For testing, the important detail is that lint:critical-tests runs before Vitest. If a critical production file has no test, verify reports that as its own failure instead of hiding it in the test run.
Running the checks in parallel
When several things are broken, running the checks one after another can be slow.
bun run verify:parallelscripts/verify.ts runs the same 15 steps several at a time. By default it runs cpus - 1 at once, and you can change that with VERIFY_CONCURRENCY.
verify stops at the first failure. The parallel runner reports every failure it finds, and it shows how long each step took.
Hooks and CI
Your machine and CI run the same basic checks.
Lefthook runs these before each commit:
lint:discipline
lint:critical-tests
bun run vitest runBefore each push, it runs the full check:
bun run verify.github/workflows/verify.yml runs on every pull request and on every push to main.
That way the checks you run locally match the checks that decide whether a change can merge.
Database guarantee audit
The database audit isn't part of verify, because it needs a live database.
Run it with:
bun run db:guarantees:auditPoint it at the database you want to inspect.
If verify included this audit, verify would need a running Postgres instance. Keeping it separate means verify still runs without a database.
Where to put a new test
Write a unit test when you can run the behavior without any infrastructure.
Write an integration test when the behavior depends on Postgres itself, such as transactions, constraints or data already in the database.
Write an invariant or contract test when you are protecting a rule that must hold for many inputs, not just one example.
If you're changing money, pricing, tax, orders, inventory, checkout or payment code, check whether the file is on a critical path. If it is, a test has to import that production file.
When you add a test, don't only ask which line you want to cover. Ask this instead:
What could break here, and what is the lowest layer where I can prove it hasn't?