Skip to content

Testing

Structured test framework built on Node.js's native node:test module that organizes tests into plans, cases, and handlers with lifecycle hooks and shared context.

Quick Reference

ItemValue
Package@venizia/ignis-helpers
ClassesTestPlan, BaseTestPlan, TestCase, TestCaseHandler, BaseTestCaseHandler, TestDescribe, AppTestDescribe, TestCaseDecisions
ExtendsBaseTestPlan (uses Logger + MemoryStorageHelper, does not extend BaseHelper)
RuntimesBoth

Import Paths

typescript
import {
  TestPlan,
  BaseTestPlan,
  TestCase,
  TestCaseHandler,
  BaseTestCaseHandler,
  TestDescribe,
  AppTestDescribe,
  TestCaseDecisions,
} from '@venizia/ignis-helpers';

import type {
  ITestContext,
  ITestPlan,
  ITestPlanOptions,
  ITestHooks,
  TTestHook,
  ITestCase,
  ITestCaseHandler,
  ITestCaseInput,
  ITestCaseHandlerOptions,
  ITestCaseOptions,
  TTestCaseDecision,
} from '@venizia/ignis-helpers';

Creating an Instance

A test suite is assembled from three layers: a TestCaseHandler (execution + validation logic), a TestCase (metadata wrapper), and a TestPlan (orchestrator with hooks and shared context). The plan is then executed via TestDescribe.

typescript
import {
  TestPlan,
  TestDescribe,
  TestCase,
  TestCaseHandler,
  TestCaseDecisions,
} from '@venizia/ignis-helpers';
import type { ITestContext, TTestCaseDecision } from '@venizia/ignis-helpers';

// 1. Define a handler
class MyTestHandler extends TestCaseHandler {
  async execute() {
    return { result: 'some-value' };
  }

  getValidator() {
    return (opts: { result: string }): TTestCaseDecision => {
      if (opts.result === 'some-value') {
        return TestCaseDecisions.SUCCESS;
      }
      return TestCaseDecisions.FAIL;
    };
  }
}

// 2. Create a test plan
const myTestPlan = TestPlan.newInstance({
  scope: 'My Feature',
  hooks: {
    before: async (testPlan) => console.log('Starting tests for:', testPlan.scope),
    after: async () => console.log('Finished tests.'),
  },
  testCases: [
    TestCase.withOptions({
      code: 'MY-FEATURE-001',
      description: 'It should return the correct value',
      expectation: 'The result should be "some-value"',
      handler: new MyTestHandler({ context: {} as any }),
    }),
  ],
});

// 3. Run the test plan
TestDescribe.withTestPlan({ testPlan: myTestPlan }).run();

Usage

Shared Context

TestPlan implements ITestContext, providing bind() and getSync() methods backed by a MemoryStorageHelper registry. Use this to share data between lifecycle hooks and test case handlers.

typescript
import {
  TestPlan,
  TestDescribe,
  TestCase,
  TestCaseHandler,
  TestCaseDecisions,
} from '@venizia/ignis-helpers';
import type { ITestPlan, TTestCaseDecision } from '@venizia/ignis-helpers';

class SecureApiHandler extends TestCaseHandler<{ token: string }> {
  async execute() {
    const token = this.context.getSync<string>({ key: 'token' });
    const response = await app.request('/api/secure-data', {
      headers: { Authorization: `Bearer ${token}` },
    });
    return { status: response.status };
  }

  getValidator() {
    return (opts: { status: number }): TTestCaseDecision => {
      return opts.status === 200
        ? TestCaseDecisions.SUCCESS
        : TestCaseDecisions.FAIL;
    };
  }
}

const authTestPlan = TestPlan.newInstance<{ token: string }>({
  scope: 'Authentication',
  hooks: {
    before: async (testPlan: ITestPlan<{ token: string }>) => {
      const token = await generateTestToken();
      testPlan.bind({ key: 'token', value: token });
    },
  },
  testCases: [
    TestCase.withOptions({
      code: 'AUTH-001',
      description: 'Secure endpoint returns 200 with valid token',
      expectation: 'Response status is 200',
      handler: new SecureApiHandler({ context: {} as any }),
    }),
  ],
});

