Integration
This page shows you how to run the integration tests and decide when a new test needs a real database.
Most of Litestore's tests run against stubs. Three test files run against a real Postgres database instead, because they check what the database does, not what the application does. This page explains how to run them and when a new test belongs with them.
The three files are in test/integration/ and run with their own Vitest config.
Keeping them in a separate lane means the unit lane stays fast and needs no database. You shouldn't need Postgres on your machine to run tests in watch mode. But a test that proves a Postgres constraint means nothing without Postgres.
Running them
bun run test:integrationThe script runs:
vitest run --config vitest.integration.config.tsThe lane reads its database from TEST_DATABASE_URL, so it never points at your development database by accident.
You need the local Postgres from compose.yml. You also need to create the test database and run the migrations against it once. After that, this one command is all you run.
Two guards protect your development database
test/integration/setup.ts runs before each test file's imports. It sets the connection before Prisma is created, using this default:
postgresql://postgres:postgres@localhost:5432/litestore_testIf you set TEST_DATABASE_URL, it uses that instead, for both the pooled and the unpooled URL.
Before it does any of this, it checks the URL. If the URL doesn't match litestore_test, it throws and stops. The config also sets the same URL a second time, so no code that runs between loading the config and running the setup can see your development database URL.
The reason for this care is the next line in the file: every test starts from an empty database. Before each test, truncateAll runs a single TRUNCATE … RESTART IDENTITY CASCADE over every non-Prisma table in the public schema.
If that ran against your development data, it would wipe it. So the harness refuses to start unless the database name says it's the test database.
Every test empties the database
The integration lane truncates all tables before each test. It only runs against
a database whose URL matches litestore_test.
Files run one at a time
The config sets:
fileParallelism: falseThe suites share one database and empty it between tests. If two files ran at once, one would delete the other's rows in the middle of a check.
Each test gets 30 seconds instead of the default 10, because a real transaction against a real database takes longer than a stubbed call.
The files end in .test.ts on purpose. The critical-tests ratchet counts a source file as tested when a *.test.ts file imports it. An integration suite that covers a critical path has to be named like a test to count.
The three suites
db-guarantees.test.ts
This suite proves that the CHECK constraints and triggers really refuse the writes they are meant to refuse. Examples are a refund larger than the order, an edit to a ledger row, or a stock movement whose numbers don't add up.
It creates a paid order and then tries writes that the database must refuse. The writes fall into two groups.
The money checks try to write:
- An order with no base-currency amount, even through raw SQL.
- An order whose refunded amount is larger than its total.
- Refunds in the payment ledger that add up to more than the order total.
- Approved refund requests that add up to more than the order total.
- A ledger row in a different currency from its order.
- An approval for more than the stored refund request amount.
The stock checks try to write a movement that doesn't match the current inventory row. They also check the opposite case: the canonical stock writer is allowed through, because its inventory and ledger numbers agree.
That second check matters. Without it, a database that refused every write would pass the suite.
The checks only look at whether the write was refused, not which application function refused it. The suite uses raw SQL on purpose so the application code isn't involved at all.
inventory-transactional.test.ts
This suite proves that when several checkouts try to buy the last unit at the same time, the row lock lets them through one at a time.
It runs the transactional inventory code against real Postgres and checks these behaviors:
- The guarded decrement lowers stock correctly against a real row.
- When several buyers race for the last unit, exactly one wins and stock never goes below zero.
- The
allowNegativeoption lets an order that is already charged go through anyway. - The ledger is append-only, and each row's before and after amounts match its delta.
It also checks that the variant total only adds up active locations. And it checks that the default-location helper returns the active location with the highest priority, and only creates one when none exists.
A race condition is the clearest case of something a stub can't test. The test checks what two real transactions do to one real row.
price-resolution.test.ts
This suite proves that a variant gets the right price for a channel and currency, including when it has to fall back to another price.
It also pins down the rules the money code must always follow:
- A percent coupon on top of sale prices is calculated on the discounted total.
minOrderAmountis checked against the original subtotal.- An amount coupon never gives more than its maximum discount.
- A coupon that is used up, expired, not yet started or deactivated is rejected.
An unknown coupon code returns a message instead of throwing. Shoppers mistype coupon codes all the time, and that isn't an error.
Why none of these can be a unit test
A mocked Prisma client will accept a row that Postgres would refuse.
So a stubbed test of a database guarantee only proves that the stub agrees with itself:
test
↓
mocked client
↓
accepts the invalid row
↓
assertion passes
real database
↓
never consultedWhat you want to test is the database refusing the write. A test that never reaches the database is testing something else.
The unit lane
bun run testThis runs Vitest in watch mode.
bun run vitest runThis runs the unit suite once. bun run verify and the pre-commit hook use this command.
The unit lane points server-only at test/server-only-stub.ts, so tests can import server modules. It also leaves out **/.claude/**.
The integration config uses the same aliases for the same reason. In both lanes, the tests run the real server code.
Contract tests are not implementation tests
scripts/check-critical-tests.ts keeps a ratchet over the critical paths. If a
test fails because a guarantee changed, it is telling you that the guarantee
changed. Deleting the test to make the build pass removes the warning, not the
problem.
When a new test belongs here
Put a test in the integration lane when it proves something the database does. That includes a constraint, a trigger, a transaction boundary, or two writers racing for the same row.
Keep it in the unit lane when it tests the application's own logic and a stub can't give a false result. That includes a calculation, a mapping, or a branch that depends on the data you pass in.
The integration lane has a real cost. It needs infrastructure, it runs one file at a time, and it needs a 30-second timeout. That cost is worth it for a database guarantee and wasted on a pure function.
Ask yourself:
Could a mocked client pass this test while production is broken?
Related
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.
Guarantees
Learn which money, refund, stock, identity, visibility and cache promises Postgres enforces, which ones a harness check enforces, and which ones are explicitly not guaranteed.
Harness
Learn about the 25 CI checks that run on your fork, and the 8-guide rule set behind them.