Master Guides

Testing

One command for the whole suite — the dotnet test of Master.

master test runs the backend test suite (and optionally the frontend’s) with NODE_ENV=test, so tests hit the test database from config/environments/env.test.json instead of your development data. Scaffolded apps ship with an in-process integration test that boots the real MasterController pipeline — no ports, no mocks.

The command#

terminal
$ master test # backend: npm test in backend/ with NODE_ENV=test
$ master test --frontend # ... then npm test in frontend/ as well
$ master test --env staging # use another NODE_ENV for the run

What happens:

  • Master finds the project root (master.config.js) and runs npm test inside backend/ with NODE_ENV=test (override with --env).
  • The scaffolded backend test script is node --test test/*.test.js — Node’s built-in runner, no extra dependency.
  • With --frontend, it then runs npm test in frontend/ (if that package has a test script).
  • The exit code is the first non-zero exit code of the suites it ran, so CI fails correctly. A workspace without a test script is skipped with a warning.

The test environment#

master new writes three database configs. The test one points at a separate database so the suite can create and destroy data freely:

backend/config/environments/env.test.json (sqlite)
{
  "AppContext": {
    "type": "better-sqlite3",
    "connection": "db/test/"
  }
}

For MySQL/Postgres the generated file targets a <app>_test database. Because NODE_ENV=test also selects config/appsettings.test.json (if you add one), you can lower log levels or swap services just for tests. Tests that touch the database need the schema first:

terminal
$ master db migrate --env test
$ master test

The scaffolded integration test#

backend/test/health.test.js is the template’s example — the ASP.NET WebApplicationFactory style. master.createTestClient() boots setupServer → startMVC → start on an ephemeral 127.0.0.1 port and returns a client with a cookie jar:

backend/test/health.test.js
// Integration test through the real pipeline (ASP.NET WebApplicationFactory-style).
// Run with `master test` (or `npm test` inside backend/).
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import master from 'mastercontroller';

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

test('GET /health answers ok:true and liveness is up', async () => {
  process.env.MC_DISABLE_DEFAULT_LOG_PROVIDER = '1';
  client = await master.createTestClient({
    root: dirname(dirname(fileURLToPath(import.meta.url))),
    app: 'app',
    environment: 'test',
    configure: (m) => { m.useHealthChecks({ path: '/health/report' }); },
  });
  const res = await client.get('/health');
  assert.equal(res.status, 200);
  assert.equal(res.json().ok, true);
  const live = await client.get('/health/live');
  assert.equal(live.status, 200);
  assert.equal(live.json().status, 'Healthy');
});
  • root is the backend folder; app: 'app' loads app/routes.js and the controllers exactly like server.js does.
  • environment: 'test' selects env.test.json / appsettings.test.json.
  • configure(m) runs before start — register the middleware your test needs (health checks here; m.addScoped('db', AppContext), auth schemes, etc.).
  • MC_DISABLE_DEFAULT_LOG_PROVIDER=1 keeps request logs out of the test output.

The client API#

client methods
const res = await client.post('/posts', { body: { title: 'Hello', body: 'World' } });
res.status;            // 201
res.ok;                // true
res.headers.location;  // '/posts/1'
res.json();            // parsed body (lazy)
res.text;              // raw body

await client.get('/posts/1', { query: { fields: 'title' }, headers: { accept: 'application/json' } });
await client.put('/posts/1', { body: { title: 'Renamed' } });
await client.delete('/posts/1');

const admin = client.withBearer(token);          // same cookie jar, different identity
const custom = client.withHeaders({ 'x-api-version': '2.0' });
await client.close();

Redirects are not followed, so you can assert on Location; Set-Cookie headers are captured and sent back automatically. Full reference: MasterController Testing.

Testing a scaffolded resource#

A scaffolded controller (generators) uses this.db, so the test must register the context the same way server.js does and migrate the test database first:

backend/test/posts.test.js
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import master from 'mastercontroller';
import AppContext from '../app/models/AppContext.js';

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

test('posts: validation, create, read', async () => {
  process.env.MC_DISABLE_DEFAULT_LOG_PROVIDER = '1';
  client = await master.createTestClient({
    root: dirname(dirname(fileURLToPath(import.meta.url))),
    app: 'app',
    environment: 'test',
    configure: (m) => { m.addScoped('db', AppContext); },
  });

  const bad = await client.post('/posts', { body: {} });
  assert.equal(bad.status, 400);                         // ValidationProblemDetails
  assert.ok(bad.json().errors.title);

  const created = await client.post('/posts', { body: { title: 'Hello', body: 'World' } });
  assert.equal(created.status, 201);
  const id = created.json().data.id;

  const shown = await client.get(`/posts/${id}`);
  assert.equal(shown.json().data.title, 'Hello');

  const missing = await client.get('/posts/abc');      // :id(int) rejects non-integers
  assert.equal(missing.status, 404);
});

CI#

.github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
  backend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm ci
      - run: npx master db migrate --env test     # schema for env.test.json (sqlite: backend/db/test/)
      - run: npx master test                      # add --frontend once the frontend has a test script
CI tips
  • The master CLI is a devDependency of the scaffolded root package, so npx master … works without a global install.
  • SQLite needs no service container; for Postgres/MySQL start one and point env.test.json at it (or override with env vars — see Configuration).
  • master test never reads user secrets you have not set in CI — put test-only values in appsettings.test.json or Section__Key env vars.
  • Each test file gets its own createTestClient(); call client.close() in after() so the runner exits.