Testing
Unit and integration testing with Vitest
Arcway provides built-in testing utilities that boot the framework with an in-memory SQLite database, making integration and unit tests fast and isolated.
Arcway.test()
Arcway.test() boots a full Arcway application with an in-memory SQLite database and returns a test app instance for making requests and inspecting state.
import { Arcway } from 'arcway';
import { describe, it, expect, afterAll } from 'vitest';
describe('users API', () => {
let app;
afterAll(async () => {
await app.shutdown();
});
it('boots and handles requests', async () => {
app = await Arcway.test({ rootDir: './fixtures/my-app' });
// Create a user
const res = await app.request('POST', '/api/users', {
body: { name: 'Alice', email: 'alice@test.com' },
});
expect(res.status).toBe(200);
// Verify in database
const users = await app.db('users').select('*');
expect(users).toHaveLength(1);
expect(users[0].name).toBe('Alice');
// Fetch the user
const getRes = await app.request('GET', '/api/users/1');
expect(getRes.status).toBe(200);
});
});TestApp Interface
Arcway.test() resolves to a test app object:
{
db, // Knex instance — raw database access
port, // Server port
app, // Underlying Arcway application
request, // (method, path, options?) => Promise<TestResponse>
run, // (fn) => Promise — execute with infrastructure context
shutdown, // () => Promise — tear down server and connections
}request(method, path, options?)
The request method takes the HTTP method as a separate first argument, followed by the path and an optional options object:
// GET request
const res = await app.request('GET', '/api/users');
// POST with body
const res = await app.request('POST', '/api/users', {
body: { name: 'Alice', email: 'alice@test.com' },
});
// Request with custom headers
const res = await app.request('GET', '/api/users', {
headers: { Authorization: 'Bearer token123' },
});TestResponse
The response object returned by request():
{
status, // HTTP status code
headers, // Response headers
body, // Parsed JSON for JSON responses, raw text otherwise
text, // Raw response text
}For JSON API responses, body contains the parsed JSON. Access data and errors through it:
const res = await app.request('GET', '/api/users/1');
// Access the response data
const userData = res.body;
expect(userData.name).toBe('Alice');
// For error responses
const errRes = await app.request('GET', '/api/users/999');
expect(errRes.status).toBe(404);
expect(errRes.body.error).toBeDefined();db
Direct Knex database access for setting up fixtures or verifying side effects:
// Insert test data directly
await app.db('users').insert({ name: 'Bob', email: 'bob@test.com' });
// Verify database state after a request
const users = await app.db('users').select('*');
expect(users).toHaveLength(1);
// Clean up between tests
await app.db('users').del();run()
Executes a function with a full infrastructure context (db, events, cache, etc.). The function receives a ctx object:
const result = await app.run(async (ctx) => {
const { db } = ctx;
return db('users').where({ id: 1 }).first();
});
expect(result.name).toBe('Alice');This is useful for testing logic that uses infrastructure directly:
const user = await app.run(async (ctx) => {
const { db, events } = ctx;
const [id] = await db('users').insert({ name: 'Alice', email: 'alice@test.com' });
await events.emit('users/created', { id });
return db('users').where('id', id).first();
});
expect(user.id).toBeDefined();shutdown()
Tears down the test server and closes all connections. Always call this in afterAll:
afterAll(async () => {
await app.shutdown();
});createTestContext
For unit testing functions that only need database access (without booting a full HTTP server), use createTestContext:
import { createTestContext } from 'arcway';
import { describe, it, expect, afterEach } from 'vitest';
describe('user functions', () => {
let testCtx;
afterEach(async () => {
await testCtx.cleanup();
});
it('creates a user', async () => {
testCtx = await createTestContext('test', {
rootDir: './fixtures/my-app', // runs migrations from this directory
});
// Use testCtx.db for direct database access
await testCtx.db('users').insert({ name: 'Alice' });
const users = await testCtx.db('users').select('*');
expect(users).toHaveLength(1);
});
});createTestContext returns:
{
ctx, // { db, events, queue, cache, files, mail, log } — all stubbed except db
db, // Knex instance (same as ctx.db)
cleanup, // () => Promise — destroys the database connection
}createTestContext is lighter than Arcway.test() -- it sets up an in-memory database with your schema but does not start a server. This makes it ideal for testing pure business logic and database queries.
Options:
createTestContext('name', {
rootDir: './path', // Run migrations from this project directory
dir: './path', // Or specify migrations directory directly
dbClient: 'better-sqlite3', // Database client (default: 'better-sqlite3')
dbConnection: {}, // Custom connection config
events: customEventStub, // Override default stubs
queue: customQueueStub,
cache: customCacheStub,
files: customFilesStub,
mail: customMailStub,
log: customLoggerStub,
});Test Stubs
Arcway provides stub implementations for all infrastructure services. Stubs are inspectable -- they record all calls so you can assert against them.
import {
createEventStub,
createQueueStub,
createCacheStub,
createFilesStub,
createMailStub,
createLoggerStub,
} from 'arcway';Event Stub
const eventStub = createEventStub();
// ... run code that emits events ...
// Assert on emitted events
expect(eventStub.calls).toContainEqual({ event: 'users/created', payload: { id: 1 } });Mail Stub
const mailStub = createMailStub();
// ... run code that sends mail ...
// Assert on sent mail
expect(mailStub.sent).toHaveLength(1);
expect(mailStub.sent[0].to).toBe('user@example.com');
// Assert on queued mail
expect(mailStub.queued).toHaveLength(1);Queue Stub
const queueStub = createQueueStub();
// Behaves like a real queue
await queueStub.push('emails', { to: 'alice@test.com' });
const items = await queueStub.pop('emails', 1);
expect(items[0].data.to).toBe('alice@test.com');
// Inspect pushed items
expect(queueStub.pushed).toHaveLength(1);Cache Stub
const cacheStub = createCacheStub();
// Stubs behave like a real cache (in-memory Map)
await cacheStub.set('key', 'value');
const val = await cacheStub.get('key');
expect(val).toBe('value');
// wrap() works too
const result = await cacheStub.wrap('key2', async () => 'computed');
expect(result).toBe('computed');Files Stub
const filesStub = createFilesStub();
// Behaves like real file storage (in-memory Map)
await filesStub.write('avatar.jpg', Buffer.from('data'));
const data = await filesStub.read('avatar.jpg');
expect(data).toBeDefined();
expect(await filesStub.exists('avatar.jpg')).toBe(true);Logger Stub
const loggerStub = createLoggerStub();
// ... run code that logs ...
// Assert on log output
expect(loggerStub.messages).toContainEqual(
expect.objectContaining({ level: 'info', message: expect.stringContaining('created') }),
);Full Integration Test Example
A complete example showing a realistic test workflow:
import { Arcway } from 'arcway';
import { describe, it, expect, afterAll } from 'vitest';
describe('users API', () => {
let app;
afterAll(async () => {
await app.shutdown();
});
it('full CRUD workflow', async () => {
app = await Arcway.test({ rootDir: './fixtures/my-app' });
// Create a user
const createRes = await app.request('POST', '/api/users', {
body: { name: 'Alice', email: 'alice@test.com' },
});
expect(createRes.status).toBe(200);
// List users
const listRes = await app.request('GET', '/api/users');
expect(listRes.status).toBe(200);
expect(listRes.body).toHaveLength(1);
// Get single user
const getRes = await app.request('GET', '/api/users/1');
expect(getRes.status).toBe(200);
expect(getRes.body.name).toBe('Alice');
// Update user
const updateRes = await app.request('PUT', '/api/users/1', {
body: { name: 'Alice Smith' },
});
expect(updateRes.status).toBe(200);
// Verify update in database
const user = await app.db('users').where({ id: 1 }).first();
expect(user.name).toBe('Alice Smith');
// Delete user
const deleteRes = await app.request('DELETE', '/api/users/1');
expect(deleteRes.status).toBe(200);
// Verify deletion
const users = await app.db('users').select('*');
expect(users).toHaveLength(0);
});
it('handles errors', async () => {
const res = await app.request('GET', '/api/users/999');
expect(res.status).toBe(404);
expect(res.body.error).toBeDefined();
});
});