Testing ​
The test helpers are a separate entry point:
import { createSandbox, expectQueries } from "egon-orm/testing";The sandbox ​
The sandbox runs each test inside a transaction that is rolled back afterwards. Tests see their own writes, nothing is committed, and there is no cleanup to write.
import { afterEach, beforeEach, expect, test } from "vitest";
import { loadFeed, register, repo, User, userId } from "./testing-app.js";const sandbox = await createSandbox(repo);
beforeEach(() => sandbox.begin());
afterEach(() => sandbox.rollback());
test("registers a user", async () => {
await register({ email: "ada@example.com" }); // application code using `repo`
expect(await repo.count(User)).toBe(1);
});createSandbox(repo)takes the repocreateRepomade, the one your application imports.sandbox.begin()takes one connection, opens a transaction, and routes every operation ofrepothrough it, including code that imports the repo itself. It returns a repo bound to that transaction, liketxin a transaction callback.sandbox.rollback()undoes everything and gives the repo its pool back. It does nothing when the sandbox has not begun, so it is safe inafterEachafter a failedbeforeEach.begin()twice without a rollback is aQueryError.repo.transactioninside a test becomes a savepoint, so code that uses transactions works unchanged. Itsisolation,readOnlyandretriesare left out: only a real transaction can have them. Two of them started together take turns, and a statement made on the repo meanwhile (by a concurrent request, not from inside the callback) waits for the open one to end, so that its rollback cannot undo the statement.- A write the database refuses undoes only itself, as it would outside the sandbox: each statement made on the repo itself runs in a savepoint of its own (two more statements per query, in tests only). Inside
repo.transactionthe usual rules apply.
test("refuses a second account with the same email", async () => {
await register({ email: "ada@example.com" });
const second = await register({ email: "ada@example.com" }); // the database refuses it
expect(second.success).toBe(false);
expect(await repo.count(User)).toBe(1); // and the test carries on
});Parallelism ​
Vitest runs each test file in its own worker. Each file therefore has its own sandbox and connection: files run in parallel over one database without seeing each other's data, and the tests within one file run one after another. Two files that write the same unique value at the same time both succeed: the second one waits until the first one's test rolls back.
Limits ​
- Code that opens its own connection or pool (another repo, another adapter) is outside the sandbox.
- A test cannot observe a real commit. Behaviour that depends on one, such as two connections seeing each other's work, needs a test without the sandbox (and its own cleanup).
- The repo used inside one of its own
repo.transactioncallbacks (a statement, anotherrepo.transaction, a bulk insert, a stream) is aQueryErrorin the sandbox. In production such a call runs on another connection, outside the transaction (usually a mistake: passtx); the sandbox has one connection, where the call could only run inside the transaction or wait for it forever. Code that commits on purpose outside a transaction it is in needs a test without the sandbox. - Locks are held until the rollback: a statement on another connection that needs a row or a table the test wrote (
DROP,ALTER,TRUNCATE) waits for the test to end. repo.inTransactionstaysfalseandrepo.rollback()stays an error, as in production.
Counting queries ​
expectQueries(repo, fn) runs fn and returns the query events it caused, those of its transactions included. Use it to pin down the number of queries, so an N+1 cannot creep in:
test("loads the feed in two queries", async () => {
const queries = await expectQueries(repo, () => loadFeed(userId));
expect(queries).toHaveLength(2);
});Each event has sql, operation, source and durationMs. The statements that begin and end a transaction are counted too, with operation: "transaction" (in the sandbox they are SAVEPOINT and RELEASE); the sandbox's own statements are not. To count only the queries, filter them out: queries.filter((q) => q.operation !== "transaction").
A database for tests ​
- SQLite in memory needs no service and is the fastest:
sqlite({ filename: ":memory:" }). Check Dialect support for features your queries use. - The same database as production, in Docker, is the most faithful. Start it once, for example with Docker Compose and
docker compose up -d --wait, pointDATABASE_URLat it, and create the tables once before the run (a VitestglobalSetupthat runs your migrations). The sandbox keeps them empty between tests.
Test data ​
There is no factory helper. A plain function is enough, and insertOrThrow makes a bad fixture fail loudly:
export const insertUser = (attrs: Partial<InsertShape<typeof User>> = {}) =>
repo.insertOrThrow(change(User, { email: `user${Math.random()}@example.com`, ...attrs }));Good to know
- Nothing is committed. Each test runs in a transaction that is rolled back; there is no cleanup to write.
- Your application's repo is the one under test. The sandbox routes it through the test's transaction, so code that imports the repo itself works unchanged.
- Code that opens its own connection is outside the sandbox.
See also ​
- Transactions
- The tutorial's tests.

