ubq 0.0.7(beta)

This page as markdown

Test API

The test file API, and what is absent on purpose.

The API for test files. Status: the user accepted it on 2026-11-11 (D15 to D18 in 02-decisions.md).

The type sketch is in experiments/api/ubq.ts. good.ts there compiles, and each line of bad.ts gives the compile error that this document claims. mock-rt.ts proves the mock call at run time on Node and Deno.

Not in this document: the CLI, the config, the fake clock, and snapshots. Each one gets its own document.

1. A full example#

import { test, describe, assert, spy, testdir, mockImport } from 'ubqtest'
import { parse } from '../src/parse.ts'

describe('parse', () => {
  test('circle', () => {
    const shape = parse('c')
    assert(shape)                       // narrows away undefined
    assert.equal(shape.kind, 'circle')  // narrows the union
    assert.equal(shape.r, 1)
  })

  test('unknown input', () => {
    assert.throws(() => parse('?'), /unknown shape/)
  })

  for (const [input, kind] of [['c', 'circle'], ['s', 'square']] as const) {
    test(`kind of ${input}`, () => {
      assert.match(parse(input), { kind })
    })
  }
})

test('greet reads the user from the db', async () => {
  const fetchUser = spy(async (id: number) => ({ id, name: 'ada' }))
  const { greet } = await mockImport(() => import('../src/greet.ts'), [
    [() => import('../src/db.ts'), { fetchUser }],
  ])
  assert.equal(await greet(7), 'hello ada')
  assert.deepEqual(fetchUser.calls[0]?.args, [7])
})

test('writes a report', { timeout: 500 }, async ({ signal }) => {
  const dir = testdir({ 'in.json': '{"n":1}' })
  await writeReport(dir, { signal })
  assert.match(readFileSync(`${dir}/report.txt`, 'utf8'), /n = 1/)
})

node test/parse.ts runs this file. So does ubq test/parse.ts.

2. Design rules#

  1. The first guess must work, or the error must name the fix. Names come from node:assert/strict, Vitest, and tap, in that order of preference, because those names are the most common in existing code. A common wrong guess (test.skip, assert.ok, assert.strictEqual) is a compile error whose text is the correct call. See section 9.
  2. One way to do each thing (D2). No aliases.
  3. Few tokens. No chains, no wrappers, no setup code that repeats in each file.
  4. Types carry the rules. An assertion narrows the type of its argument. A mock must match the module that it replaces. A comparison of a string with a number does not compile.
  5. A test contains all that it needs. No hooks, and no state that a reader must look for in a different part of the file.
  6. Plain code. test and describe are function calls. A loop makes many tests. An if makes a test conditional.

3. test#

test(name: string, fn: (ctx: { signal: AbortSignal }) => void | Promise<void>): void
test(name: string, options: { skip?: string | false; timeout?: number }, fn): void

4. describe#

describe(name: string, fn: () => void): void
describe(name: string, options: { skip?: string | false; timeout?: number }, fn): void

A group gives its tests a name prefix and can give them options. It does nothing else.

5. assert#

A failed assertion throws. The test stops at its first failure.

assert(value, message?)                        // asserts value
assert.equal(actual, expected, message?)       // asserts actual is typeof expected
assert.notEqual(actual, expected, message?)
assert.deepEqual(actual, expected, message?)
assert.match(actual, pattern, message?)
assert.throws(fn, expected, message?)          // returns the Error
await assert.rejects(promise, expected, message?)  // resolves to the Error
assert.snapshot(value, message?)               // compares with saved text
assert.fail(message)                           // returns never
Function Passes when
assert(value) value is truthy. Use it to narrow: assert(user), assert(err instanceof HttpError).
equal Object.is(actual, expected).
notEqual not Object.is(actual, expected).
deepEqual same structure, same values, same prototypes. The rules of assert.deepStrictEqual in Node.
match actual has the shape of pattern. See below.
throws fn throws an error that fits expected.
rejects the promise (or the promise that the function returns) rejects with an error that fits expected.

match patterns. One function covers "looks like this":

assert.match(res, { status: 200, body: { user: { name: /^a/ } } })

This keeps expected values short when the full object is large.

expected in throws and rejects is required. A test that accepts any error also accepts a typing mistake in the code under test.

Both return the error, so more assertions can follow:

const err = assert.throws(() => parse('?'), ParseError)
assert.equal(err.message, 'unknown shape: ?')

rejects must be awaited. If the test ends and nothing awaited the result, the test fails with a message that says so.

