Litestore · Developer guide
Testing

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:integration

The script runs:

vitest run --config vitest.integration.config.ts

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

If 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: false

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

  1. An order with no base-currency amount, even through raw SQL.
  2. An order whose refunded amount is larger than its total.
  3. Refunds in the payment ledger that add up to more than the order total.
  4. Approved refund requests that add up to more than the order total.
  5. A ledger row in a different currency from its order.
  6. 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.

Order #1042Paid $120.00
Refunded $80.00Requested $60.00
Write rejectedTotal refunded can't exceed the order total. Nothing was saved.
Postgres refuses a refund that would take back more than the order was paid.

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 allowNegative option 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.
  • minOrderAmount is 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 consulted

What 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 test

This runs Vitest in watch mode.

bun run vitest run

This 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?

On this page