Skip to main content

Testing

The framework supports Node.js's built-in test runner and Vitest. The built-in runner is the default for new projects: it executes TypeScript test files natively, has no additional test-runner dependency, and integrates with the framework's shared MongoDB and per-test isolation helpers. Node.js 24 remains the minimum supported runtime; the framework's own test infrastructure and CI run on Node.js 26.

Vitest remains a fully supported alternative. Choose the runner that best fits the project's existing tooling and plugins; the native runner is not presented as an execution-speed upgrade.

Name tests *.test.ts or *.test.js and keep them next to the file they cover. For example, test src/controllers/Auth.ts in src/controllers/Auth.test.ts.

Install the test database

The framework loads mongodb-memory-server lazily, so applications that use the supplied in-memory MongoDB global setup must install it directly:

npm install --save-dev mongodb-memory-server

Redis is optional during tests. When REDIS_URI is configured, the framework uses a fresh namespace for each test and clears it afterward.

Node.js test-runner setup

Node.js runs every test file in a separate process. The framework therefore splits its lifecycle into:

  • a global setup that starts one MongoDB replica set for the complete run;
  • per-file hooks that start and stop a framework server with a fresh database;
  • per-test hooks that isolate the Redis namespace.

Project test configuration

Keep project-specific test configuration in src/tests/setup.ts. This file can set test folder locations or other environment required before the framework server starts.

Create one preload file at src/tests/setupNodeTest.ts:

import './setup.ts';
import '@adaptivestone/framework/tests/setupNodeTest.js';
import './setupHooks.ts';

The runner preloads this file for every test file. Do not import it from each individual test.

Production HTTP wiring (bootHttp)

The framework builds the test server itself, so the Server options your src/index.ts passes are not picked up automatically — including the bootHttp hook. Declare them with configureTestServer from src/tests/setup.ts, which loads before the framework preload:

src/tests/setup.ts
import { configureTestServer } from '@adaptivestone/framework/tests/testHelpers.js';
import bootHttp from '../bootHttp.ts';

configureTestServer({ bootHttp });

Point it at the same function production uses, rather than re-registering the wiring from a test hook: two copies drift, and the copy that drifts is the one the tests trust. Without this, tests run against a server that never ran that wiring at all — an error handler registered in bootHttp does not exist, so a request that returns 409 in production returns 500 under test, with nothing reporting the difference.

folders is not accepted here; the bootstrap owns those (see the TEST_FOLDER_* variables below). Call configureTestServer at module scope, before any hook boots the server — calling it afterwards throws, because a late call cannot retroactively wire the server that is already running.

Global MongoDB setup

Create src/tests/globalSetupNodeTest.ts:

export {
globalSetup,
globalTeardown,
} from '@adaptivestone/framework/tests/globalSetupNodeTest.js';

globalSetup starts MongoDB once and passes TEST_MONGO_URI to the child test processes. globalTeardown stops it after every test file has finished.

Custom hooks

Project hooks can live in src/tests/setupHooks.ts:

import { after, afterEach, before, beforeEach } from 'node:test';
import {
createDefaultTestUser,
ensureTestServerReady,
} from '@adaptivestone/framework/tests/testHelpers.js';

before(async () => {
// Root hooks registered by separate modules are siblings and may start
// concurrently. Always await framework readiness before using app state.
await ensureTestServerReady();
await createDefaultTestUser();
});

after(async () => {
// Clean up project-level test state.
});

beforeEach(async () => {
// Prepare each test.
});

afterEach(async () => {
// Clean up each test.
});

The framework hooks are already registered by setupNodeTest.js. Project hooks should contain only application-specific preparation and cleanup.

Node.js does not serialize sibling root-level before() hooks registered by different modules. A project root hook that reads config, models, appInstance, or the HTTP server must call await ensureTestServerReady() first. The helper and framework preload share one idempotent startup promise, so concurrent calls wait for the same server instead of constructing two servers.

Setup used by only one test file should normally live inside the same describe() as those tests. Suite-scoped hooks wait for the root framework hook and do not accidentally affect unrelated suites.

Validation messages and application locales

The framework test helper loads its built-in locale folder by default. It does not automatically load the application's locale folder: set TEST_FOLDER_LOCALES in src/tests/setup.ts when a suite specifically needs rendered application copy.

Without that opt-in, application-specific validation message keys remain raw in HTTP 400 responses. This is intentional for ordinary API tests—assert the stable key and status code rather than translated prose that can change between locales. A copy-specific test may point TEST_FOLDER_LOCALES at src/locales, but should do so before setupNodeTest.js loads.

Package scripts

Use a small command for local tests and watch mode. Keep coverage and reporters in the CI command so ordinary development runs stay fast:

