Skip to main content

Testing

The framework supports Node.js's built-in test runner and Vitest. New Node.js 24 projects should use the built-in runner: 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.

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.

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": "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=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 the spec and lcov reporters are both enabled. Adjust them deliberately as the project grows. The LCOV report is written to coverage/lcov.info.

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 24 directly:

name: Test

on:
push:
branches: ['*']

jobs:
test:
name: Node 24
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: '24'
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 }}

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 24 and requires --experimental-test-module-mocks; prefer method mocks or dependency injection unless module replacement is necessary.

Optional Vitest setup

The framework continues to support Vitest for existing projects. Configure its public adapters instead of importing framework 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',
],
},
});

Vitest hooks and mocks should use Vitest's APIs. Do not load the Node.js and Vitest adapters in the same test run.