zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Build an application

TestingSupported

zmdb is unusually easy to test, for two structural reasons: compile() produces SQL without a connection, and Driver has one required method. Most of what you would need a database for, you do not.

Test the SQL, with no database#

import { expect, it } from 'vitest';
import { createQueryCompiler, trustedTable } from '@zmdb/sql';
import { postgres } from '@zmdb/postgres';

it('filters by email', () => {
  const q = createQueryCompiler(postgres).selectFrom(trustedTable('users')).where('email', '=', 'a@b.c').compile();
  expect(q).toEqual({ effects: { operation: 'SELECT', requiresPrimary: false, returnsRows: true }, text: 'SELECT * FROM "users" WHERE "email" = $1', parameters: ['a@b.c'] });
});

Microseconds, no setup. Assert on the whole CompiledQuery rather than a substring — a text match will pass while the parameters are wrong.

Fake the driver#

import { type Driver } from '@zmdb/orm';
import { postgres } from '@zmdb/postgres';

const driver: Driver = { dialect: postgres, execute: async () => [{ id: 1, email: 'a@b.c', active: true }] };
const repo = defineRepository(users, driver);

it('finds a user', async () => {
  expect(await repo.findById(1)).toEqual({ id: 1, email: 'a@b.c', active: true });
});

Assert on the queries issued#

This is the test that catches N+1s, and it is worth writing for any hot path:

function recording(inner: Driver) {
  const seen: CompiledQuery[] = [];
  return {
    driver: {
      ...inner,
      execute: (q, options) => (seen.push(q), inner.execute(q, options)),
    } satisfies Driver,
    seen,
  };
}

it('populate issues two queries, not one per row', async () => {
  const { driver, seen } = recording(realDriver);
  await defineRepository(users, driver, { relations }).findAll({ populate: ['posts'] });
  expect(seen).toHaveLength(2);
});

A regression here is invisible in a functional test — the results stay correct, the query count quietly triples.

A real database, in memory#

node:sqlite is a built-in, so this needs no dependency:

import { DatabaseSync } from 'node:sqlite';
import { diff, snapshot } from '@zmdb/core/migrations';
import { sqlite } from '@zmdb/core/sqlite';

export function freshDb() {
  const db = new DatabaseSync(':memory:');
  db.exec('PRAGMA foreign_keys = ON');
  for (const op of diff({ tables: {} }, snapshot(allSchemas), { dialect: sqlite })) {
    db.exec(sqlite.migrations.emitUp(op));
  }
  return db;
}

let db: DatabaseSync;
beforeEach(() => {
  db = freshDb();
});

The schema comes from your actual schema objects, so it cannot drift from a fixture. Sub-millisecond, isolated per test.

⚠️ Warning

Testing on SQLite while deploying on Postgres leaves ILIKE, RETURNING, ON CONFLICT, JSON operators, transactional DDL and case sensitivity untested. Use PGlite for tests that touch those, and a real Postgres for the integration suite.

Fixtures from the schema#

import { seedRows } from '@zmdb/orm/seeding';

const [row] = seedRows(users, { count: 1, seed: 1 });
if (row === undefined) throw new Error('seedRows returned nothing');
await repo.create(row); // already a CreateDTO — no id to strip

Adding a column does not break every test that built a row by hand. Use a distinct seed per test so one test's data cannot make another pass. See Seed Functions.

HTTP handlers#

import { bodyText } from '@zmdb/web';
import { createTestApp } from '@zmdb/web/testing';

const app = createTestApp(AppModule, {
  overrides: [{ token: DRIVER, useValue: fakeDriver }],
});
await app.init();

it('GET /users', async () => {
  const res = await app.request({ method: 'GET', path: '/users', headers: {} });
  expect(res.status).toBe(200);
  expect(JSON.parse(await bodyText(res))).toHaveLength(1);
});

No port, no server, no supertest. app.get(token) reaches a provider if you need to assert on it directly. See Testing Applications.

The one test every project needs#

If the transformer is not running, is<T>() and assert<T>() silently accept everything. That is a validation layer that reports success and checks nothing, and it fails open:

it('the AOT transformer is running', () => {
  expect(is<{ id: number }>({ id: 'not a number' })).toBe(false);
});

Put it in its own file, at the top of the suite. See AOT Setup.

Type-level tests#

Behaviour that only exists in the type system needs a type-level test, in a *.type-test.ts file:

import type { Expect, Equal } from './type-utils.js';

type _1 = Expect<Equal<CreateDTO<User>, { email: string; active?: boolean }>>;

These fail at tsc, not at runtime, which is where the guarantee lives. This is how the project's own DTO derivation is tested.

Migrations#

it('migrations round-trip', async () => {
  await up(conn, migrations);
  await down(conn, migrations);
  await up(conn, migrations);
});

Catches the two common defects: a down that does not undo, and an up that is not re-runnable after one.

---

See also: Testing Applications · AOT Setup · PGlite