MasterController

Testing

Boot the real app in-process and drive it with a client that remembers cookies.

master.createTestClient() is the WebApplicationFactory / TestServer + HttpClient pairing: it runs setupServer → startMVC(app) → start → listen(127.0.0.1:0) once and returns a TestClient. Requests go through the entire pipeline — middleware, auth, binding, caching, controllers — on an ephemeral port with no network exposure. Works with node --test out of the box.

A first test#

test/account.test.js
// test/account.test.js
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import master from 'mastercontroller';

let client;
after(() => client.close());

test('login then read profile', async () => {
  client = await master.createTestClient({
    root: new URL('..', import.meta.url).pathname,   // the app root (config/, app/)
    app: 'app',
    environment: 'test',
    configure: (m) => { m.auth.addCookie('cookie', { secret: process.env.TEST_COOKIE_SECRET }); },
  });

  const login = await client.post('/login', { body: { email: 'a@b.co', password: 'pw' } });   // Set-Cookie captured
  assert.equal(login.status, 200);

  const me = await client.get('/me');                                                        // Cookie sent automatically
  assert.equal(me.json().email, 'a@b.co');

  const admin = client.withBearer(token);                                                    // separate identity, same jar
  const denied = await admin.get('/admin');
  assert.equal(denied.status, 403);
});

Run it with node --test. createTestClient is idempotent on an already-started app, so several test files can share one boot; call close() when you are done.

Client API#

javascript
const client = await master.createTestClient({ root, app: 'app', environment: 'test', configure });

// requests — JSON bodies, query objects, extra headers
const res = await client.get('/items', { query: { page: 2 }, headers: { 'x-api-version': '2.0' } });
await client.post('/items', { body: { name: 'A1' } });
await client.put('/items/1', { body: { name: 'A2' } });
await client.patch('/items/1', { body: { name: 'A3' } });
await client.delete('/items/1');
await client.head('/items');

// responses
res.status;      // 200
res.ok;          // true
res.headers;     // fetch Headers — res.headers.get('location') / res.header('location')
res.text;        // raw body (already read)
res.json();      // parsed lazily; null if not JSON

// identities and state
const asAdmin = client.withBearer(token);            // new client, same cookie jar
const withTenant = client.withHeaders({ 'x-tenant': 'acme' });
client.withCookie('.Culture', 'c=de|uic=de');
client.cookies;  client.clearCookies();

client.baseUrl;  // http://127.0.0.1:<ephemeral> for raw fetch()
await client.close();
  • Options: root (sets master.root), app ('app' by default; false skips controller discovery), environment ('test'), configure(master, server) — runs after setupServer() and before startMVC(), the place for schemes, services, minimal routes.
  • Bodies: objects are JSON-encoded with content-type: application/json; pass a Buffer or URLSearchParams for anything else. accept: application/json is sent by default (json: false to omit).
  • Cookies: Set-Cookie is captured into a jar shared by every with*() client; expired cookies are dropped.
  • Redirects are not followed (redirect: 'manual'), so you can assert on Location.

Minimal-API hosts#

app: false boots without app/ — handy for unit-style tests of minimal APIs and middleware.

javascript
// Pure minimal-API host — no controller discovery
const client = await master.createTestClient({
  app: false,
  configure: (m) => {
    m.map.get('/ping', () => ({ pong: true }), { allowAnonymous: true });
  },
});
assert.deepEqual((await client.get('/ping')).json(), { pong: true });

Asserting on logs#

javascript
import master, { memoryProvider } from 'mastercontroller';

const mem = memoryProvider();
const client = await master.createTestClient({ root, configure: (m) => m.logging.addProvider(mem) });
await client.get('/orders/1');
assert.ok(mem.entries.some((e) => e.category === 'Request' && e.scope?.path === '/orders/1'));
Request-scoped services in tests
Because each request gets its own DI scope, a test that registers m.addScoped('db', AppContext) in configure exercises exactly the production lifetime — see Dependency Injection. Point the test environment at a throwaway database in config/appsettings.test.json.
Named exports
import { TestClient, createTestClient } from 'mastercontroller' createTestClient(master, opts) takes the instance explicitly, which is useful with new MasterControl() when you want isolated apps per test file.