| name | testing-practices |
| description | Use when writing or reviewing tests in this monorepo using the module-test harness. |
Testing practices
Fires when writing or modifying tests,
picking a test pattern,
or reviewing
test code in this monorepo.
The skill covers the module-test harness,
the describe/it API,
test file
conventions,
coverage,
parameterized tests,
async patterns,
and sinon
integration.
Test framework
- Unit tests use
@monochromatic-dev/module-test/ts,
a custom harness built on chai,
sinon,
and expect-type
- Unit tests import package behavior from built
dist,
never from sibling source files
- Browser and e2e tests use Playwright,
executed inside a podman container
- All tests run through
mise run;
never invoke bun test or playwright directly
- Each test file is self-contained with top-level
await describe(...);
no external test runner needed
Running tests
mise run buildAndTest
mise run buildAndTest -- package/module/async-time/src/wait.unit.test.ts
mise run test
mise run watch:test
Required for unit tests:
unit tests import package code from built dist.
Run mise run buildAndTest after source changes,
including when narrowing to one file,
because mise run test alone can exercise stale artifacts.
Use mise run test
only when the same verification sequence already rebuilt dist,
or when the
file tests the test harness itself instead of package output.
Browser and e2e tests
Browser and e2e tests run inside a podman container image (monochromatic-playwright).
Build the image first with mise run prepare:playwright.
mise run test:browser
mise run test:browser:chromium
mise run test:browser:firefox
mise run test:e2e
Playwright configuration lives in playwright.browser.config.ts and playwright.e2e.config.ts.
The browser test server (playwright/serve.ts) uses h3.
Test file naming
- Unit tests:
{name}.unit.test.ts -- co-located alongside source
- Browser tests:
{name}.browser.test.ts
- E2E tests:
{name}.e2e.test.ts
Test discovery uses rg --files --glob '**/*.unit.test.*' -- no configuration file needed.
Test file setup
import {
describe,
expect,
it,
} from '@monochromatic-dev/module-test/ts';
import { wait, } from '../dist/final/neutral/index.mjs';
await describe({
name: wait.name,
children: [
it({
name: 'resolves to undefined',
fn: async () => {
const result = await wait(1,);
expect(result,).toBeUndefined();
},
},),
],
},);
Key differences from bun:
test / Jest:
- Import
describe,
it,
expect from @monochromatic-dev/module-test/ts (not bun:test)
- Import package code under test from built
dist,
not from sibling src files
- Use
it (not test)
describe and it take an options object { name, children/fn },
not positional (name, callback) arguments
describe returns a promise -- use await describe(...) at the top level
- Children are concurrent by default via
Promise.allSettled
Importing code under test
Treat direct imports from sibling source files as a test bug.
Tests for exported
package behavior must import from the built dist entry point,
for example
../dist/final/neutral/index.mjs from a src/*.unit.test.ts file.
This verifies
the artifact users consume and catches export-map,
build,
and bundling errors.
Relative source imports are allowed only for fixtures,
mock data,
or test-only
helpers that are not part of package behavior.
Describe name conventions
When a describe block names a specific imported function,
use functionName.name instead of a string literal.
This keeps names in sync with refactors.
describe({ name: wait.name, ... })
describe({ name: 'SEVERITY_MAP', ... })
describe({ name: 'error code constants', ... })
describe({ name: 'valid fixtures', ... })
For the top-level describe in a file,
use an empty string name: '' when the file tests a single module.
The empty-name suite is invisible in output -- the filename already identifies what is being tested.
Nest the real function-named describes as children.
await describe({
name: '',
children: [
describe({
name: stripAnsi.name,
children: [ ... ],
}),
describe({
name: extractRuleName.name,
children: [ ... ],
}),
],
});
Coverage requirements
Target 100% test coverage.
When certain lines or branches cannot be tested (e.g. error handling for impossible states),
use V8 ignore comments:
if (impossibleCondition)
throw new Error('This should never happen',);
if (untestableCondition) {
console.error('Untestable path',);
return fallbackValue;
}
Test structure
- Use descriptive test names that explain expected behavior
- Group related tests using nested
describe blocks as children
- Keep
describe names unique at the same scope within a file -- duplicate names cause misattributed results (see TROUBLESHOOTING.testing.md)
- Use
.map() to generate parameterized it entries in the children array
- Test both happy path and error scenarios
Parameterized tests with .map()
Since there is no test.each,
generate it entries with .map() and spread into children:
await describe({
name: shouldIgnoreFile.name,
children: [
...IGNORED_EXTENSIONS.map(function mapExt(ext,) {
return it({
name: `returns true for ${String(ext,)} extension`,
fn: async () => {
expect(shouldIgnoreFile(`/some/path/file${String(ext,)}`,),).toBe(
true,
);
},
},);
},),
it({
name: 'returns false for plain .ts files',
fn: async () => {
expect(shouldIgnoreFile('/some/path/file.ts',),).toBe(false,);
},
},),
],
},);
Test timeouts
Pass timeout in the options object (milliseconds):
it({
name: 'fetches embeddings from external API',
fn: async () => {
const result = await embed({ input: 'test', },);
expect(result,).toBeDefined();
},
timeout: 30_000,
},);
Suites also accept timeout which applies to all children collectively.
Skipping tests
Pass skip: true or skip: 'reason string':
it({
name: 'unix-only behavior',
skip: process.platform === 'win32',
fn: async () => { ... },
})
Expected failures
Pass fails: true to mark a test that is expected to throw.
A throwing test is PASS;
a passing test is FAIL:
it({
name: 'rejects invalid input',
fails: true,
fn: async () => {
throw new Error('expected',);
},
},);
Repeating tests (flake detection)
Pass repeats: N to run the test N additional times after the first:
it({
name: 'not flaky',
repeats: 2,
fn: async () => { ... },
})
Sequential execution
Children run concurrently by default.
Set concurrency: 1 on describe
to run children one at a time in array order:
describe({
name: 'ordered operations',
concurrency: 1,
children: [
it({ name: 'step 1', fn: async () => { ... } }),
it({ name: 'step 2', fn: async () => { ... } }),
],
})
Children are lazy descriptors and do not run until the parent dispatches
them,
so concurrency: 1 sequences execution without any wrapping.
The
parent's effective concurrency is inherited by nested describes that
don't set their own,
so a single concurrency: 1 at the top sequences
all descendants.
Region markers
Use //region and //endregion markers to organize test groups within a describe block:
await describe({
name: wait.name,
children: [
it({
name: 'resolves after the specified delay',
fn: async () => {
const DELAY = 20;
const TOLERANCE = 5;
const start = performance.now();
await wait(DELAY,);
const elapsed = performance.now() - start;
expect(elapsed,).toBeGreaterThanOrEqual(DELAY - TOLERANCE,);
},
},),
it({
name: 'returns undefined',
fn: async () => {
const result = await wait(1,);
expect(result,).toBeUndefined();
},
},),
],
},);
Region markers provide IDE folding and navigability in long test files.
Assertion count verification
The it function provides a scoped expect via the test context parameter.
Use ctx.expect.assertions(n) to verify exactly N assertions run:
it({
name: 'all assertions execute',
fn: async ctx => {
ctx.expect.assertions(2,);
ctx.expect(await readFile(path,),).toBe('content',);
ctx.expect(exists,).toBe(true,);
},
},);
The global expect also works but does not support assertion counting.
Sinon sandbox for spies/stubs
The test context provides a sinon sandbox that auto-restores after the test:
it({
name: 'logs a message',
fn: async ctx => {
const spy = ctx.sinon.spy(console, 'log',);
doSomething();
expect(spy,).toHaveBeenCalledWith('hello',);
},
},);
Async error assertions
Await the async operation first,
then assert on the caught error.
.rejects and .resolves are legacy APIs -- avoid them in new test code.
.rejects.toThrow() passes the rejected value to chai's .throw(),
which expects a function to call.
The harness patches around this mismatch,
but the indirection obscures stack traces and is inherently fragile.
.resolves.toBe(x) adds nothing over const v = await p; expect(v).toBe(x).
it({
name: 'rejects on missing file',
fn: async () => {
let caught: unknown;
try {
await readConfig('/nonexistent',);
}
catch (error) {
caught = error;
}
expect(caught,).toBeInstanceOf(ConfigError,);
expect((caught as Error).message,).toContain('not found',);
},
},);
it({
name: 'returns parsed config',
fn: async () => {
const result = await readConfig('/valid',);
expect(result,).toHaveProperty('version', 2,);
},
},);
Type-level testing
Use expectTypeOf from expect-type,
re-exported by @monochromatic-dev/module-test/ts:
import {
describe,
expectTypeOf,
it,
} from '@monochromatic-dev/module-test/ts';
import type { ChatRole, } from '../dist/final/neutral/index.mjs';
await describe({
name: 'ChatRole',
children: [
it({
name: 'is exactly the role union',
fn: async () => {
expectTypeOf<ChatRole>().toEqualTypeOf<'system' | 'user' | 'assistant'>();
},
},),
],
},);
Available matchers
The expect function provides Jest-compatible matchers backed by chai:
Value matchers:
toBe,
toEqual,
toStrictEqual,
toContain,
toContainEqual,
toMatch,
toMatchObject,
toSatisfy,
toBeCloseTo
Type/state matchers:
toBeDefined,
toBeUndefined,
toBeNull,
toBeTruthy,
toBeFalsy,
toBeNaN,
toBeTypeOf,
toBeInstanceOf
Comparison matchers:
toBeGreaterThan,
toBeGreaterThanOrEqual,
toBeLessThan,
toBeLessThanOrEqual
Collection matchers:
toHaveLength,
toHaveProperty
Error matcher:
toThrow
Spy matchers (sinon-chai):
toHaveBeenCalled,
toHaveBeenCalledTimes,
toHaveBeenCalledWith,
toHaveBeenCalledExactlyOnceWith,
toHaveBeenLastCalledWith,
toHaveBeenNthCalledWith,
toHaveReturned,
toHaveReturnedTimes,
toHaveReturnedWith,
toHaveLastReturnedWith,
toHaveNthReturnedWith
Negation:
expect(x).not.toBe(y)
Promise matchers (legacy,
avoid in new code):
expect(promise).rejects.toThrow(),
expect(promise).resolves.toBe(42).
Prefer awaiting the promise first,
then asserting on the result or caught error directly.
Asymmetric matchers (for use inside toHaveBeenCalledWith):
expect.stringContaining,
expect.stringMatching,
expect.objectContaining,
expect.arrayContaining,
expect.anything,
expect.any
Test output format
Every log line carries the full suite hierarchy as a tag chain.
The leftmost tag
is the outermost describe,
the rightmost tag is the current it (or innermost
describe);
empty-name suites contribute no tag.
Default verbosity is compact:
each describe emits one info-level line listing
its fulfilled children's names.
Per-test PASS lines are at debug (hidden
unless DEBUG=true).
Failures remain at error and are always visible.
[info] [iso] [outer] [inner] PASS childA, childB, ... (Nms) -- happy-path enumeration
[info] [iso] [outer] [inner] PASS childA -- mixed-result: passing siblings only
[error] [iso] [outer] [inner] [bad] FAIL (Nms) -- per-test failure with full hierarchy
[error] [iso] [outer] [inner] FAIL (Nms) -- suite rollup with failure
[debug] [iso] [outer] [inner] [test-name] PASS (Nms) -- per-test detail with DEBUG=true
Parent-children relationships are visible per line by default;
no DEBUG flag
needed.
Levels:
info -- per-suite PASS childA, childB (Nms) enumeration line and
SKIP notices from it.
error -- FAIL for tests and suites;
always visible regardless of
verbosity.
debug -- per-test PASS (Nms),
per-suite start (concurrency: N)
traces,
and the rollup line for empty-name (invisible) suites.
Hidden by
default;
set DEBUG=true or pass --verbose to surface.
When a failure surfaces,
read the rightmost (deepest) tag plus the FAIL line
to identify the failing test;
walk leftward through tags to follow the suite
nesting back to the file's top-level describe.
The full Error.cause chain
mirrors the tag chain.
Linting test code
Testing intentional violations
When tests intentionally violate a lint rule to verify behavior:
expect(isError(new Error('test message',),),).toBe(true,);
expect(isError(new Error(),),).toBe(true,);
For known testing issues (duplicate describe blocks,
missing test output,
misattributed logs),
see TROUBLESHOOTING.testing.md in the repository root.