Testing

Test the whole app in-process.

@apex-stack/core/testing boots an app's REST and MCP surface in the same process — no HTTP server, no ports, no mocks. You assert against the real handlers, the real auth policy, and the real database, so a passing test means the AI surface works too.

Boot the app #

createTestApp loads your routes, resources, and auth resolver and returns a client that talks to them directly — in memory, in the same process as your test. Point it at your project root (it discovers server/ for you) or pass explicit entries:

import { createTestApp } from '@apex-stack/core/testing'

// Discover routes/resources/auth from the project
const app = await createTestApp({ root: process.cwd() })

// …or wire it explicitly (handy for a focused unit test)
const app = await createTestApp({
  entries: [todosResource, searchRoute],
  auth: myAuthResolver,
})

The returned app exposes REST helpers and an mcp handle:

SurfaceOn the test app
RESTapp.get, app.post, app.patch, app.put, app.delete
MCPapp.mcp.listTools(), app.mcp.call(name, args, opts?)

REST helpers #

Each verb takes a path and an optional body/options and resolves to a plain { status, body, headers }body is already parsed, so you assert on data, not on a Response:

import { expect, test } from 'vitest'
import { createTestApp } from '@apex-stack/core/testing'

test('creates and lists a todo', async () => {
  const app = await createTestApp({ root: process.cwd() })

  const created = await app.post('/api/todos', { text: 'ship it' })
  expect(created.status).toBe(201)
  expect(created.body.text).toBe('ship it')

  const { status, body } = await app.get('/api/todos')
  expect(status).toBe(200)
  expect(body).toHaveLength(1)
})

Validation, status codes, and error shapes are the real ones your handlers return — a bad body comes back as the same 400 a browser would get.

Authenticating a call #

Pass { user } on any request to inject a session and skip the login round-trip — the handler sees exactly the identity defineAuth would resolve. Auth, can, resource scope, and CSRF all stay live; you're skipping login, not authorization:

// Inject a user — no cookie, no login handler
const res = await app.delete('/api/todos/1', { user: { id: 'u1', roles: ['admin'] } })
expect(res.status).toBe(200)

// Anonymous → the real fail-closed response
expect((await app.delete('/api/todos/1')).status).toBe(401)

To exercise the real login flow, use the built-in cookie jar: a call that sets a session cookie is remembered and replayed on later requests, just like a browser.

await app.post('/api/login', { email: 'a@b.co', password: 'secret' })
// subsequent calls carry the sealed session cookie automatically
const me = await app.get('/api/me')
expect(me.body.email).toBe('a@b.co')
Why this matters

{ user } keeps unit tests fast and readable, while the cookie jar lets you cover login, logout, CSRF, and session expiry end-to-end — both against the same in-process app.

The MCP surface #

Because every typed route is also an MCP tool, the test app gives you the tool surface too. listTools() returns what the caller may reach (auth-filtered), and call(name, args, opts) invokes a tool with the same { user } option:

test('todos_create is callable over MCP', async () => {
  const app = await createTestApp({ root: process.cwd() })

  const tools = await app.mcp.listTools()
  expect(tools.map((t) => t.name)).toContain('todos_create')

  const out = await app.mcp.call('todos_create', { text: 'via AI' }, { user: { id: 'u1' } })
  expect(out.text).toBe('via AI')
})

A tool the caller can't reach is omitted from listTools() and refused on call() — so you can assert the AI surface is scoped exactly like REST. See AI-native APIs.

Factories #

factory(model) from @apex-stack/data reads a table's schema and builds valid fake rows — no fixture files to maintain. It infers a sensible value per column type, and your overrides always win:

import { factory } from '@apex-stack/data'
import { schema } from '../db/index.js'

const todos = factory(schema.todos)

todos.make()                       // one plain object, schema-valid
todos.make({ text: 'pinned' })       // overrides win
todos.makeMany(3)                   // array of 3 (no DB write)

make/makeMany only build objects; create/createMany insert them and return the persisted rows:

import { db } from '../db/index.js'

const row = await todos.create(db, { done: true })
const rows = await todos.createMany(db, 10)
MethodReturnsWrites to DB
make(overrides?)one objectno
makeMany(n, overrides?)array of objectsno
create(db, overrides?)one inserted rowyes
createMany(db, n, overrides?)array of inserted rowsyes

Timestamp columns (createdAt/updatedAt) are left unset so the database default fills them. Want richer data? Plug in @faker-js/faker through the fake hook and every generated value flows through it.

import { faker } from '@faker-js/faker'

const users = factory(schema.users, {
  fake: (column) => {
    if (column.name === 'email') return faker.internet.email()
    if (column.name === 'name')  return faker.person.fullName()
  },
})

apex test #

apex test is a thin wrapper around Vitest wired up for an Apex project — TypeScript, ESM, and path aliases just work. Every argument passes straight through:

apex test                 # watch mode (Vitest default)
apex test run             # single run — CI
apex test run --coverage  # flags pass through to Vitest
apex test auth.test.ts    # filter by file

Test-aware scaffolding #

The generators write a matching test next to whatever they create, so new code ships with a starting spec instead of a blank file:

apex make service billing   # → server/services/billing.ts + billing.test.ts
apex make api invoices      # → server/api/invoices.ts + invoices.test.ts
apex make model invoice     # → models/invoice.ts + invoice.test.ts

The generated test boots the surface with createTestApp (for api/model) and stubs the happy path for you to fill in. Don't want one? Pass --no-test.

apex make api invoices --no-test   # skip the generated spec