TestDescribe.withTestPlan({ testPlan: authTestPlan }).run();

Test Case Resolver

Instead of (or in addition to) providing testCases directly, supply a testCaseResolver function that dynamically generates test cases at plan construction time. The resolver receives the plan context. Both testCases and testCaseResolver results are concatenated.

typescript
const plan = TestPlan.newInstance({
  scope: 'Dynamic Tests',
  testCaseResolver: ({ context }) => {
    return endpoints.map((endpoint) =>
      TestCase.withOptions({
        code: `EP-${endpoint.name}`,
        description: `Test ${endpoint.name}`,
        expectation: 'Returns 200',
        handler: new EndpointHandler({ context }),
      }),
    );
  },
});

Handler Arguments

Handlers support args (static) and argResolver (dynamic) for injecting test-specific input data. If both are omitted, getArguments() returns null. If both are provided, args takes priority.

typescript
class CreateUserHandler extends TestCaseHandler<{}, { name: string }> {
  async execute() {
    const args = this.getArguments(); // { name: 'Alice' }
    return await userService.create(args!);
  }

  getValidator() {
    return (user: { id: string; name: string }): TTestCaseDecision => {
      return user.name === 'Alice'
        ? TestCaseDecisions.SUCCESS
        : TestCaseDecisions.FAIL;
    };
  }
}

// Static args
new CreateUserHandler({ context: {} as any, args: { name: 'Alice' } });

// Dynamic args via resolver
new CreateUserHandler({
  context: {} as any,
  argResolver: () => ({ name: 'Alice' }),
});

Lifecycle Hooks

Hooks are registered via ITestPlanOptions.hooks and executed by TestDescribe using node:test's before, beforeEach, after, and afterEach functions.

HookWhenPurpose
beforeBefore all testsSetup (e.g., start server, seed database)
beforeEachBefore each testReset state
afterEachAfter each testCleanup per test
afterAfter all testsCleanup (e.g., close connections)

NOTE

Hook callbacks receive the full ITestPlan instance (not just the context), giving access to bind(), getSync(), getTestCases(), getHooks(), and getRegistry().

typescript
const plan = TestPlan.newInstance<{ db: Database }>({
  scope: 'With Hooks',
  hooks: {
    before: async (testPlan) => {
      const db = await connectDatabase();
      testPlan.bind({ key: 'db', value: db });
    },
    afterEach: async (testPlan) => {
      const db = testPlan.getSync<Database>({ key: 'db' });
      await db.truncateAll();
    },
    after: async (testPlan) => {
      const db = testPlan.getSync<Database>({ key: 'db' });
      await db.close();
    },
  },
  testCases: [/* ... */],
});

Modifying Test Cases After Construction

BaseTestPlan exposes withTestCases() for replacing the test case array after construction. This returns this for chaining.

typescript
const plan = TestPlan.newInstance({ scope: 'Mutable' });
plan.withTestCases({
  testCases: [
    TestCase.withOptions({
      code: 'TC-001',
      description: 'Added after construction',
      expectation: 'Should pass',
      handler: myHandler,
    }),
  ],
});

WARNING

withTestCases() fully replaces the existing test case array rather than appending to it.

TestCaseDecisions

Test case validators must return one of these decision constants:

DecisionValueMeaning
SUCCESS'200_SUCCESS'Test passed
FAIL'000_FAIL'Test failed
UNKNOWN'000_UNKNOWN'No decision reached (treated as failure by _execute())

The _execute() method on TestCaseHandler calls assert.equal(validateRs, TestCaseDecisions.SUCCESS), so any value other than '200_SUCCESS' causes the test to fail.

API Summary

Class Hierarchy

