Arcwayv0.3.0

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();
  });
});

On this page