Schmock's callable API makes it ideal for testing — no HTTP server needed, no network latency, full control over responses.
import { schmock } from '@schmock/core'
import { describe, it, expect, beforeEach } from 'vitest'
describe('UserService', () => {
let mock: Schmock.CallableMockInstance
beforeEach(() => {
mock = schmock({ state: { users: [] } })
mock('GET /users', ({ state }) => state.users)
mock('POST /users', ({ body, state }) => {
const user = { id: state.users.length + 1, ...body }
state.users.push(user)
return [201, user]
})
mock('GET /users/:id', ({ params, state }) => {
const user = state.users.find(u => u.id === Number(params.id))
return user || [404, { error: 'Not found' }]
})
})
it('creates and retrieves a user', async () => {
const created = await mock.handle('POST', '/users', {
body: { name: 'Alice', email: 'alice@example.com' },
})
expect(created.status).toBe(201)
expect(created.body.id).toBe(1)
const fetched = await mock.handle('GET', '/users/1')
expect(fetched.body.name).toBe('Alice')
})
it('returns 404 for missing user', async () => {
const res = await mock.handle('GET', '/users/999')
expect(res.status).toBe(404)
})
})it('tracks all requests', async () => {
await mock.handle('POST', '/users', { body: { name: 'Alice' } })
await mock.handle('POST', '/users', { body: { name: 'Bob' } })
expect(mock.callCount('POST', '/users')).toBe(2)
expect(mock.lastRequest('POST', '/users')?.body).toEqual({ name: 'Bob' })
const history = mock.history('POST', '/users')
expect(history[0].body).toEqual({ name: 'Alice' })
})import { validationPlugin } from '@schmock/validation'
beforeEach(() => {
mock = schmock()
mock.pipe(validationPlugin({
request: {
body: {
type: 'object',
required: ['name', 'email'],
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
},
},
}))
mock('POST /users', ({ body }) => [201, body])
})
it('rejects invalid request bodies', async () => {
const res = await mock.handle('POST', '/users', {
body: { name: '' },
})
expect(res.status).toBe(400)
expect(res.body.code).toBe('REQUEST_VALIDATION_ERROR')
})
it('accepts valid request bodies', async () => {
const res = await mock.handle('POST', '/users', {
body: { name: 'Alice', email: 'alice@example.com' },
})
expect(res.status).toBe(201)
})Test against real API contracts:
import { openapi } from '@schmock/openapi'
describe('Petstore API', () => {
let mock: Schmock.CallableMockInstance
beforeAll(async () => {
mock = schmock({ state: {} })
mock.pipe(await openapi({
spec: './petstore.yaml',
seed: { pets: [{ petId: 1, name: 'Rex', tag: 'dog' }] },
validateRequests: true,
}))
})
it('lists pets', async () => {
const res = await mock.handle('GET', '/pets')
expect(res.status).toBe(200)
expect(res.body).toBeInstanceOf(Array)
expect(res.body[0]).toHaveProperty('petId')
})
it('creates a pet', async () => {
const res = await mock.handle('POST', '/pets', {
body: { name: 'Buddy', tag: 'dog' },
})
expect(res.status).toBe(201)
expect(res.body.name).toBe('Buddy')
})
it('deletes a pet', async () => {
const res = await mock.handle('DELETE', '/pets/1')
expect(res.status).toBe(204)
})
it('returns 404 for deleted pet', async () => {
const res = await mock.handle('GET', '/pets/1')
expect(res.status).toBe(404)
})
})The seed row and the toHaveProperty('petId') assertion assume this
petstore's Pet schema declares petId. The plugin stores and returns a
resource's id property: the path parameter's name
only when the item schema declares it, otherwise id. For a Pet that declares
id, seed { id: 1, … } and assert on id.
A file-path spec ('./petstore.yaml'), and any relative external $refs
it has with refs: { external: true }, is read from disk in jsdom and
happy-dom test environments too, not fetched relative to window.location.
Use supertest with the Express adapter:
import express from 'express'
import request from 'supertest'
import { schmock } from '@schmock/core'
import { toExpress } from '@schmock/express'
describe('Express integration', () => {
let app: express.Express
beforeEach(() => {
const mock = schmock()
mock('GET /users', [{ id: 1, name: 'Alice' }])
mock('POST /users', ({ body }) => [201, { id: 2, ...body }])
app = express()
app.use(express.json())
app.use('/api', toExpress(mock))
})
it('GET /api/users', async () => {
const res = await request(app).get('/api/users')
expect(res.status).toBe(200)
expect(res.body).toHaveLength(1)
})
it('POST /api/users', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Bob' })
expect(res.status).toBe(201)
expect(res.body.name).toBe('Bob')
})
})import { TestBed } from '@angular/core/testing'
import { HttpClient, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { schmock } from '@schmock/core'
import { provideSchmockInterceptor } from '@schmock/angular'
describe('UserService', () => {
let http: HttpClient
let mock: Schmock.CallableMockInstance
beforeEach(() => {
mock = schmock()
// Register the route WITHOUT the baseUrl prefix — see the note below.
mock('GET /users', [{ id: 1, name: 'Alice' }])
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideSchmockInterceptor(mock, { baseUrl: '/api' }),
],
})
http = TestBed.inject(HttpClient)
})
it('fetches users', (done) => {
http.get<any[]>('/api/users').subscribe(users => {
expect(users).toHaveLength(1)
expect(users[0].name).toBe('Alice')
done()
})
})
})Two things differ from the other adapters:
- The Angular adapter's
baseUrlboth filters requests and strips the prefix before route lookup, so/api/usersis matched against the routeGET /users. (Core'smock.intercept()filters without stripping — routes there keep the prefix.) - Use
provideSchmockInterceptor, not a hand-written{ provide: HTTP_INTERCEPTORS, useClass: ... }entry. The interceptor class is built at runtime, souseClassfails with NG0204 ("needs JIT compiler") in AOT builds; the helper provides it withuseFactoryinstead.
Pass the spec as an object. Under the browser build a file path throws
OPENAPI_NODE_ONLY (see Running in a browser),
so fetch the spec or import it as JSON:
import { provideSchmockInterceptorFromSpec } from '@schmock/angular'
beforeEach(async () => {
const spec: object = await fetch('/assets/api.json').then((res) => res.json())
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptorsFromDi()),
await provideSchmockInterceptorFromSpec(
{ spec, seed: { users: { count: 5 } } },
{ baseUrl: '/api' },
),
],
})
})describe('error handling', () => {
let mock: Schmock.CallableMockInstance
beforeEach(() => {
mock = schmock()
mock('GET /flaky', () => [500, { error: 'Internal server error' }])
mock('GET /timeout', () => [504, { error: 'Gateway timeout' }])
mock('POST /validate', ({ body }) => {
if (!body?.name) return [400, { error: 'name is required' }]
return [201, body]
})
})
it('handles 500 errors', async () => {
const res = await mock.handle('GET', '/flaky')
expect(res.status).toBe(500)
})
it('handles missing routes', async () => {
const res = await mock.handle('GET', '/nonexistent')
expect(res.status).toBe(404)
expect(res.body.code).toBe('ROUTE_NOT_FOUND')
})
})import { queryPlugin } from '@schmock/query'
describe('paginated list', () => {
let mock: Schmock.CallableMockInstance
beforeEach(() => {
const users = Array.from({ length: 25 }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
role: i % 3 === 0 ? 'admin' : 'user',
}))
mock = schmock()
// Plugins apply to every route on the mock, not only to one route.
mock.pipe(queryPlugin({
pagination: { defaultLimit: 10 },
sorting: { allowed: ['name', 'id'] },
filtering: { allowed: ['role'] },
}))
mock('GET /users', () => users)
})
it('paginates results', async () => {
const res = await mock.handle('GET', '/users', {
query: { page: '2', limit: '10' },
})
expect(res.body.data).toHaveLength(10)
expect(res.body.pagination.page).toBe(2)
expect(res.body.pagination.total).toBe(25)
})
it('filters by role', async () => {
const res = await mock.handle('GET', '/users', {
query: { 'filter[role]': 'admin' },
})
expect(res.body.data.every(u => u.role === 'admin')).toBe(true)
})
it('sorts by name descending', async () => {
const res = await mock.handle('GET', '/users', {
query: { sort: 'name', order: 'desc' },
})
const names = res.body.data.map(u => u.name)
expect(names).toEqual([...names].sort().reverse())
})
})- Use
mock.resetState()inbeforeEachif you share a mock instance across tests but need fresh state - Use
fakerSeedin OpenAPI tests for deterministic data - Use
mock.listen(0)when you need a real HTTP server — port 0 picks a random available port - Use
mock.history()to verify the exact requests your code made, not just the responses
Run the complete local verification matrix before submitting a change:
bun run test:all
bun run test:e2e
bun run lint
bun run eslint
bun run knip
bun run buildBDD step definitions are also compiled under the repository's strict TypeScript configuration. Run that gate alone while editing scenarios or steps:
bun run typecheck:bddThe root typecheck and CI typecheck jobs include this BDD gate automatically.
Unit and BDD suites of every package resolve sibling @schmock/* imports to
../<pkg>/src through resolve.alias in vitest.config.ts and
vitest.config.bdd.ts, so they test source without a build. Only the tests
that load dist by file path need bun run build first: dist-shape.test.ts
in core and angular, and in openapi browser-compat.test.ts,
browser-bundle.test.ts, entry-parity.test.ts and
steps/openapi-browser-build.steps.ts.
The @schmock/devtools service-worker relay is tested without a browser.
packages/devtools/src/test-support/relay-harness.ts fakes only the
service-worker objects in process (the registration lifecycle, scope matching,
claims, the client message queue, fetch events and Cache Storage) on top of
Node's real MessageChannel, Request, Response and AbortController. Its
self-test, relay-harness.test.ts, pins each modelled behaviour before the
worker and page tests rely on it. The harness forwards a page's abort to the
worker's request signal. In real Chrome the mock did not see the abort, so the
499 that the abort scenarios assert is the harness's behaviour, not Chrome's.
What only a real browser shows (Network rows, browser default headers, hard
reload, abort) was checked once by hand in headless Chrome over the DevTools
protocol, and the results are recorded in
DevTools as observed. That check is not
committed and does not run in CI.
Package manifests, exports, build output, or release changes also require the packed release-candidate gate:
bun run check:publishIt first runs check:manifests (publish shape per package: files, license,
repository, homepage, bugs, engines, README, and script symmetry), then
verifies clean/repeated build equality and stale-artifact removal, packs all
12 workspaces, runs Node and Bun consumers, compiles 15 strict standalone
declaration entries, compiles packed Core declarations with TypeScript 5.6,
checks React root/testing context identity, exercises the CLI and browser
bundle, runs the release-script tests, and runs publint plus attw.
Both browser stages bundle with esbuild (--platform=browser --external:node:* --metafile) and run scripts/check-browser-node-imports.mjs, which reads each
external import's kind from the metafile. It fails on any external Node
built-in outside the allowlist, and on an allowlisted one reached any way other
than a lazy import(): a static import or a require of it breaks Angular's
application builder, which externalises nothing. The allowlist holds only
node:http, core's lazy listen() import. A bare built-in such as path or
util fails the esbuild bundle itself.
One command runs every release check without touching npm, the remote, or GitHub:
bun run publishIt runs the release preflight (12 synchronized manifests, bun.lock parity, a
clean worktree on canonical main), then lint, test:all, build and
check:publish, then prints the release plan and the exact execute command:
bun run publish -- all --execute --confirm all@vX.Y.Z:<40-char-commit>The confirmation token names the version and the release commit so that the
person releasing affirms both; nothing derives it for you. Any arguments are
passed through to .agents/skills/devops/scripts/publish.sh, which owns the
guarantees: it skips a package only when npm reports the same package contents
already published, and it pushes main and creates one unified vX.Y.Z
release after all 12 packages are published.