Skip to main content

Unit Testing

Zelt provides @zeltjs/testing package with utilities for unit testing your services with dependency injection support.

Installation

pnpm add -D @zeltjs/testing

Test Runner Adapters

Import from the adapter for your test runner. This auto-registers cleanup via afterAll.

Vitest

// Reason: import-only example for test framework setup
import { onTest, createTestTarget } from '@zeltjs/testing/vitest';

Jest

// Reason: import-only example for test framework setup
import { onTest, createTestTarget } from '@zeltjs/testing/jest';

Bun

// Reason: import-only example for test framework setup
import { onTest, createTestTarget } from '@zeltjs/testing/bun';

Node.js Test Runner

// Reason: import-only example for test framework setup
import { onTest, createTestTarget } from '@zeltjs/testing/node';

Manual Setup

If you prefer manual control or use a different test runner, import from the base package and call shutdownAll() yourself:

// Reason: import-only example for test framework setup
import { onTest, createTestTarget, shutdownAll } from '@zeltjs/testing';
import { afterAll } from 'your-test-runner';

afterAll(shutdownAll);

createTestTarget

createTestTarget is the primary testing utility for instantiating services with dependency injection. It automatically handles lifecycle management and cleanup.

describe('UserService', () => {
  it('should create user', async () => {
    const { target } = await createTestTarget(UserService);

    const user = await target.create({ name: 'Alice' });
    expect(user.name).toBe('Alice');
  });
});

Options

OptionTypeDescription
configsClass[]Configuration classes to register
overridesOverride[]Mock implementations for dependencies

Return Value

PropertyTypeDescription
targetTThe instantiated service
get(cls) => TResolve additional dependencies from the container
shutdown() => Promise<void>Cleanup function (auto-registered to shutdownAll)

Mocking Dependencies

Use overrides to replace real implementations with mocks (Solitary Unit Test):

describe('UserService', () => {
  it('should send welcome email', async () => {
    const mockEmailService = {
      send: vi.fn().mockResolvedValue(undefined),
    };

    const { target } = await createTestTarget(UserService, {
      overrides: [
        { provide: EmailService, useValue: mockEmailService },
      ],
    });

    await target.register({ email: 'alice@example.com' });
    expect(mockEmailService.send).toHaveBeenCalledWith(
      'alice@example.com',
      expect.stringContaining('Welcome')
    );
  });
});

Lifecycle Management

createTestTarget automatically registers shutdown functions to shutdownAll:

  1. Startup: All registered Lifecycle implementations are started when the test target is created
  2. Shutdown: Call shutdownAll() in your test runner's global teardown (handled automatically by adapter imports)

This ensures resources are properly cleaned up even if tests fail.

Testing Commands

When testing CLI commands, you need to create a fresh app instance for each test. App instances cannot be reused after createRuntime() is called.

The Problem

Reusing a global app instance causes errors:

describe('GreetCommand', () => {
  it('test 1', async () => {
    const nodeApp = await onNode(app);
    await nodeApp.commands.execCommand(['greet']);
    // works
  });

  it('test 2 — reusing the same app instance throws', async () => {
    // ❌ Cannot call onNode() on an already-ready app
    await expect(onNode(app)).rejects.toThrow(ZeltLifecycleStateError);
  });
});

Once onNode() is called, the app transitions to ready state. Calling onNode() again on the same instance fails because lifecycle hooks cannot be re-registered.

The Solution

Create a new app instance for each test:

describe('GreetCommand', () => {
  let nodeApp:
    | {
        shutdown(): Promise<void>;
        commands: { execCommand(argv: readonly string[]): Promise<{ exitCode: number }> };
      }
    | undefined;

  afterEach(async () => {
    await nodeApp?.shutdown();
  });

  it('test 1', async () => {
    const app = createApp([command([GreetCommand])]);
    nodeApp = await onNode(app);
    await nodeApp.commands.execCommand(['greet']);
    // works
  });

  it('test 2', async () => {
    const app = createApp([command([GreetCommand])]);
    nodeApp = await onNode(app);
    await nodeApp.commands.execCommand(['greet']);
    // works — fresh app instance
  });
});

Using a Factory Function

For cleaner tests, extract app creation into a factory:

function createTestApp() {
  return createApp([command([GreetCommand])]);
}

describe('GreetCommand', () => {
  let nodeApp:
    | {
        shutdown(): Promise<void>;
        commands: { execCommand(argv: readonly string[]): Promise<{ exitCode: number }> };
      }
    | undefined;

  afterEach(async () => {
    await nodeApp?.shutdown();
  });

  it('executes successfully', async () => {
    nodeApp = await onNode(createTestApp());
    const result = await nodeApp.commands.execCommand(['greet']);
    // assert result
  });
});