Migrating from Jest to node:test (dk-server-foundation)
This guide documents how to migrate a project that uses @zyno-io/dk-server-foundation from Jest to Node.js's built-in node:test runner. It is written as a reference for Claude and other AI assistants performing this migration.
Why node:test?
Since projects already build to CJS via tsc (which runs the Deepkit type compiler), testing against the built output eliminates test-time compilation entirely — Deepkit reflection is already compiled in.
Benefits:
- Tests run against the same compiled output as production
- Faster test startup (no transform pipeline)
- Zero-config Deepkit reflection (already compiled by
tsc)
Step-by-step migration
1. Create tsconfig.test.json
Extend the base tsconfig.json to also compile test files:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": false,
"declarationMap": false
},
"include": ["src/**/*", "tests/**/*", "types.d.ts"]
}This compiles tests/ into dist/tests/ alongside dist/src/. Relative imports like ../../src/foo naturally resolve to dist/src/foo in the output.
2. Create globalSetup.ts
Create tests/shared/globalSetup.ts with setup and teardown exports:
import { TestingHelpers } from '@zyno-io/dk-server-foundation';
export async function setup() {
TestingHelpers.setDefaultDatabaseConfig({
MYSQL_HOST: 'localhost',
MYSQL_PORT: 3306,
MYSQL_USER: 'root',
MYSQL_PASSWORD_SECRET: 'secret'
});
await TestingHelpers.cleanupTestDatabases('myapp_test');
}
export async function teardown() {
await TestingHelpers.cleanupTestDatabases('myapp_test');
}The dksf-test runner will automatically load dist/tests/shared/globalSetup.js if it exists, calling setup() before tests and teardown() after.
3. Update package.json scripts
{
"scripts": {
"test": "tsc -p tsconfig.test.json && dksf-test",
"test:debug": "tsc -p tsconfig.test.json && node --inspect=9201 node_modules/.bin/dksf-test"
}
}dksf-test sets APP_ENV=test and TZ=UTC automatically, finds all *.spec.js files in dist/tests/, and runs them with node --test. You can pass specific files and node flags:
# Run a single test file (source path is mapped to dist automatically)
yarn test tests/my-feature.spec.ts
# Run all tests in a directory
yarn test tests/integration/
# Pass node flags
yarn test --test-name-pattern="my test"Remove Jest dependencies and configuration files.
4. Migrate test imports
Add explicit imports at the top of each test file:
import { describe, it, before, after, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';Rename any beforeAll/afterAll calls to before/after.
5. Migrate assertions
Simple replacements
| Jest | node:test |
|---|---|
expect(x).toBe(y) | assert.strictEqual(x, y) |
expect(x).toEqual(y) | assert.deepStrictEqual(x, y) |
expect(x).toBeDefined() | assert.notStrictEqual(x, undefined) |
expect(x).toBeUndefined() | assert.strictEqual(x, undefined) |
expect(x).toBeNull() | assert.strictEqual(x, null) |
expect(x).toBeTruthy() | assert.ok(x) |
expect(x).toBeFalsy() | assert.ok(!x) |
expect(x).toBeInstanceOf(Y) | assert.ok(x instanceof Y) |
expect(x).toHaveLength(n) | assert.strictEqual(x.length, n) |
expect(x).toContain(y) | assert.ok(x.includes(y)) |
expect(x).toMatch(/re/) | assert.match(x, /re/) |
expect(x).toBeGreaterThan(n) | assert.ok(x > n) |
expect(x).toBeGreaterThanOrEqual(n) | assert.ok(x >= n) |
expect(x).toBeLessThan(n) | assert.ok(x < n) |
expect(x).toHaveProperty(k) | assert.ok(k in x) |
expect(x).not.toBe(y) | assert.notStrictEqual(x, y) |
expect(x).not.toEqual(y) | assert.notDeepStrictEqual(x, y) |
expect(() => fn()).toThrow(msg) | assert.throws(() => fn(), { message: msg }) |
expect(() => fn()).toThrow(ErrClass) | assert.throws(() => fn(), ErrClass) |
expect(() => fn()).toThrow(/re/) | assert.throws(() => fn(), /re/) |
expect(() => fn()).not.toThrow() | assert.doesNotThrow(() => fn()) |
expect(promise).rejects.toThrow(msg) | await assert.rejects(promise, { message: msg }) |
expect(promise).rejects.toThrow(/re/) | await assert.rejects(promise, /re/) |
expect(promise).rejects.toThrow(ErrClass) | await assert.rejects(promise, ErrClass) |
toMatchObject — use matchesObject from dk-server-foundation
import { matchesObject } from '@zyno-io/dk-server-foundation/testing/expect';
// Before
expect(result).toMatchObject({ name: 'Fred', age: 30 });
// After
matchesObject(result, { name: 'Fred', age: 30 });Asymmetric matchers — use helpers from dk-server-foundation
import { matchesObject, anyOf, stringContaining, arrayContaining, anything, objectContaining } from '@zyno-io/dk-server-foundation/testing/expect';
// Before
expect(result).toMatchObject({ id: expect.any(Number), name: expect.stringContaining('Fred') });
// After
matchesObject(result, { id: anyOf(Number), name: stringContaining('Fred') });Available matchers:
| Jest | dk-server-foundation |
|---|---|
expect.any(Type) | anyOf(Type) |
expect.anything() | anything() |
expect.stringContaining(s) | stringContaining(s) |
expect.arrayContaining(arr) | arrayContaining(arr) |
expect.objectContaining(obj) | objectContaining(obj) |
Mock assertions — use assertCalledWith
import { assertCalledWith, anyOf } from '@zyno-io/dk-server-foundation/testing/expect';
// Before
expect(mockFn).toHaveBeenCalledWith('arg1', expect.any(Number));
// After
assertCalledWith(mockFn, 'arg1', anyOf(Number));Other mock assertion conversions:
| Jest | node:test |
|---|---|
expect(fn).toHaveBeenCalledTimes(n) | assert.strictEqual(fn.mock.callCount(), n) |
expect(fn).toHaveBeenCalled() | assert.ok(fn.mock.callCount() > 0) |
expect(fn).not.toHaveBeenCalled() | assert.strictEqual(fn.mock.callCount(), 0) |
fn.mock.calls[0][1] | fn.mock.calls[0].arguments[1] |
fn.mockClear() | fn.mock.resetCalls() |
6. Migrate mock APIs
| Jest | node:test |
|---|---|
jest.fn() | mock.fn() |
jest.fn(() => value) | mock.fn(() => value) |
jest.spyOn(obj, method) | mock.method(obj, method) |
jest.spyOn(obj, method).mockReturnValue(x) | mock.method(obj, method, () => x) |
jest.spyOn(obj, method).mockResolvedValue(x) | mock.method(obj, method, async () => x) |
jest.clearAllMocks() | Per-mock fn.mock.resetCalls() |
jest.restoreAllMocks() | mock.restoreAll() |
jest.useFakeTimers() | mock.timers.enable() |
jest.advanceTimersByTime(n) | mock.timers.tick(n) |
jest.useRealTimers() | mock.timers.reset() |
jest.setTimeout(n) | --test-timeout=N flag in test runner |
mockReturnValue / mockImplementation on existing mocks
node:test does not have mockReturnValue() or mockImplementation() on mock instances. Instead, pass the implementation when creating the mock:
// Jest
const fn = jest.fn();
fn.mockReturnValue(42);
fn.mockImplementation(x => x * 2);
// node:test
const fn = mock.fn(() => 42);
// To change implementation later, create a new mock or use a wrapper:
let impl = (x: number) => x * 2;
const fn = mock.fn((x: number) => impl(x));
impl = (x: number) => x * 3; // change behaviormockReturnValueOnce — no direct equivalent
Use a counter or array of return values:
// Jest
fn.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue(0);
// node:test
const returns = [1, 2];
const fn = mock.fn(() => returns.shift() ?? 0);7. Migrate module mocking
Since tests run against CJS output, use require.cache manipulation with TestingHelpers.resetSrcModuleCache():
// Before (Jest)
jest.doMock('../../src/app/resolver', () => ({
getAppConfig: () => mockConfig
}));
const { MyModule } = await import('../../src/my-module');
// After (node:test with CJS)
import { TestingHelpers } from '@zyno-io/dk-server-foundation';
TestingHelpers.resetSrcModuleCache();
const resolver = require('../../src/app/resolver');
resolver.getAppConfig = () => mockConfig;
const { MyModule } = require('../../src/my-module');This works because CJS import { x } from 'y' compiles to const y_1 = require('y'), preserving a reference to the module exports object. Patching resolver.getAppConfig is visible to all subsequently-required modules.
8. Migrate it.skip, it.only, it.each
it.skip / describe.skip — same syntax, works identically:
it.skip('not yet implemented', () => { ... });
describe.skip('disabled suite', () => { ... });it.only / describe.only — same syntax, but only affects the current file. To filter tests across files, use --test-name-pattern:
node --test --test-name-pattern="my test name" dist/tests/**/*.spec.jsit.each / describe.each — no built-in equivalent. Use a loop:
// Jest
it.each([
[1, 2, 3],
[4, 5, 9]
])('adds %i + %i = %i', (a, b, expected) => {
expect(a + b).toBe(expected);
});
// node:test
for (const [a, b, expected] of [
[1, 2, 3],
[4, 5, 9]
]) {
it(`adds ${a} + ${b} = ${expected}`, () => {
assert.strictEqual(a + b, expected);
});
}9. Migrate test timeouts
| Jest | node:test |
|---|---|
it('name', fn, 30_000) | it('name', { timeout: 30_000 }, fn) |
beforeAll(fn, 10_000) | before(fn, { timeout: 10_000 }) |
10. Environment detection
The isTest constant in dk-server-foundation checks process.env.APP_ENV === 'test'. The config loader auto-detects node --test and sets APP_ENV=test if it wasn't already set, so test-time behaviors (e.g., skipping worker job queueing) work automatically.
11. Clean up Redis connections
If your tests use Redis-backed features (mutex, cache, leader election, mesh), call disconnectAllRedis() in your top-level after() hook to properly close memoized connections. Without this, node:test child processes will hang waiting for the event loop to drain.
import { after } from 'node:test';
import { disconnectAllRedis } from '@zyno-io/dk-server-foundation';
after(async () => {
await tf.stop();
await disconnectAllRedis();
});12. Remove old config files
Delete these files if they exist:
jest.config.js/jest.config.ts
Troubleshooting
APP_ENV must be specified in the environment
Set APP_ENV=test in your test runner script (see step 2). The config loader auto-detects node --test, but if you use a custom runner that spawns node separately, set the env var explicitly.
Tests hang or time out
node:test runs test files sequentially by default. If your before/after hooks don't complete (e.g., database connection not closing), the entire suite will hang. Ensure all connections are properly closed in after() hooks. See step 10 for Redis cleanup.
beforeAll / afterAll is not defined
Replace with before/after from node:test:
import { before, after } from 'node:test';Source maps not working
Ensure --enable-source-maps is passed to node in your test runner. The tsconfig.json must have "sourceMap": true.
Mock function call structure differences
In node:test, mock call arguments are accessed via .arguments property, not as a flat array:
// Jest
fn.mock.calls[0][1]; // second argument of first call
// node:test
fn.mock.calls[0].arguments[1]; // second argument of first call