js/test/test

js/test/test.ts

fino:test — TAP-13 test framework with nesting and BDD-style describe/it.

Two equivalent but non-mixable patterns:

Pattern 1: suite + test

import { test, suite } from './test.ts';

test('standalone', (t) => { t.ok(true); });

suite('math', () => {
  test('adds', (t) => { t.equal(1 + 1, 2); });
  test('subs', (t) => { t.equal(2 - 1, 1); });
});

Pattern 2: describe + it + lifecycle hooks

import { describe, it } from './test.ts';

describe('math', () => {
  before(async () => { ... });       // once, before first it
  beforeEach(async () => { ... });   // before each it
  afterEach(async () => { ... });    // after each it (always runs)
  after(async () => { ... });        // once, after last it (always runs)

  it('adds', (t) => { t.equal(1 + 1, 2); });
  it('subs', (t) => { t.equal(2 - 1, 1); });
});

Mixing is forbidden: it() inside suite(), test() inside describe(), suite() inside describe(), or describe() inside suite() all throw. it() and hook functions throw if used at the top level.

TAP-13 output and captured diagnostics

Nested groups produce standard TAP subtests (indented 4 spaces per level):

TAP version 13 1..2 ok 1 - standalone # Subtest: math 1..2 ok 1 - adds ok 2 - subs ok 2 - math # tests 2 # pass 2

Console output from tests is captured by default so passing tests keep TAP output clean. Failing tests print a final # Failure details section with their captured stdout, stderr, and thrown errors. The CLI exposes this as fino test --show-output=failures|always|never: failures is the default, always streams output live for debugging, and never suppresses captured output even when tests fail.

The release contract is intentionally smaller than Node's node:test API: TAP output, name filters, skip reasons, per-test metadata, duration annotations, lifecycle hooks, and captured output are supported. only, todo, per-test timeouts, concurrency controls, subtest creation from an assertion object, and pluggable reporters are not part of this module.

Internal representation

Both APIs share a tree of nodes:

