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
| Item | Value |
|---|---|
| Package | @venizia/ignis-helpers |
| Classes | TestPlan, BaseTestPlan, TestCase, TestCaseHandler, BaseTestCaseHandler, TestDescribe, AppTestDescribe, TestCaseDecisions |
| Extends | BaseTestPlan (uses Logger + MemoryStorageHelper, does not extend BaseHelper) |
| Runtimes | Both |
Import Paths
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.
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.
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.
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.
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.
| Hook | When | Purpose |
|---|---|---|
before | Before all tests | Setup (e.g., start server, seed database) |
beforeEach | Before each test | Reset state |
afterEach | After each test | Cleanup per test |
after | After all tests | Cleanup (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().
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.
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:
| Decision | Value | Meaning |
|---|---|---|
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()
+-- AppTestDescribeITestPlanOptions
| Option | Type | Default | Description |
|---|---|---|---|
scope | string | -- | Name for the test suite (used as the describe() label). Required. |
hooks | ITestHooks<R> | {} | Lifecycle hooks (before, beforeEach, after, afterEach). |
testCases | Array<ITestCase<R>> | [] | Static list of test cases. |
testCaseResolver | (opts: { context: ITestContext<R> }) => Array<ITestCase<R>> | undefined | Dynamic test case generator, receives the plan context. |
BaseTestPlan / TestPlan Methods
| Method | Returns | Description |
|---|---|---|
TestPlan.newInstance(opts) | TestPlan<R> | Static factory method. |
withTestCases({ testCases }) | this | Replace 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> | null | Get 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 }) | void | Store a value in the context registry. |
getSync({ key }) | T | Retrieve a value from the context registry. |
execute() | void | Run all test cases via node:test it() blocks. |
ITestCaseOptions
| Option | Type | Default | Description |
|---|---|---|---|
code | string | -- | Unique test case identifier (e.g., 'AUTH-001'). Required, must be non-empty. |
name | string | undefined | Optional short name for the test case. |
description | string | -- | What the test case does. Required, must be non-empty. |
expectation | string | undefined | Expected outcome description. Validated as required and non-empty by constructor. |
handler | TestCaseHandler<R, I> | -- | The handler that executes and validates the test. Required. |
TestCase Methods
| Method | Returns | Description |
|---|---|---|
TestCase.withOptions(opts) | TestCase<R, I> | Static factory. Validates code, description, expectation are non-empty. |
run() | Promise<void> | Delegates to handler._execute(). |
ITestCaseHandlerOptions
| Option | Type | Default | Description |
|---|---|---|---|
scope | string | 'TestCaseHandler' | Logger scope. |
context | ITestContext<R> | -- | The test plan context for shared state. Required. |
args | I | null | null | Static arguments for the handler. |
argResolver | (...args: any[]) => I | null | undefined | Dynamic argument resolver, called once at construction. |
validator | (opts: any) => ValueOrPromise<TTestCaseDecision> | undefined | Validator function. Overrides getValidator() if provided. |
TestCaseHandler Methods
| Method | Returns | Description |
|---|---|---|
execute() | ValueOrPromise<any> | Abstract. Perform the action under test. |
getValidator() | ((opts) => ValueOrPromise<TTestCaseDecision>) | null | Abstract. Return a validator function or null. |
validate(opts) | ValueOrPromise<TTestCaseDecision> | Runs the validator (from this.validator or getValidator()). |
getArguments() | I | null | Returns the handler's args. |
_execute() | Promise<void> | Internal. Calls execute(), then validate(), then assert.equal(result, SUCCESS). |
TestDescribe Methods
| Method | Returns | Description |
|---|---|---|
TestDescribe.withTestPlan({ testPlan }) | TestDescribe<R> | Static factory method. |
run() | void | Wraps the test plan in a node:test describe() block with all lifecycle hooks wired up. Throws if testPlan is not set. |
Type Definitions
ITestContext
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
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
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
interface ITestCase<R extends object = {}, I extends object = {}> {
code: string;
name?: string;
description: string;
expectation?: string;
handler: ITestCaseHandler<R, I>;
run: () => ValueOrPromise<void>;
}ITestCaseHandler
interface ITestCaseHandler<R extends object = {}, I extends object = {}> {
context: ITestContext<R>;
args: I | null;
validator?: (args: AnyObject) => ValueOrPromise<TTestCaseDecision>;
}TTestCaseDecision
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:
// 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:
// 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():
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:
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
Related Concepts:
- Dependency Injection -- Testing with DI
- Application -- Application lifecycle in tests
Other Helpers:
- Helpers Index -- All available helpers
External Resources:
- Node.js Test Runner -- Native
node:testmodule documentation
- Node.js Test Runner -- Native