Litestore · Developer guide

Overview

Learn how to run the unit and integration lanes, what the invariant and contract tests assert, and which harness check keeps money and inventory covered.

Litestore has two test lanes: a database-free unit lane, and an integration lane that runs against a real local Postgres database.

The distinction is deliberate. Unit tests should be cheap enough to run constantly while working. Integration tests exist for behavior that cannot be meaningfully proved without the database.

Litestore currently has 154 *.test.ts files. 124 live in lib/__tests__/; the rest sit next to the modules they cover, with three under test/integration/.

Running the tests

bun run test

Runs Vitest in watch mode.

bun run vitest run

Runs the unit suite once. This is the one-shot command used by verify and the pre-commit hook.

bun run test:integration

Runs the integration lane.

bun run test is Vitest, not bun test. The repository's tests depend on the Vitest configuration, so using Bun's test runner is not equivalent.

Unit lane

vitest.config.ts excludes:

test/integration/**
**/.claude/**

It sets SKIP_ENV_VALIDATION=1, uses the node environment, and aliases server-only to test/server-only-stub.ts.

The result is a test process that does not need Postgres, Redis, S3 credentials, or other infrastructure just to run the unit suite.

Integration lane

vitest.integration.config.ts points the tests at:

postgresql://postgres:postgres@localhost:5432/litestore_test

TEST_DATABASE_URL can override that value.

The integration config also sets:

fileParallelism: false

The suites share a database and truncate or modify shared tables, so running the files concurrently would let one test interfere with another.

The integration timeout is 30 seconds instead of the 10-second default.

Contract tests

A contract test checks a behavior that other parts of the system are allowed to depend on.

There are two useful levels of contract tests in Litestore.

Application invariants

lib/__tests__/invariants.test.ts runs a deterministic matrix of amounts, rates, and tax behaviors against the actual calculation engines.

It checks properties such as:

  • subtotal + tax === total
  • inclusive tax does not change the charged total
  • exclusive tax adds tax
  • calculateExclusiveTax is non-negative
  • calculateExclusiveTax is monotonic
  • sum() does not depend on operand order
  • sum() throws when currencies do not match

The matrix is deterministic. There is no random input generation involved.

These tests are small, but they cover rules that sit underneath checkout, pricing, and payments. A test that only checks one example can miss a broken invariant; these tests are there to catch the class of failure.

Database guarantees

The integration lane checks contracts that belong to Postgres itself.

test/integration/db-guarantees.test.ts seeds a paid order and then tries to create states that the database must reject, including an over-cap refund and invalid stock-movement data.

That test is not primarily interested in which application function rejected the write. The point is that the database will not accept the invalid state.

test/integration/inventory-transactional.test.ts and test/integration/price-resolution.test.ts cover the other database-dependent cases.

Contract tests protect promises, not implementations

They are slower and there are fewer of them, and they are the ones you should never delete to make a build green.

Tests must execute production code

Do not reimplement the function being tested inside the test file.

A test like this can stay green while the real application is broken:

production code

not imported

test file

local copy of production logic

assertions pass

That happened in the old payment-webhook.test.ts. It defined local handlePaymentSuccess and handlePaymentFailure implementations and then tested those copies across 679 lines.

The test was deleted rather than repaired.

The test needs to import the production implementation and exercise that implementation. A copy of the logic in a test is just another implementation of the logic.

This is also why the critical-test check looks at imports, rather than merely looking for a test file with a matching name.

Critical-path coverage

Litestore has a separate check for code where a missing test is particularly risky:

scripts/check-critical-tests.ts

The check requires every source file under the critical-path globs to be imported by at least one *.test.ts.

The current globs cover:

  • lib/checkout/
  • lib/payments/
  • lib/orders/
  • lib/pricing.ts
  • lib/channel.ts
  • the cart, tax, orders, inventory, shipping, and collabs services
  • server/admin/{refunds,returns,orders,shipping,analytics}/
  • server/web/{checkout,tax,shipping}/

Type, index, and payload files are excluded where they contain no testable logic.

Why check imports?

A filename tells us very little.

refunds.test.ts could exist without importing the refund implementation. It could test a helper, a mock, or a local copy of the code.

The critical-test check instead asks:

Does a test actually import this production file?

That catches the particular failure mode where the repository appears to have a test but the production module is never executed by it.

Known exceptions

Existing untested critical files are recorded in:

scripts/discipline-baselines/critical-tests.txt

The baseline currently holds 47 lines. It is allowed to contain existing debt so that the check can be introduced without pretending that the repository is already fully covered.

A new critical file without a test fails the check.

Deleting the only test that imports an existing critical file also fails it.

The baseline should get smaller, not become a place to put every new exception.

Rendering tests

There are currently no rendering tests.

The repository has:

  • no *.test.tsx files
  • no @testing-library dependency
  • no snapshot assertions
  • no toMatchSnapshot calls

That is a current testing choice, not an unfinished migration.

A snapshot would tell us that the rendered markup changed. It would not tell us whether the new UI is correct, and it would need updating whenever the design changes.

Where component behavior can be expressed as data or configuration, the repository tests that directly instead.

For example, lib/blocks/composition.test.ts checks that facet bindings map to distinct URL parameters and that those parameters parse back into the filter shape expected by the block.

There is also no enforced line-coverage percentage.

vitest.config.ts contains a coverage block using the v8 provider, but @vitest/coverage-v8 is not installed. Until that changes, critical-path presence is enforced through lint:critical-tests; there is no percentage threshold to satisfy.

Running a subset

One file:

bun run vitest run lib/__tests__/refund-cap.test.ts

One directory:

bun run vitest run lib/__tests__

Watch mode:

bun run test

Integration tests:

bun run test:integration

When working on a specific area, run the relevant file or directory first. There is no reason to wait for the entire verification gate after every small change.

Before a pull request

The normal local gate is:

bun run verify

The checks are chained so that cheap failures appear before the more expensive ones.

The current sequence is:

  1. biome check .
  2. lint:discipline
  3. lint:cache
  4. lint:dead
  5. lint:flex
  6. lint:theme-boundaries
  7. lint:no-as-any
  8. lint:critical-tests
  9. lint:event-completeness
  10. lint:nested-soft-delete
  11. lint:public-feed-visibility
  12. lint:nested-hidden
  13. the 13-script lint:cleanliness aggregate
  14. vitest run
  15. typecheck

The important part for testing is that lint:critical-tests runs before Vitest. A missing test for a critical production file is therefore reported as a verification failure rather than being hidden inside the test run.

Parallel verification

When several things are broken, running the checks one after another can be unnecessarily slow.

bun run verify:parallel

scripts/verify.ts runs the same 15 legs with bounded concurrency. The default is cpus - 1; VERIFY_CONCURRENCY can override it.

Unlike verify, the parallel runner reports all failures it encounters instead of stopping at the first one. It also reports the time taken by each leg.

Hooks and CI

The same basic checks are enforced locally and in CI.

Lefthook runs the following before a commit:

lint:discipline
lint:critical-tests
bun run vitest run

Before a push it runs the full:

bun run verify

.github/workflows/verify.yml runs on every pull request and on pushes to main.

This keeps the checks that developers run locally aligned with the checks that gate changes in CI.

Database guarantee audit

The database audit is separate from verify because it requires a live database.

Run it with:

bun run db:guarantees:audit

This should be run against the database environment you want to inspect.

It is not included in the normal verification command because a database-dependent audit would make the otherwise database-free verification path depend on a running Postgres instance.

Where to put a new test

Use a unit test when the behavior can be exercised without infrastructure.

Use an integration test when the behavior depends on real Postgres behavior, transactions, constraints, or database state.

Use an invariant or contract test when the thing being protected is a rule that should remain true across a range of inputs rather than one particular example.

For money, pricing, tax, orders, inventory, checkout, and payment code, check whether the file falls under the critical-test paths. If it does, the production file itself needs to be imported by a test.

The useful question when adding a test is not just "what line am I trying to cover?" It is:

What behavior could break here, and what is the lowest layer at which I can actually prove that it has not?

On this page