Testing
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#
$ 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 runsnpm testinsidebackend/withNODE_ENV=test(override with--env). - The scaffolded backend
testscript isnode --test test/*.test.js— Node’s built-in runner, no extra dependency. - With
--frontend, it then runsnpm testinfrontend/(if that package has atestscript). - The exit code is the first non-zero exit code of the suites it ran, so CI fails correctly. A workspace without a
testscript 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:
{
"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:
$ 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:
// 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');
});rootis the backend folder;app: 'app'loadsapp/routes.jsand the controllers exactly likeserver.jsdoes.environment: 'test'selectsenv.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=1keeps request logs out of the test output.
The client API#
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:
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#
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- The
masterCLI is a devDependency of the scaffolded root package, sonpx master …works without a global install. - SQLite needs no service container; for Postgres/MySQL start one and point
env.test.jsonat it (or override with env vars — see Configuration). master testnever reads user secrets you have not set in CI — put test-only values inappsettings.test.jsonorSection__Keyenv vars.- Each test file gets its own
createTestClient(); callclient.close()inafter()so the runner exits.