Tests are scripts. Not a framework.
Write tests in TypeScript, run with node test.ts.
No runner, no overhead — just 16 kB.
$ node ./tests/index.ts
12:34:56 ✔ adds numbers
12:34:56 ✔ async operation (52ms)
12:34:56 ✔ Auth › login succeeds
12:34:56 ✔ Auth › logout clears session
12:34:56 ✖ broken test
523ms
4 passed
1 failed✔ passed · ✖ failed · ○ skipped · • pending (process exited before test finished)
npm i -D mantenTest files are plain scripts — run them directly:
node tests/index.tsNo file discovery, no config files, no abstraction layers. Node.js startup time, nothing more.
sequential = await
concurrent = Remove await
That's the entire concurrency model.
await test('first', async () => { /* ... */ }) // waits
test('second', async () => { /* ... */ }) // runs immediately
test('third', async () => { /* ... */ }) // runs with secondTip
Prefer concurrent by default. Only await to enforce ordering. Since Node.js won't exit while promises are settling, you don't actually need to await anything.
Every API is a standalone import — no callback destructuring:
import {
test, describe, expect, skip, onTestFinish
} from 'manten'Each function automagically knows which test or group it belongs to.
One dependency (expect for assertions — swap it for any assertion library).
// tests/index.ts
import { test, expect } from 'manten'
test('adds numbers', () => {
expect(1 + 1).toBe(2)
})
test('async operation', async () => {
const result = await fetchData()
expect(result).toBeDefined()
})node tests/index.tsTip
Node.js 22.6+ runs TypeScript natively — no loaders needed.
Tests execute immediately when test() is called. Use await to control ordering:
// Sequential
await test('step 1', async () => { /* ... */ })
await test('step 2', async () => { /* ... */ }) // runs after step 1
// Concurrent
test('independent A', async () => { /* ... */ })
test('independent B', async () => { /* ... */ }) // runs with Aimport { describe, test } from 'manten'
await describe('Auth', () => {
test('login', async () => { /* ... */ }) // Auth › login
test('logout', async () => { /* ... */ }) // Auth › logout
})
// Runs after both Auth tests complete
test('next', () => { /* ... */ })Awaiting a group waits for all children. Groups nest infinitely.
Import files inside describe() — their tests automatically nest under the parent group:
// tests/index.ts
import { describe } from 'manten'
describe('my-app', async () => {
import('./auth.ts')
import('./api.ts')
import('./utils.ts')
// Or add `await` to run files sequentially
})// tests/auth.ts
import { describe, test, expect } from 'manten'
describe('Authentication', () => {
test('login', () => { /* ... */ })
test('logout', () => { /* ... */ })
test('refresh token', () => { /* ... */ })
})
// Output: my-app › Authentication › login
// my-app › Authentication › logout
// my-app › Authentication › refresh tokenEach file works standalone too — node tests/auth.ts runs just that file. The entry point is your test runner, written in plain JavaScript.
To pass data into a test file, export a function that wraps a describe():
// tests/specs/builds.ts
import { describe, test, expect } from 'manten'
export const builds = (nodePath: string) => describe('builds', () => {
test('compiles', async () => {
const result = await run(nodePath)
expect(result.exitCode).toBe(0)
})
})Since the describe() doesn't run until the function is called, these can be statically imported:
// tests/index.ts
import { builds } from './specs/builds.ts'
import { errors } from './specs/errors.ts'
import { describe } from 'manten'
describe('my-app', async () => {
for (const nodeVersion of ['v20', 'v22', 'v24']) {
const node = await getNode(nodeVersion)
await describe(`Node ${node.version}`, () => {
builds(node.path)
errors(node.path)
})
}
})tests/
index.ts # entry point — run this
specs/ # test files
utils/ # shared test helpers
fixtures/ # static test data
Use a single index.ts entry point that imports all test files. This gives you one command to run everything and enables node --watch across all files.
node --watch tests/index.tsBuilt into Node.js (stable since v22). Watches all imported files — change any test file and tests re-run automatically. This is why a single entry point matters: one command watches your entire test suite.
Pass a timeout (ms) as the third argument. The test receives an AbortSignal for cooperative cancellation:
test('fetch with timeout', async ({ signal }) => {
await fetch('https://api.example.com', { signal })
}, 5000)For multi-step tests, use signal.throwIfAborted() between operations. Combine with your own signals using AbortSignal.any().
test('flaky API', async () => {
await unreliableAPI()
}, {
timeout: 5000,
retry: 3
})Output shows which attempt succeeded: ✔ flaky API (2/3).
import { test, onTestFail, onTestFinish } from 'manten'
test('with cleanup', async () => {
const resource = await acquire()
onTestFinish(() => resource.cleanup()) // runs after test (pass or fail)
onTestFail(error => console.log('Debug:', error))
})onFinish runs after all tests in a describe():
import { describe, test, onFinish } from 'manten'
describe('Database', async () => {
const database = await connect()
onFinish(() => database.close())
test('query', () => { /* ... */ })
})import { test, skip } from 'manten'
test('linux only', () => {
if (process.platform !== 'linux') {
skip('Only runs on Linux')
}
// ...
})Skip entire groups — skip() must be called before any test() or nested describe():
describe('GPU tests', () => {
if (!hasGPU) {
skip('GPU not available')
}
test('render shader', () => { /* ... */ }) // all skipped
})import { test, expectSnapshot } from 'manten'
test('user state', () => {
// Named (recommended) — order-independent
expectSnapshot(getUser(), 'initial state')
// Auto-numbered — keys become "user state 1", "user state 2", etc.
expectSnapshot(getUser())
expectSnapshot(getPermissions())
})Snapshots are stored in .manten.snap. Update with MANTEN_UPDATE_SNAPSHOTS=1 node tests/index.ts. Without named snapshots, reordering expectSnapshot() calls breaks comparisons.
Warning
Snapshots are serialized with util.inspect, which may produce different output across Node.js versions. If snapshots fail after upgrading Node, re-run with MANTEN_UPDATE_SNAPSHOTS=1 to regenerate.
describe('Database tests', () => {
test('query 1', async () => { /* ... */ })
test('query 2', async () => { /* ... */ })
test('query 3', async () => { /* ... */ })
}, { parallel: 2 }) // max 2 concurrentOptions:
false(sequential)true(unbounded)number(limit)'auto'(adapts to CPU load)
Tests that you explicitly await run immediately, bypassing the parallel queue — useful for setup/teardown steps within a parallel group.
describe('API suite', () => {
test('endpoint 1', async () => { /* ... */ })
test('endpoint 2', async () => { /* ... */ })
}, { timeout: 10_000 })Individual test timeouts still apply — whichever is stricter wins.
Prevent stuck processes in CI:
import { setProcessTimeout } from 'manten'
setProcessTimeout(10 * 60 * 1000) // kill after 10 minutesRun specific tests by substring match (case-sensitive). Matches against the full title including describe prefixes:
TESTONLY='login' node tests/index.ts
TESTONLY='Auth' node tests/index.ts # matches "Auth › login", "Auth › logout", etc.Create and run a test. fn always receives { signal } — an AbortSignal that aborts on timeout or when the parent group is aborted.
timeoutOrOptions:number | { timeout?: number, retry?: number }- Returns:
Promise<void>
Create a test group. fn always receives { signal } — an AbortSignal that aborts on timeout or when the parent group is aborted.
options:{ parallel?: boolean | number | 'auto', timeout?: number }- Returns:
Promise<void>
Jest's expect. Or use Node.js Assert, Chai, etc.
Compare against a stored snapshot. Creates one if none exists. Test names must be unique across all files — duplicates throw an error.
Hooks for the current test. Must be called within test(). Hook errors are logged but don't fail the test.
Cleanup hook for the current describe() group. Errors are logged and set process.exitCode = 1.
Skip the current test or describe group.
Global timeout for the entire process.
{ snapshotPath?: string } — must be called before any expectSnapshot(). Also configurable via MANTEN_SNAPSHOT_PATH and MANTEN_UPDATE_SNAPSHOTS env vars.
Manten is written in TypeScript. All APIs are fully typed, and Test/Describe types are exported for advanced use cases.
Manten (まんてん, 満点) means "maximum points" or 100% in Japanese.
No runner = zero overhead. No file discovery, no spawning processes, no config. Tests are scripts — run them however you want.
Manten runs tests concurrently by default. Shared setup hooks don't compose with concurrent execution. Inline setup in each test, or use describe() + onFinish() for shared resources.
When a test fails, manten sets process.exitCode = 1 but doesn't force-exit. All remaining tests run to completion, and the final report prints on the exit event. CI systems pick up the non-zero exit code automatically.
Create disposable file system fixtures for testing. Pairs naturally with manten's hooks:
import { createFixture } from 'fs-fixture'
import { test, expect } from 'manten'
test('reads config', async () => {
await using fixture = await createFixture({
'package.json': JSON.stringify({ name: 'my-app' }),
'src/index.js': 'export default 42'
})
const result = await readPackageJson(fixture.path)
expect(result.name).toBe('my-app')
}) // fixture auto-cleaned up when test scope exits