Testing
Philosophy: If the Schema Defines the Contract, TypoKit Can Test It
Section titled “Philosophy: If the Schema Defines the Contract, TypoKit Can Test It”TypoKit treats tests as a first-class output of the schema, not an afterthought. Since your type definitions already encode validation rules, required fields, and response shapes, TypoKit generates contract tests automatically — the same way it generates validators, database schemas, and OpenAPI specs.
This means:
- Contract tests are free — Every route gets validation coverage the moment you define it.
- Factories respect constraints — Test data generators honor
@format,@minLength,@maximum, and other JSDoc tags automatically. - Integration tests are isolated — Each test gets a fresh server and in-memory database, with no shared mutable state.
Auto-Generated Contract Tests
Section titled “Auto-Generated Contract Tests”When you run typokit generate:tests (or it runs automatically before typokit test), TypoKit inspects your route contracts and generates one test file per route group:
__generated__/ users.contract.test.ts posts.contract.test.ts auth.contract.test.tsRoutes are grouped by their first path segment — all /users/* routes go into users.contract.test.ts.
What Gets Generated
Section titled “What Gets Generated”Each route produces three levels of test coverage:
- Valid input — Sends a well-formed request and expects the configured success status (default 200).
- Missing required fields — Omits each required field one at a time and expects 400.
- Invalid field formats — Sends malformed values (bad emails, invalid UUIDs, out-of-range numbers, invalid enum values) and expects 400.
Example Generated Test
Section titled “Example Generated Test”// Auto-generated from route schemas — do not editimport { describe, it, expect, beforeAll, afterAll } from "vitest";import { createTestClient } from "@typokit/testing";import { app } from "../src/app";
let client;
beforeAll(async () => { client = await createTestClient(app);});
afterAll(async () => { await client.close();});
describe("POST /users", () => { it("accepts valid request", async () => { const res = await client.post("/users", { body: { name: "Jane Doe", email: "jane@example.com", role: "member", }, }); expect(res.status).toBe(201); });
it("rejects missing required field: name", async () => { const res = await client.post("/users", { body: { email: "jane@example.com", role: "member" }, }); expect(res.status).toBe(400); });
it("rejects missing required field: email", async () => { const res = await client.post("/users", { body: { name: "Jane Doe", role: "member" }, }); expect(res.status).toBe(400); });
it("rejects invalid email format", async () => { const res = await client.post("/users", { body: { name: "Jane Doe", email: "not-an-email", role: "member" }, }); expect(res.status).toBe(400); });});
describe("GET /users/:id", () => { it("accepts valid request", async () => { const res = await client.get("/users/550e8400-e29b-41d4-a716-446655440000"); expect(res.status).toBe(200); });});Smart Regeneration
Section titled “Smart Regeneration”TypoKit tracks schema hashes in .typokit/build-cache.json. When you run typokit test, it checks whether schemas have changed since the last generation and auto-runs generate:tests if needed. You never have to remember to regenerate.
The Test Client
Section titled “The Test Client”The test client provides a typed HTTP interface for making requests against your app in tests.
createTestClient(app)
Section titled “createTestClient(app)”Starts the app on a random available port and returns a lightweight HTTP client:
import { createTestClient } from "@typokit/testing";import { app } from "../src/app";
const client = await createTestClient(app);
// Simple GETconst res = await client.get("/users/123");console.log(res.status); // 200console.log(res.body); // { id: "123", name: "Jane Doe", ... }
// POST with body, query params, and headersconst res = await client.post("/users", { body: { name: "Jane Doe", email: "jane@example.com" }, query: { notify: "true" }, headers: { "X-Request-Id": "test-123" },});
// Always clean upawait client.close();Available methods: .get(), .post(), .put(), .patch(), .delete().
request<TContract>()
Section titled “request<TContract>()”For full type safety, use the contract-based request helper:
import { request } from "@typokit/testing";import { createUserContract } from "../src/routes/users/contracts";
const res = await request<typeof createUserContract>(client, { body: { name: "Jane Doe", email: "jane@example.com" },});// res.body is typed as the contract's TResponseTest Response Shape
Section titled “Test Response Shape”Every response from the test client has this shape:
interface TestResponse<T> { status: number; body: T; headers: Record<string, string>;}Integration Suite
Section titled “Integration Suite”For tests that need a real database, createIntegrationSuite manages the full lifecycle — server, database, and seed data — with complete isolation between tests.
createIntegrationSuite(app, options)
Section titled “createIntegrationSuite(app, options)”import { createIntegrationSuite } from "@typokit/testing";import { app } from "../src/app";
const suite = await createIntegrationSuite(app, { database: true, // spin up in-memory database seed: "standard", // apply named seed fixtures});
// suite.client — same test client API as above// suite.db — in-memory database handleIn-Memory Database API
Section titled “In-Memory Database API”When database: true, the suite provides a database handle for direct data inspection:
// Insert test data directlyawait suite.db.insert("users", { id: "test-1", name: "Jane Doe", email: "jane@example.com",});
// Query dataconst users = await suite.db.findAll("users");const user = await suite.db.findById("users", "test-1");
// List available tablesconst tables = await suite.db.tables();
// Clear a single table or all tablesawait suite.db.clearTable("users");await suite.db.clear();Seed Fixtures
Section titled “Seed Fixtures”Register reusable seed data sets that can be applied by name:
import { registerSeed } from "@typokit/testing";
registerSeed("standard", async (db) => { await db.insert("users", { id: "u1", name: "Admin", email: "admin@example.com" }); await db.insert("users", { id: "u2", name: "Member", email: "member@example.com" }); await db.insert("posts", { id: "p1", title: "Hello World", authorId: "u1" });});Then reference it in your suite:
const suite = await createIntegrationSuite(app, { database: true, seed: "standard", // applies the registered seed});Test Factories
Section titled “Test Factories”Test factories generate realistic, schema-compliant data from your type metadata. They respect all JSDoc constraints automatically.
createFactory<T>(metadata)
Section titled “createFactory<T>(metadata)”import { createFactory } from "@typokit/testing";import { UserMetadata } from "../.typokit/schemas/User";
const userFactory = createFactory<User>(UserMetadata);
// Build a single valid instanceconst user = userFactory.build();// { id: "550e8400-...", name: "aBcDeFgH", email: "test_a1b2@example.com", ... }
// Build with overridesconst admin = userFactory.build({ role: "admin", name: "Admin User" });
// Build multiple instancesconst users = userFactory.buildMany(10);Invalid Variants
Section titled “Invalid Variants”The buildInvalid method generates an instance where exactly one field has an invalid value — perfect for testing validation:
// Invalid email formatconst badEmail = userFactory.buildInvalid("email");// { id: "550e8400-...", name: "aBcDeFgH", email: "not-an-email", ... }
// Invalid enum valueconst badRole = userFactory.buildInvalid("role");// { id: "550e8400-...", name: "aBcDeFgH", email: "test@example.com", role: "INVALID_ENUM", ... }How Factories Respect Constraints
Section titled “How Factories Respect Constraints”Factories read JSDoc tags from the type metadata and generate values accordingly:
| JSDoc Tag | Factory Behavior |
|---|---|
@format email | Generates test_{seed}@example.com |
@format uuid | Generates valid UUIDv4 |
@format date | Generates ISO date string |
@format url | Generates https://example.com/{seed} |
@minLength N / @maxLength N | Constrains string length |
@minimum N / @maximum N | Constrains numeric range |
@enum | Picks from valid enum values |
@id @generated | Generates auto-increment or UUID based on format |
Schema Matcher
Section titled “Schema Matcher”TypoKit provides a custom test matcher for validating responses against compiled schemas:
import { registerSchemaValidators, toMatchSchema } from "@typokit/testing";import { validators } from "../.typokit/validators";
// Register once in your test setupregisterSchemaValidators(validators);
// Use in tests (works with Jest, Vitest, and Rstest)expect(res.body).toMatchSchema("User");expect(res.body).toMatchSchema("CreateUserInput");When validation fails, the matcher provides path-based error messages:
Expected value to match schema "User": - $.email: expected string with format "email", received "not-an-email" - $.age: expected number >= 0, received -1CLI Test Commands
Section titled “CLI Test Commands”TypoKit provides three test commands that auto-detect your test runner (Vitest, Jest, or Rstest):
typokit test
Section titled “typokit test”Runs all tests — both generated contract tests and your manual tests:
typokit testThis command:
- Checks if schemas have changed since last generation
- Auto-runs
generate:testsif needed - Detects your test runner from config files (
vitest.config.*,jest.config.*,rstest.config.*) - Runs the full test suite
typokit test:contracts
Section titled “typokit test:contracts”Runs only auto-generated contract tests:
typokit test:contractsFilters to __generated__/**/*.contract.test.ts files. Useful for quick validation that your routes match their schemas.
typokit test:integration
Section titled “typokit test:integration”Runs only integration tests:
typokit test:integrationFilters to tests/integration/** files. These are your hand-written tests that exercise business logic with real database access.
Common Options
Section titled “Common Options”# Force a specific test runnertypokit test --runner vitest
# Verbose outputtypokit test --verbose
# Run a specific test filetypokit test users.contract.test.tsCI Consistency Guarantees
Section titled “CI Consistency Guarantees”TypoKit’s testing system is designed for reliable CI from day one:
Idempotent Generation
Section titled “Idempotent Generation”The same schema always generates identical test code. There is no randomness, no timestamps, and no environment-dependent values in generated tests. If you regenerate and git diff shows no changes, your tests are in sync.
Isolated Tests
Section titled “Isolated Tests”- Contract tests spin up a fresh server per file via
createTestClient - Integration tests get an independent server + in-memory database via
createIntegrationSuite - No shared mutable state between tests by default
- Database tests use transaction rollback per test for clean isolation
Flaky Test Detection
Section titled “Flaky Test Detection”When TypoKit detects a test that passes and fails inconsistently across 3 runs, it flags it as flaky. Flaky tests are reported separately in the output so they don’t block your CI pipeline while you investigate.
Deterministic Factories
Section titled “Deterministic Factories”Factory data is generated from a seeded PRNG (Mulberry32), so the same factory call always produces the same values. No Math.random(), no Date.now() — just predictable, reproducible test data.
Putting It All Together
Section titled “Putting It All Together”Here’s a typical test workflow for a TypoKit app:
import { describe, it, expect, beforeAll, afterAll } from "vitest";import { createIntegrationSuite, createFactory } from "@typokit/testing";import { app } from "../../src/app";import { UserMetadata } from "../../.typokit/schemas/User";
const userFactory = createFactory(UserMetadata);
describe("User management", () => { let suite;
beforeAll(async () => { suite = await createIntegrationSuite(app, { database: true, seed: "standard", }); });
afterAll(async () => { await suite.client.close(); });
it("creates a user with valid data", async () => { const input = userFactory.build({ role: "member" }); const res = await suite.client.post("/users", { body: input });
expect(res.status).toBe(201); expect(res.body.email).toBe(input.email);
// Verify in database const dbUser = await suite.db.findById("users", res.body.id); expect(dbUser).toBeDefined(); expect(dbUser.name).toBe(input.name); });
it("rejects duplicate email", async () => { const input = userFactory.build();
await suite.client.post("/users", { body: input }); const res = await suite.client.post("/users", { body: input });
expect(res.status).toBe(409); });});Next Steps
Section titled “Next Steps”- Building Your First API — hands-on tutorial that includes writing tests
- Error Handling — how validation errors propagate to test responses
- CLI Reference: Test Commands — full command options and flags