Skip to content

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:

json
{
    "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:

typescript
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

json
{
    "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:

bash
# 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:

typescript
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

Jestnode: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

typescript
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

typescript
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:

Jestdk-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

typescript
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:

Jestnode: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

Jestnode: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:

typescript
// 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 behavior

mockReturnValueOnce — no direct equivalent

Use a counter or array of return values:

typescript
// 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():

typescript
// 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:

typescript
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:

bash
node --test --test-name-pattern="my test name" dist/tests/**/*.spec.js

it.each / describe.each — no built-in equivalent. Use a loop:

typescript
// 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

Jestnode: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.

typescript
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:

typescript
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:

typescript
// Jest
fn.mock.calls[0][1]; // second argument of first call

// node:test
fn.mock.calls[0].arguments[1]; // second argument of first call

Released under the MIT License.