{
"scripts": {
"test": "node --import=./src/tests/setupNodeTest.ts --test --test-global-setup=./src/tests/globalSetupNodeTest.ts \"src/**/*.test.ts\"",
"t": "node --import=./src/tests/setupNodeTest.ts --test --watch --test-global-setup=./src/tests/globalSetupNodeTest.ts \"src/**/*.test.ts\"",
"test:ci": "mkdir -p coverage && node --import=./src/tests/setupNodeTest.ts --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-exclude=\"src/tests/**\" --test-coverage-lines=80 --test-coverage-branches=80 --test-coverage-functions=75 --test-global-setup=./src/tests/globalSetupNodeTest.ts --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=coverage/junit.xml --test-reporter=lcov --test-reporter-destination=coverage/lcov.info \"src/**/*.test.ts\""
}
}

Run the suite once:

npm test

Run it in watch mode:

npm run t

Run the CI configuration with coverage thresholds and LCOV output:

npm run test:ci

The example thresholds are 80% for lines, 80% for branches, and 75% for functions. These are enforcement flags: a below-threshold run exits with code 1 even when multiple reporters are enabled. Adjust them deliberately as the project grows. LCOV is written to coverage/lcov.info, and JUnit test results are written to coverage/junit.xml.

Native global setup, whole-module mocks, and test coverage remain experimental Node.js test-runner surfaces. Pin the test runtime in CI rather than following an unbounded latest release.

Writing a Node.js test

Use node:test with the standard strict assertion module:

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { getTestServerURL } from '@adaptivestone/framework/tests/testHelpers.js';

describe('person', () => {
it('creates a person', async () => {
const response = await fetch(getTestServerURL('/person'), {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
name: 'Example Person',
age: 30,
}),
});

assert.equal(response.status, 200);
});
});

The preload and global setup belong in the runner command, not in this file.

Assertion plans

t.plan(n) counts assertions made through t.assert (and registered subtests), not calls to a separately imported node:assert object. If a test uses a plan, use t.assert.* for every assertion in that test:

import { it } from 'node:test';

it('returns one result', (t) => {
t.plan(2);
t.assert.strictEqual(1, 1);
t.assert.deepStrictEqual([{ id: 1 }], [{ id: 1 }]);
});

Top-level node:assert/strict remains appropriate in tests that do not use t.plan().

Migration traps from Jest and Vitest

  • assert.partialDeepStrictEqual(actual, expected) applies subset semantics to arrays as well as objects. Assert the array length separately when extra elements must fail the test. For rejected errors, use the two-argument assert.rejects(promise, errorPattern) form. Compare selected Mongoose document fields or deliberately normalize the document before deep matching.

  • Calling mockImplementationOnce() twice before the mock is invoked targets the same next call and the later registration replaces the earlier one. Pass explicit zero-based onCall indices when queuing several results:

    const load = t.mock.method(service, 'load');
    load.mock.mockImplementationOnce(() => firstResult, 0);
    load.mock.mockImplementationOnce(() => secondResult, 1);
  • TypeScript represents mock.calls[n].arguments using one overload of an overloaded method, which may not be the overload exercised by the test. Prefer assertions at the public API boundary. When argument inspection is necessary, keep any unknown-first tuple cast local to that assertion.

Framework and server access

Use appInstance for the initialized framework application:

import { appInstance } from '@adaptivestone/framework/helpers/appInstance.js';

Use serverInstance when a test needs lower-level access to the server:

import { serverInstance } from '@adaptivestone/framework/tests/testHelpers.js';

HTTP endpoint testing

The framework starts each test server on a random available port. getTestServerURL() returns the correct URL:

import assert from 'node:assert/strict';
import { it } from 'node:test';
import {
defaultAuthToken,
getTestServerURL,
} from '@adaptivestone/framework/tests/testHelpers.js';

it('rejects an invalid request', async () => {
const response = await fetch(getTestServerURL('/some/endpoint'), {
method: 'POST',
headers: {
authorization: defaultAuthToken,
'content-type': 'application/json',
},
body: JSON.stringify({ invalid: true }),
});

assert.equal(response.status, 400);
});

Do not catch and discard fetch() errors in tests. A rejected request should fail the test and preserve the original error.

Default test user

The framework does not create a user automatically. Call createDefaultTestUser() from project setup when a suite needs one:

import {
createDefaultTestUser,
defaultAuthToken,
defaultUser,
} from '@adaptivestone/framework/tests/testHelpers.js';

const { user, token } = await createDefaultTestUser();

defaultUser and defaultAuthToken reference the values created by the helper. Projects with a custom User model should implement their own creation helper and use setDefaultUser() and setDefaultAuthToken().

Test helpers

The public helpers are exported from @adaptivestone/framework/tests/testHelpers.js:

import {
createDefaultTestUser,
defaultAuthToken,
defaultUser,
ensureTestServerReady,
getTestServerURL,
serverInstance,
setDefaultAuthToken,
setDefaultUser,
} from '@adaptivestone/framework/tests/testHelpers.js';
  • getTestServerURL(path) returns the active test server URL.
  • ensureTestServerReady() waits for the initialized per-file server and is safe to call concurrently from application root hooks.
  • serverInstance exposes the current test server.
  • createDefaultTestUser() creates the framework's default User and token.
  • setDefaultUser() and setDefaultAuthToken() support custom User models.

MongoDB and Docker

The global setup uses MongoMemoryReplSet with one wiredTiger member. Each test file receives a unique database, and the framework drops it during teardown.

MongoDB publishes ARM64 binaries for Ubuntu, but not for every Debian release used by official Node.js Docker images. Use the project's Ubuntu-based Node image for GitLab and ARM64 Docker testing:

registry.gitlab.com/adaptivestone/ubuntu-node:latest

When installation and tests run in separate CI jobs, use the same image for both. This prevents Linux, libc, Node.js, and native dependency mismatches. The current Ubuntu image supports MongoMemoryServer's automatic distro and MongoDB version selection; application setup should not normally set MONGOMS_DISTRO or MONGOMS_VERSION.

This template uses npm install in CI and production image builds. npm can remove optional dependency records for other CPU architectures when it rewrites package-lock.json; a later npm ci on another architecture may then reject the otherwise valid lockfile. npm install repairs those optional records in the CI workspace and avoids making an ARM-generated lockfile block an x64 job.

GitLab CI

The following pipeline installs dependencies once and runs quality checks and tests with the exact same Ubuntu/Node artifact:

stages:
- install
- checks

default:
# Keep dependency installation and execution on the same Ubuntu/Node image.
# Ubuntu is also required for mongodb-memory-server binary availability.
image: registry.gitlab.com/adaptivestone/ubuntu-node:latest

install:
stage: install
script:
# Keep cross-platform optional dependencies resolvable after an ARM install.
- npm install
artifacts:
paths:
- node_modules/
expire_in: 2 hours

quality:
stage: checks
needs:
- install
script:
- npm run check

tests:
stage: checks
needs:
- install
services:
- redis:latest
variables:
EMAIL_TRANSPORT: stub
REDIS_URI: redis://redis
script:
- npm run test:ci
coverage: '/[Aa]ll files[^|]*\|[^|]*\s+([\d\.]+)/'
artifacts:
when: always
paths:
- coverage/lcov.info

GitHub Actions

GitHub-hosted Ubuntu runners can use Node.js 26 for the current test infrastructure. Applications that deliberately verify the minimum supported runtime may add a separate Node.js 24 job:

name: Test

on:
push:
branches: ['*']

jobs:
test:
name: Node 26
runs-on: ubuntu-latest
permissions:
contents: read

services:
redis:
image: redis:latest
ports:
- 6379:6379

env:
LOGGER_CONSOLE_LEVEL: error
REDIS_URI: redis://localhost

steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- uses: actions/setup-node@v6
with:
node-version: '26'
cache: npm

- name: Install dependencies
# ARM npm can omit optional x64 peers when it rewrites package-lock.json.
run: npm install

- name: Run tests
run: npm run test:ci

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v6
with:
report_type: test_results
files: coverage/junit.xml
token: ${{ secrets.CODECOV_TOKEN }}

Mocking

Node.js provides function and method mocks through each test's MockTracker:

import assert from 'node:assert/strict';
import { it } from 'node:test';
import S3 from '../S3.ts';

it('validates credentials', (t) => {
const validateCreds = t.mock.method(S3, 'validateCreds', () => true);

assert.equal(S3.validateCreds(), true);
assert.equal(validateCreds.mock.callCount(), 1);
});

Mocks created through t.mock are restored automatically after the test. Whole-module ESM mocking is experimental in Node.js 26 and requires --experimental-test-module-mocks; prefer method mocks or dependency injection unless module replacement is necessary.

Vitest setup

Vitest is a fully supported alternative runner and remains an optional peer dependency. Install it in projects that choose it:

npm install --save-dev vitest

Configure the framework's public adapters instead of importing lifecycle internals:

import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
globalSetup: [
'@adaptivestone/framework/tests/globalSetupVitest.js',
],
setupFiles: [
'./src/tests/setup.ts',
'@adaptivestone/framework/tests/setupVitest.js',
'./src/tests/setupHooks.ts',
],
},
});

./src/tests/setup.ts loads before the framework adapter here too, so configureTestServer belongs in the same place for both runners.

Vitest hooks and mocks should use Vitest's APIs. Do not load the Node.js and Vitest adapters in the same test run. The framework does not require a Vitest coverage provider; projects that enable Vitest coverage should install and configure their preferred provider separately.