Leaf: { name, fn, children: null, skip: string|null } Group: { name, kind: 'suite'|'describe', children: [, before, beforeEach, after, afterEach, skip: string|null }

_current points at the group being registered into (null = top level). The unified runner _runEntries(entries, depth, parentNode) recurses the tree, applying hooks from parentNode to each leaf inside a describe group.

Types

type TestMetadataValue = string | number | boolean | bigint | null | undefined

Primitive value accepted by TestContext#meta().

type TestMetadata = Record<string, TestMetadataValue>

Metadata object accepted by TestContext#meta().

Keys must match [A-Za-z_][A-Za-z0-9_.:-]*. Repeated calls merge keys, and later values overwrite earlier values.

type SkipOption = boolean | string

Value accepted by the skip registration option.

true skips without a reason, and a string is printed as the TAP skip reason.

type RegisterOptions = { skip?: SkipOption; }

Options accepted by test(), suite(), describe(), and it().

type TestFn = (t: TestContext) => void | Promise<void>

Callback used by test() and it().

The assertion helper collects failures for TAP output. Returning a promise lets the runner await async test work.

type GroupFn = () => void

Registration callback used by suite() and describe().

type HookFn = () => void | Promise<void>

Lifecycle hook callback used by before(), after(), beforeEach(), and afterEach().

Interfaces

interface RunOptions {

Options passed to run() when executing registered tests manually.

filter keeps only matching describe() paths and their ancestors. The CLI passes this from fino test --filter.

showOutput controls test console output. failures captures output and prints it only in final failure diagnostics, always writes output as tests run, and never keeps captured output hidden. The CLI passes this from fino test --show-output.

durations appends runner-owned duration=<time> metadata to every TAP result line. The CLI passes this from fino test --durations.

Properties

filter?: string
showOutput?: ShowOutputMode
durations?: boolean

Classes

class TestContext extends Assert {

Assertion context passed to test() and it() callbacks.

TestContext extends Assert, so existing assertion calls such as t.equal(actual, expected) continue to work. The additional meta() method attaches primitive key/value metadata to the leaf test's TAP result line. Repeated calls merge keys and later values overwrite earlier ones.

Metadata keys must match [A-Za-z_][A-Za-z0-9_.:-]*. Values may be strings, numbers, booleans, bigints, null, or undefined.

import { test } from 'fino:test/test';

test('parses empty input', (t) => {
  t.meta({ case: 'empty', rows: 0 });
  t.equal(parse(''), []);
});

Methods

meta(values: TestMetadata): void

Attach primitive metadata to this test's final TAP result line.

Later calls merge with previous metadata and overwrite duplicate keys. Invalid keys or unsupported value types throw TypeError, which fails the current test like any other thrown error.

Functions

function test(name: string, optsOrFn: TestFn | RegisterOptions, maybeFn?: TestFn): void

Register a test case. Can be top-level or inside suite(). Throws inside describe().

The callback receives an Assert instance that collects all assertion failures before the runner reports the test result. Pass { skip: true } or { skip: 'reason' } as the middle argument to mark the test skipped.

import { test } from 'fino:test/test';

test('adds numbers', (t) => {
  t.equal(1 + 1, 2);
});

function suite(name: string, optsOrFn: GroupFn | RegisterOptions, maybeFn?: GroupFn): void

Register a group of tests. Can be nested inside other suite() calls. Throws inside describe().

Suites are grouping-only; they do not support lifecycle hooks. Use describe() when tests need before, after, beforeEach, or afterEach.

import { suite, test } from 'fino:test/test';

suite('math', () => {
  test('adds', (t) => t.equal(1 + 1, 2));
});

function describe(name: string, optsOrFn: GroupFn | RegisterOptions, maybeFn?: GroupFn): void

Register a BDD-style test group with optional lifecycle hooks. Can be nested inside other describe() calls. Throws inside suite().

The registration callback runs immediately and should only register tests and hooks. Runtime work belongs inside it() callbacks or lifecycle hooks.

import { describe, it } from 'fino:test/test';

describe('api', () => {
  it('responds', (t) => t.ok(true));
});

function it(name: string, optsOrFn: TestFn | RegisterOptions, maybeFn?: TestFn): void

Register a test case inside describe(). Throws outside describe().

The callback may be synchronous or async and receives the same assertion helper used by test(). A parent describe({ skip }) propagates to all child it() calls.

import { describe, it } from 'fino:test/test';

describe('user lookup', () => {
  it('returns a user', async (t) => {
    t.ok(await Promise.resolve({ id: 1 }));
  });
});

function before(fn: HookFn): void

Run fn once before the first it in this describe block. Throws outside describe().

A failing before() marks each entry in the group failed. Use it for shared setup that every test in the block requires.

import { before, describe, it } from 'fino:test/test';

describe('database', () => {
  before(async () => {
    // connect
  });
  it('queries', (t) => t.ok(true));
});

function after(fn: HookFn): void

Run fn once after the last it in this describe block. Always runs even if tests fail. Throws outside describe().

Errors thrown by after() are reported as failures with captured output, while still running after earlier test or hook failures.

import { after, describe, it } from 'fino:test/test';

describe('server', () => {
  after(async () => {
    // close server
  });
  it('starts', (t) => t.ok(true));
});

function beforeEach(fn: HookFn): void

Run fn before each it in this describe block. Throws outside describe().

If beforeEach() fails, the test body is skipped and the entry is reported failed. Use it for per-test state that must be fresh.

import { beforeEach, describe, it } from 'fino:test/test';

describe('counter', () => {
  let value = 0;
  beforeEach(() => { value = 0; });
  it('increments', (t) => t.equal(++value, 1));
});

function afterEach(fn: HookFn): void

Run fn after each it in this describe block. Always runs even if the test fails. Throws outside describe().

Failures from afterEach() are collected with assertion failures from the same test. Use it for cleanup that should be visible when it fails.

import { afterEach, describe, it } from 'fino:test/test';

describe('temp files', () => {
  afterEach(async () => {
    // remove temp files
  });
  it('writes', (t) => t.ok(true));
});

async function run(options: RunOptions = {}): Promise<void>

Run all registered tests and print TAP-13 output.

Called automatically by the fino test command. User test files only need to call test() / suite() / describe() — never run().

import { run, test } from 'fino:test/test';

test('manual runner', (t) => t.ok(true));
await run({ filter: 'manual' });