BaseTestCaseHandler (abstract)
  +-- TestCaseHandler (abstract) -- execute(), getValidator(), validate()
                                       +-- Your concrete handler

BaseTestPlan (abstract)
  +-- TestPlan -- newInstance()

TestDescribe -- withTestPlan(), run()
  +-- AppTestDescribe

ITestPlanOptions

OptionTypeDefaultDescription
scopestring--Name for the test suite (used as the describe() label). Required.
hooksITestHooks<R>{}Lifecycle hooks (before, beforeEach, after, afterEach).
testCasesArray<ITestCase<R>>[]Static list of test cases.
testCaseResolver(opts: { context: ITestContext<R> }) => Array<ITestCase<R>>undefinedDynamic test case generator, receives the plan context.

BaseTestPlan / TestPlan Methods

MethodReturnsDescription
TestPlan.newInstance(opts)TestPlan<R>Static factory method.
withTestCases({ testCases })thisReplace the plan's test case array.
getTestCases()Array<ITestCase<R>>Get all registered test cases.
getHooks()ITestHooks<R>Get all lifecycle hooks.
getHook({ key })TTestHook<R> | nullGet a specific hook by name.
getRegistry()MemoryStorageHelper<R>Get the backing context registry.
getContext()ITestContext<R>Returns this (the plan is the context).
bind({ key, value })voidStore a value in the context registry.
getSync({ key })TRetrieve a value from the context registry.
execute()voidRun all test cases via node:test it() blocks.

ITestCaseOptions

OptionTypeDefaultDescription
codestring--Unique test case identifier (e.g., 'AUTH-001'). Required, must be non-empty.
namestringundefinedOptional short name for the test case.
descriptionstring--What the test case does. Required, must be non-empty.
expectationstringundefinedExpected outcome description. Validated as required and non-empty by constructor.
handlerTestCaseHandler<R, I>--The handler that executes and validates the test. Required.

TestCase Methods

MethodReturnsDescription
TestCase.withOptions(opts)TestCase<R, I>Static factory. Validates code, description, expectation are non-empty.
run()Promise<void>Delegates to handler._execute().

ITestCaseHandlerOptions

OptionTypeDefaultDescription
scopestring'TestCaseHandler'Logger scope.
contextITestContext<R>--The test plan context for shared state. Required.
argsI | nullnullStatic arguments for the handler.
argResolver(...args: any[]) => I | nullundefinedDynamic argument resolver, called once at construction.
validator(opts: any) => ValueOrPromise<TTestCaseDecision>undefinedValidator function. Overrides getValidator() if provided.

TestCaseHandler Methods

MethodReturnsDescription
execute()ValueOrPromise<any>Abstract. Perform the action under test.
getValidator()((opts) => ValueOrPromise<TTestCaseDecision>) | nullAbstract. Return a validator function or null.
validate(opts)ValueOrPromise<TTestCaseDecision>Runs the validator (from this.validator or getValidator()).
getArguments()I | nullReturns the handler's args.
_execute()Promise<void>Internal. Calls execute(), then validate(), then assert.equal(result, SUCCESS).

TestDescribe Methods

MethodReturnsDescription
TestDescribe.withTestPlan({ testPlan })TestDescribe<R>Static factory method.
run()voidWraps the test plan in a node:test describe() block with all lifecycle hooks wired up. Throws if testPlan is not set.

Type Definitions

ITestContext

typescript
interface ITestContext<R extends object> {
  scope: string;
  getRegistry: () => MemoryStorageHelper<R>;
  bind: <T>(opts: { key: string; value: T }) => void;
  getSync: <E = AnyType>(opts: { key: keyof R }) => E;
}

ITestPlan

typescript
interface ITestPlan<R extends object = {}> extends ITestContext<R> {
  getTestCases: () => Array<ITestCase<R>>;
  getContext: () => ITestContext<R>;
  getHooks: () => ITestHooks<R>;
  getHook: (opts: { key: keyof ITestHooks<R> }) => TTestHook<R> | null;
  execute: () => ValueOrPromise<void>;
}

