Testing
TypeGraph’s in-memory SQLite backend makes tests fast and isolated — each test gets a fresh database with zero setup cost. This guide covers test utilities, common patterns, and strategies for testing at different levels.
Test Setup
Section titled “Test Setup”In-memory backend (recommended)
Section titled “In-memory backend (recommended)”createLocalSqliteBackend() creates an in-memory SQLite database with TypeGraph tables
pre-configured. Each call returns a completely isolated database.
import { beforeEach, describe, expect, it } from "vitest";import { createLocalSqliteBackend } from "@nicia-ai/typegraph/adapters/drizzle/sqlite/local";import { createStore } from "@nicia-ai/typegraph";import { graph } from "../src/graph"; // your graph definition
describe("Person queries", () => { let store: ReturnType<typeof createStore<typeof graph>>;
beforeEach(() => { const { backend } = createLocalSqliteBackend(); store = createStore(graph, backend); });
it("creates and retrieves a person", async () => { const alice = await store.nodes.Person.create({ name: "Alice", email: "alice@example.com", });
const found = await store.nodes.Person.getById(alice.id); expect(found?.props.name).toBe("Alice"); });});No teardown is needed — the in-memory database is garbage collected when the backend goes out of scope.
Shared test helper
Section titled “Shared test helper”If many test files use the same setup, extract a helper:
import { createLocalSqliteBackend } from "@nicia-ai/typegraph/adapters/drizzle/sqlite/local";import { createStore } from "@nicia-ai/typegraph";import { graph } from "../src/graph";
export function createTestStore() { const { backend } = createLocalSqliteBackend(); return createStore(graph, backend);}import { beforeEach, describe, expect, it } from "vitest";import { createTestStore } from "./test-helpers";
describe("Person", () => { let store: ReturnType<typeof createTestStore>;
beforeEach(() => { store = createTestStore(); });
// tests...});createStore vs createStoreWithSchema
Section titled “createStore vs createStoreWithSchema”| Factory | Sync? | Schema management | Use for |
|---|---|---|---|
createStore(graph, backend) |
Yes | None | Tests, local dev (no searchable() fields) |
createStoreWithSchema(graph, backend) |
No | Auto-init, auto-migrate | Production, fulltext, schema evolution tests |
Use createStore for most tests — it’s synchronous and avoids async setup. Use
createStoreWithSchema when you’re specifically testing schema migrations or
evolution, or whenever the graph under test has searchable() fields: a
fulltext write/search through a bare createStore() test throws
StoreNotInitializedError because the durable fulltext marker was never
written.
import { createStoreWithSchema } from "@nicia-ai/typegraph";
it("migrates from v1 to v2", async () => { const { backend } = createLocalSqliteBackend();
// Initialize with v1 schema const [storeV1] = await createStoreWithSchema(graphV1, backend); await storeV1.nodes.Person.create({ name: "Alice" });
// Migrate to v2 schema const [storeV2, result] = await createStoreWithSchema(graphV2, backend); expect(result.status).toBe("migrated");});Testing Queries
Section titled “Testing Queries”Seed data, then query
Section titled “Seed data, then query”The typical pattern is: create data through the collection API, then verify queries return the expected results.
it("finds friends-of-friends", async () => { // Seed const alice = await store.nodes.Person.create({ name: "Alice" }); const bob = await store.nodes.Person.create({ name: "Bob" }); const carol = await store.nodes.Person.create({ name: "Carol" });
await store.edges.knows.create(alice, bob, {}); await store.edges.knows.create(bob, carol, {});
// Query const fof = await store .query() .from("Person", "p") .whereNode("p", (p) => p.id.eq(alice.id)) .traverse("knows", "e") .recursive({ minHops: 2, maxHops: 2 }) .to("Person", "friend") .select((ctx) => ctx.friend.name) .execute();
expect(fof).toEqual(["Carol"]);});Bulk seeding
Section titled “Bulk seeding”For tests that need a larger dataset, use bulkCreate for speed:
beforeEach(async () => { const people = Array.from({ length: 100 }, (_, i) => ({ props: { name: `Person ${i}`, email: `person${i}@example.com` }, })); await store.nodes.Person.bulkCreate(people);});Testing query shapes with toSQL()
Section titled “Testing query shapes with toSQL()”You can inspect the generated SQL without executing to verify query structure:
it("compiles a traversal to a single statement", () => { const query = store .query() .from("Person", "p") .traverse("worksAt", "e") .to("Company", "c") .select((ctx) => ({ person: ctx.p.name, company: ctx.c.name }));
const { sql } = query.toSQL(); expect(sql).toContain("WITH"); expect(sql).not.toContain(";"); // single statement});Testing prepared queries
Section titled “Testing prepared queries”it("executes prepared queries with different bindings", async () => { await store.nodes.Person.create({ name: "Alice" }); await store.nodes.Person.create({ name: "Bob" });
const prepared = store .query() .from("Person", "p") .whereNode("p", (p) => p.name.eq(p.name.bind("targetName"))) .select((ctx) => ctx.p.name) .prepare();
const alice = await prepared.execute({ targetName: "Alice" }); const bob = await prepared.execute({ targetName: "Bob" });
expect(alice).toEqual(["Alice"]); expect(bob).toEqual(["Bob"]);});Testing Transactions
Section titled “Testing Transactions”Verify atomicity by asserting that failed transactions leave no partial data:
it("rolls back on error", async () => { try { await store.transaction(async (tx) => { await tx.nodes.Person.create({ name: "Alice" }); throw new Error("abort"); }); } catch { // expected }
const all = await store .query() .from("Person", "p") .select((ctx) => ctx.p) .execute();
expect(all).toHaveLength(0); // Alice was rolled back});Testing with the Query Profiler
Section titled “Testing with the Query Profiler”Use the Query Profiler in tests to catch unindexed filter patterns before they reach production.
import { QueryProfiler } from "@nicia-ai/typegraph/profiler";import { toDeclaredIndexes } from "@nicia-ai/typegraph/indexes";import { personEmail } from "../src/indexes";
describe("Index coverage", () => { it("all query filters have index coverage", async () => { const profiler = new QueryProfiler({ declaredIndexes: toDeclaredIndexes([personEmail]), }); const profiledStore = profiler.attachToStore(store);
// Run representative queries await profiledStore .query() .from("Person", "p") .whereNode("p", (p) => p.email.eq("alice@example.com")) .select((ctx) => ctx.p.name) .execute();
// Fails if any filter property lacks an index profiler.assertIndexCoverage(); });});This is particularly effective when run against your full test suite — it catches filter patterns across all tests, not just the ones you remember to check manually.
PGlite Tests (Postgres dialect, no Docker)
Section titled “PGlite Tests (Postgres dialect, no Docker)”Use createLocalPgliteBackend() when you want the PostgreSQL dialect and
pgvector path in ordinary test runs without starting Docker. It creates an
in-process PGlite engine, runs TypeGraph DDL, and returns a backend that should
be closed after each test.
import { afterEach, beforeEach, describe, expect, it } from "vitest";import { createLocalPgliteBackend } from "@nicia-ai/typegraph/adapters/drizzle/postgres/pglite";import { createStore } from "@nicia-ai/typegraph";import { graph } from "../src/graph";
describe("Postgres dialect behavior", () => { let cleanup: (() => Promise<void>) | undefined; let store: ReturnType<typeof createStore<typeof graph>>;
beforeEach(async () => { const { backend } = await createLocalPgliteBackend(); cleanup = () => backend.close(); store = createStore(graph, backend); });
afterEach(async () => { await cleanup?.(); });
it("runs against the Postgres query compiler", async () => { const alice = await store.nodes.Person.create({ name: "Alice" }); expect(await store.nodes.Person.getById(alice.id)).toBeDefined(); });});PGlite is a good fit for SQL dialect coverage, pgvector behavior, and embedded Postgres workflows. Use a real PostgreSQL server for driver-specific behavior such as node-postgres named statements, postgres-js, pgbouncer, connection pool behavior, isolation levels, and true concurrent sessions.
PostgreSQL Integration Tests
Section titled “PostgreSQL Integration Tests”For tests that verify PostgreSQL-specific behavior (JSONB operators, GIN indexes, concurrent writes), connect to a real database:
import { Pool } from "pg";import { drizzle } from "drizzle-orm/node-postgres";import { createPostgresBackend, generatePostgresMigrationSQL } from "@nicia-ai/typegraph/adapters/drizzle/postgres";
describe("PostgreSQL integration", () => { let pool: Pool; let store: ReturnType<typeof createStore<typeof graph>>;
beforeAll(async () => { pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL }); await pool.query(generatePostgresMigrationSQL()); const db = drizzle(pool); const backend = createPostgresBackend(db); store = createStore(graph, backend); });
afterAll(async () => { await pool.end(); });
beforeEach(async () => { await pool.query("TRUNCATE typegraph_nodes, typegraph_edges CASCADE"); });
it("handles concurrent writes", async () => { const creates = Array.from({ length: 100 }, (_, i) => store.nodes.Person.create({ name: `Person ${i}` }), ); await Promise.all(creates);
const count = await store.nodes.Person.count(); expect(count).toBe(100); });});Skipping when no database is available
Section titled “Skipping when no database is available”Guard PostgreSQL tests so they’re skipped in environments without a database:
const describePostgres = process.env.TEST_DATABASE_URL ? describe : describe.skip;
describePostgres("PostgreSQL-specific", () => { // ...});Testing Pyramid
Section titled “Testing Pyramid”| Level | Backend | Speed | Isolation | When to use |
|---|---|---|---|---|
| Unit | In-memory SQLite | Fast (~1ms setup) | Full (fresh DB per test) | Collection API, query logic, business rules |
| Integration | PGlite | Fast-medium | Full (fresh DB per test) | Postgres SQL dialect, pgvector, embedded workflows |
| Integration | SQLite file or real PostgreSQL | Medium | Shared (truncate between tests) | Concurrency, driver behavior, isolation levels |
| Profiler | In-memory SQLite | Fast | Full | Index coverage, query pattern verification |
Most tests should be unit tests with in-memory SQLite. Use PGlite when you need the Postgres dialect or pgvector path in normal test runs. Reserve real PostgreSQL integration tests for behavior PGlite cannot model, such as connection pooling, driver quirks, concurrent sessions, and isolation levels.
Next Steps
Section titled “Next Steps”- Backend Setup — Configure SQLite and PostgreSQL backends
- Query Profiler — Automatic index recommendations
- Schemas & Stores — Collection API reference