Testing
Activate an isolated database, test portable and custom operations, verify migrations, and avoid cached-connection and environment surprises.
Verify queries, constraints, and rollback against an isolated database with your actual schema. Portable method mocks are useful for narrow business tests, but they do not prove constraints, query semantics, rollback, or driver behavior. Never point a test at a production connection or an existing developer database by default.
For application HTTP fixtures, use createTestApplication from @relkit/testing/drizzle after
adding a database. Update existing fixtures too, including tests of routes that do not query data:
the fixture loads the whole application’s services. When the app also declares Better Auth, use
@relkit/testing/better-auth instead. The generic @relkit/testing application helper deliberately
does not acquire either native integration. See the updated Hello fixture.
Activate and close a service explicitly
activateDrizzleService(service, env) returns { client, context, close }. client is the native Drizzle
client, context is the portable database API, and close() invokes the service’s disposal hook.
Direct activation takes resolved environment values; it does not parse your environment contract for you.
The executable data-model test uses SQLite in memory and applies the generated Drizzle migration to that same connection before making queries. The application service does not create tables:
Run it from the example or application root:
bun test tests/data-model.test.ts
It checks generated validation, all six CRUD operations, custom methods, override scope, composite
selectors, pagination limits, and rollback. It also invokes the actual member-registration and email-change
functions with an injected database context, testing both their generated schemas and public input
validation. finally closes the database even when an assertion fails.
Isolate activation lifetimes
Repeated activation of the same descriptor object shares one cached activation. Changing the environment on a later call does not create a different database, and closing it does not currently remove the cache. Use one shared lifetime deliberately, a fresh descriptor for each independent test, or process isolation. Do not close the shared descriptor after one test and expect a second activation to reopen it.
An in-memory SQLite database belongs to its connection, not to the whole test suite or development server. A test that inserts a row does not seed a separately running application. For persistence and restart tests, use an explicit temporary file and close all connections before cleaning up that test-owned path.
Test migrations separately
The migration regression test launches Drizzle Kit twice against a temporary file and inserts rows through Drizzle between runs. A successful repeat verifies that the initial migration is not applied again and that the existing rows remain:
/** Runs native Drizzle migrations in an owned SQLite fixture, checking invalid targets and row preservation across repeat migration. */
import { expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { users, memberships } from "@app/services/database/schema/index.js";
test("rejects empty and in-memory migration targets", async () => {
for (const path of ["", ":memory:"]) {
const child = Bun.spawn([process.execPath, "drizzle.config.ts"], {
cwd: resolve(import.meta.dir, ".."),
env: { ...process.env, DATABASE_PATH: path },
stdout: "pipe",
stderr: "pipe",
});
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
expect(code).not.toBe(0);
expect(stderr).toContain("Set DATABASE_PATH to the persistent SQLite file");
}
});
// The finite harness allowance includes two native Drizzle CLI cold starts.
test("applies the initial migration once and keeps rows on a second run", async () => {
const root = await mkdtemp(join(tmpdir(), "relkit-database-migrations-"));
const path = join(root, "test.sqlite");
try {
for (let run = 0; run < 2; run++) {
const child = Bun.spawn([process.execPath, "run", "db:migrate"], {
cwd: resolve(import.meta.dir, ".."),
env: { ...process.env, DATABASE_PATH: path },
stdout: "pipe",
stderr: "pipe",
});
const [code, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
]);
if (code !== 0) throw new Error(`Migration failed: ${stdout}\n${stderr}`);
expect(stdout).toContain("Database migrations applied.");
const sqlite = new Database(path);
try {
const database = drizzle({ client: sqlite });
if (run === 0) {
const [user] = await database
.insert(users)
.values({ email: "one@example.com" })
.returning();
if (user === undefined) throw new Error("User insert did not return a row");
await database.insert(memberships).values({
organizationId: "org",
userId: user.id,
role: "owner",
});
}
expect(await database.select().from(users)).toMatchObject([
{ email: "one@example.com", active: true },
]);
expect(await database.select().from(memberships)).toMatchObject([{ role: "owner" }]);
} finally {
sqlite.close();
}
}
} finally {
await rm(root, { recursive: true, force: true });
}
}, 30_000);
bun test tests/migrations.test.ts
Add upgrade fixtures for existing data, constraints, and backfills as migrations accumulate. A bootstrap test on an empty database is not a replacement for testing upgrades from deployed schema versions.
Choose the right application harness
createTestApplication discovers the Drizzle service and activates its real client factory with the test
environment. Set an isolated temporary path explicitly, migrate that file before starting the harness,
and close the application afterward. The environment name alone does not make a custom database factory safe.
For an in-memory database, migrations must run on the harness’s actual connection; a separate process
or another in-memory connection cannot initialize it. Direct activation as shown above gives the test
control over that client.
A standalone invokeFunction test does not automatically discover and activate the app’s Drizzle service.
Provide a database context through its context hook, or use the application harness when you need service
discovery and HTTP together. See Testing and the Testing API.
Run additional contract tests on the production dialect for transaction isolation, concurrent writes, native model methods, and MySQL row recovery. The repository-backed database tutorial tests SQLite; it does not claim PostgreSQL or MySQL behavior was exercised by those tests.