snapshot compares the text of a value with the text that an older run saved (D31).

Types.

Assertions find the current test through AsyncLocalStorage, which has no measurable cost (03-measurements.md, section 8). An assertion that runs after its test ended fails that test.

6. Mocks and spies#

spy, spyOn#

spy<F>(impl?: F): Spy<F>
spyOn(object, key, impl?): Spy<typeof object[key]>

mockImport#

const subject = await mockImport(
  () => import('../src/greet.ts'),
  [
    [() => import('../src/db.ts'), { fetchUser }],
    [() => import('node:fs'), { readFileSync: () => '{}' }],
  ],
)

The loaders must be arrow functions of the form () => import('...'). The library reads the module path from the import() call through a resolve hook. A mock loader does not load the real dependency.

Built in src/mock.ts. Node and Deno use the hooks of module.registerHooks. Bun has no such hooks, so the library uses Bun.plugin there. Differences on Bun:

7. Fixtures and cleanup#

A fixture is a function that a test calls. It sets something up and removes it when the test ends. The test does not have to ask for the removal.

testdir(files?: Files): string     // a new folder, removed at test end
env(vars: Record<string, string | undefined>): void   // set or delete, put back at test end
cleanup(fn: () => void | Promise<void>): void         // run fn at test end

fakeClock#

const clock = fakeClock({ now?: number | Date })
clock.now                 // milliseconds since 1970
clock.timers              // count of timers that wait
await clock.tick(ms)      // move forward, run each timer that is due
await clock.runAll()      // move from timer to timer until none waits
clock.restore()           // the real clock comes back

To share setup between tests, write a function:

const setup = () => {
  const dir = testdir({ 'config.json': '{}' })
  env({ APP_HOME: dir })
  return { dir, app: createApp() }
}

test('starts', () => {
  const { app } = setup()
  assert.equal(app.state, 'ready')
})

Each test gets new values. Nothing is shared by accident.

8. What is absent, and what to use#

Absent Use
expect(x).toBe(y) chains assert.equal(x, y). Chains cannot narrow types, cost more tokens, and have many near-equal matchers.
it test
beforeEach, afterEach, beforeAll, afterAll a setup function, fixtures, top-level code, cleanup
test.only, describe.only ubq <file>:<line> or ubq '<test id>'
test.skip, test.todo, skipIf, runIf { skip: 'reason' }, with a condition if necessary
test.each a for loop
test.concurrent none. Files run in parallel, tests in a file do not (D5).
retry none. ubq --repeat N measures a flaky test and does not hide it.
t.test subtests, t.plan, t.end describe for groups. The end of a test is the end of its function.
vi.mock, jest.mock mockImport
vi.fn, jest.fn, mock.fn spy
strictEqual, deepStrictEqual, ok, toEqual equal, deepEqual, assert(value)
doesNotThrow call the function, then assert on its result
soft assertions that let the test continue none. One failure for each test keeps the output short.

9. Errors for wrong guesses#

Names that an agent is likely to try exist in the types as properties that cannot be called. The compile error contains the fix. At run time, the same call throws the same text.

test.skip('x', () => {})
  error TS2349: This expression is not callable.
  Type 'Hint<"Use test(name, { skip: \"reason\" }, fn)">' has no call signatures.
Call Text of the error
test.skip, test.todo Use test(name, { skip: "reason" }, fn)
test.only ubq has no .only. Select tests on the command line: ubq <file>:<line>
test.each Use a for loop around test()
assert.ok Use assert(value)
assert.strictEqual Use assert.equal (it is always strict)
assert.deepStrictEqual Use assert.deepEqual (it is always strict)
assert.toMatchSnapshot, assert.matchSnapshot Use assert.snapshot(value)

it, expect, and the hook names are not exports. An import of one fails with the normal "no exported member" error. The agent guide in the package lists the replacement for each.

10. Where each part comes from#

Part Source Change
test(name, options, fn), describe Vitest, node:test no hooks, no modifiers
assert.equal, deepEqual, throws, rejects, match, fail node:assert/strict expected is required for throws and rejects, and both return the error
match with object patterns tap t.match no constructor patterns
testdir(files) tap t.testdir a free function
mockImport tap t.mockImport paths and types come from import() expressions
spy, spyOn Vitest vi.fn, vi.spyOn calls are plain records, no special assertions
{ signal } Vitest, node:test
5000 ms timeout Vitest, Jest

11. Open points#