ITestHooks / TTestHook

typescript
type TTestHook<R extends object> = (testPlan: ITestPlan<R>) => ValueOrPromise<void>;

interface ITestHooks<R extends object> {
  before?: TTestHook<R>;
  beforeEach?: TTestHook<R>;
  after?: TTestHook<R>;
  afterEach?: TTestHook<R>;
}

ITestCase

typescript
interface ITestCase<R extends object = {}, I extends object = {}> {
  code: string;
  name?: string;
  description: string;
  expectation?: string;
  handler: ITestCaseHandler<R, I>;
  run: () => ValueOrPromise<void>;
}

ITestCaseHandler

typescript
interface ITestCaseHandler<R extends object = {}, I extends object = {}> {
  context: ITestContext<R>;
  args: I | null;
  validator?: (args: AnyObject) => ValueOrPromise<TTestCaseDecision>;
}

TTestCaseDecision

typescript
type TTestCaseDecision = '000_UNKNOWN' | '000_FAIL' | '200_SUCCESS';

Troubleshooting

"[validate] Invalid test case validator!"

Cause: TestCaseHandler.validate() is called but neither a validator was passed in the constructor options nor does getValidator() return a function.

Fix: Implement getValidator() to return a validation function, or pass a validator in the handler options:

typescript
// Option 1: Implement getValidator()
class MyHandler extends TestCaseHandler {
  execute() { return { ok: true }; }
  getValidator() {
    return (opts: { ok: boolean }) =>
      opts.ok ? TestCaseDecisions.SUCCESS : TestCaseDecisions.FAIL;
  }
}

// Option 2: Pass validator in constructor options
new MyHandler({
  context: {} as any,
  validator: (opts) => opts.ok ? TestCaseDecisions.SUCCESS : TestCaseDecisions.FAIL,
});

"[TestCase] Invalid value for key: <key> | value: <value> | Opts: ..."

Cause: TestCase.withOptions() validates that code, description, and expectation are all non-empty strings. If any is missing or empty, this error is thrown.

Fix: Ensure all three required fields are provided:

typescript
// Wrong -- missing expectation
TestCase.withOptions({
  code: 'TC-001',
  description: 'Some test',
  handler: myHandler,
});

// Correct
TestCase.withOptions({
  code: 'TC-001',
  description: 'Some test',
  expectation: 'Should return 200',
  handler: myHandler,
});

"[run] Invalid test plan!"

Cause: TestDescribe.run() was called but this.testPlan is falsy. This happens if the TestDescribe instance was constructed without a valid test plan.

Fix: Ensure a valid ITestPlan is provided via the constructor or withTestPlan():

typescript
const describe = TestDescribe.withTestPlan({ testPlan: myTestPlan });
describe.run();

Tests run but always fail with assertion error

Cause: The _execute() method on TestCaseHandler asserts that the validation result equals TestCaseDecisions.SUCCESS ('200_SUCCESS'). If your validator returns undefined, null, or a string that is not exactly '200_SUCCESS', the assertion fails.

Fix: Ensure your validator always returns one of the TestCaseDecisions constants and that the success path returns TestCaseDecisions.SUCCESS explicitly:

typescript
getValidator() {
  return (opts: { value: number }): TTestCaseDecision => {
    // Always return an explicit decision constant
    return opts.value > 0
      ? TestCaseDecisions.SUCCESS
      : TestCaseDecisions.FAIL;
  };
}

"Failed to execute test handler | Error: ..."

Cause: An unhandled exception was thrown inside execute() or validate() within _execute(). The error is caught and logged, but validateRs remains TestCaseDecisions.UNKNOWN, causing the subsequent assert.equal to fail.

Fix: Check the logged error message for the root cause. Common issues include missing context values (calling getSync() for a key that was never bind()-ed) or network/database errors in the handler's execute() method.

See Also