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:
| Surface | On the test app |
|---|---|
| REST | app.get, app.post, app.patch, app.put, app.delete |
| MCP | app.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')
{ 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)
| Method | Returns | Writes to DB |
|---|---|---|
make(overrides?) | one object | no |
makeMany(n, overrides?) | array of objects | no |
create(db, overrides?) | one inserted row | yes |
createMany(db, n, overrides?) | array of inserted rows | yes |
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