uvu
uvu is a small Node.js test runner built around self-contained test files. You register tests on an unnamed `test` suite or named suites, attach before and after hooks, use mutable per-suite context, and explicitly call `run()`. The CLI finds matching files and queues their suites, while individual files can run directly with Node. Its optional assertion module provides strict, deep, type, pattern, snapshot, fixture, and error checks.
uvu remains pleasant for small, fast Node test suites whose needs fit its compact surface. New applications that expect watch, coverage, mocks, transforms, or browser environments will usually spend less time wiring Vitest or another fuller runner.
Use it if
- You want very low test-runner startup overhead for a small Node library or command-line project
- You value test files that can run directly with Node as well as through a collector CLI
- A compact suite, hook, context, and assertion API is enough for your project
- You need both CommonJS and ESM entry points without committing to a large framework
- You want active modern framework development; version 0.5.6 was published in 2022 and the repository's last push was in 2024
- You need built-in watch mode; the documentation explicitly says watching is not implemented and recommends external file-watching tools
- You need built-in coverage; the documentation explicitly delegates coverage to c8, nyc, or another separate tool
- You expect integrated mocking, fake timers, DOM environments, browser automation, or snapshot file management; uvu primarily runs callbacks and detects thrown errors
- Your tooling depends on glob syntax for discovery; the CLI deliberately interprets the directory's pattern argument and ignore values as regular expressions instead
Setup reality
`npm install --save-dev uvu` installs version 0.5.6 plus dequal, diff, kleur, and sade. The package declares Node 8 or newer and exposes both CommonJS and ESM entries, but your own syntax still has to match the Node version and package module mode. A test file imports `test` or creates a `suite`, registers callbacks, and must call `.run()`; forgetting that final call produces a file that loads without joining the run queue. Put a script such as `uvu tests` in package.json. The CLI does not use globs. With no arguments it applies its built-in regular expression to common test directories and filename suffixes; when a directory is supplied, an optional second argument becomes a case-insensitive regular expression. Ignore flags are regular expressions too. The CLI recognizes TypeScript extensions during discovery but does not transpile TypeScript by itself, so preload a compatible runtime such as `ts-node/register` or compile first. The bundled assertions are optional and failures are simply thrown Errors, which makes Node's assert or another library valid. `assert.throws` is synchronous; the docs recommend an explicit try/catch pattern for rejected promises. Snapshots are inline expected strings rather than an automatically updated snapshot-file system. There is no watch process or coverage engine; add a watcher and c8 or nyc yourself. Browser-compatible code and assertions do not turn the CLI into a browser runner, so DOM tests still need jsdom or an actual browser test tool. Hooks always run cleanup after failures, but suite context is mutable and shared across tests, so reset it deliberately.
Patterns
Write and run a basic test filewrite-basic-test
import { test } from 'uvu';
import * as assert from 'uvu/assert';
test('adds two values', () => {
assert.is(2 + 3, 5);
});
test.run();The exported `test` is an unnamed suite. The file registers nothing with uvu until `test.run()` is called.
Group related tests in a named suitegroup-named-suite
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
const Users = suite('users');
Users('normalizes email', () => {
assert.is(normalizeEmail(' A@EXAMPLE.COM '), 'a@example.com');
});
Users.run();Each named suite needs its own `run()` call, even when several suites live in one file.
Set up and clean up suite resourcesuse-suite-hooks
const Api = suite('api');
Api.before(async context => { context.server = await startServer(); });
Api.after(async context => { await context.server.close(); });
Api.before.each(context => { context.requestId = crypto.randomUUID(); });
Api.after.each(context => { delete context.requestId; });
Api('responds', async context => {
const response = await callApi(context.server, context.requestId);
assert.is(response.status, 200);
});
Api.run();After and after-each hooks run even after failed assertions. Context is mutable and shared by the suite, so reset per-test state.
Await asynchronous behaviortest-async-code
test('loads a user', async () => {
const user = await loadUser(7);
assert.equal(user, { id: 7, name: 'Ada' });
});
test.run();Return or await the promise inside the async callback. An unhandled rejection fails the test because uvu treats thrown errors as failures.
Assert details of an async rejectionassert-async-error
test('rejects invalid input', async () => {
try {
await createUser({});
assert.unreachable('should have rejected');
} catch (error) {
assert.instance(error, Error);
assert.match(error.message, 'email');
assert.is(error.code, 'INVALID_INPUT');
}
});The docs recommend explicit try/catch for async failures because `assert.throws` is designed for synchronous functions.
Run only selected tests in one suitefocus-test
const Parser = suite('parser');
Parser.only('handles escaped commas', () => {
assert.equal(parse('a,\,b'), ['a', ',b']);
});
Parser('handles plain values', () => {
assert.equal(parse('a,b'), ['a', 'b']);
});
Parser.run();Multiple tests may be marked only. Remove focused tests before committing or the rest of that suite will not execute.
Skip a known unsupported caseskip-test
test.skip('supports Windows named pipes', () => {
assert.ok(connectNamedPipe());
});
test.run();A skipped callback does not run. Keep an issue reference in nearby code so a permanent skip does not become invisible debt.
Compare an inline text snapshotcompare-snapshot
test('serializes config', () => {
const output = JSON.stringify(buildConfig(), null, 2);
assert.snapshot(output, '{\n "mode": "test"\n}');
});uvu compares against the supplied string and does not manage snapshot files or update expected output automatically.
Compare output with a fixture filecompare-fixture
import fs from 'node:fs';
test('renders report', () => {
const expected = fs.readFileSync('tests/fixtures/report.txt', 'utf8');
assert.fixture(renderReport(), expected);
});Fixture assertions include line numbers in diffs. Normalise platform-dependent newlines if the suite runs across operating systems.
Use Node's assertion libraryuse-node-assert
import { test } from 'uvu';
import assert from 'node:assert/strict';
test('returns records', () => {
assert.deepEqual(listRecords(), [{ id: 1 }]);
});
test.run();uvu/assert is optional. Any assertion that throws an Error on failure works with the runner.
Run matching files and ignore fixturesconfigure-cli
{
"scripts": {
"test": "uvu tests \\.test\\.[cm]?js$ --ignore fixtures --bail"
}
}The second positional argument and ignore values are regular expressions, not glob patterns. Shell and JSON escaping both apply inside package.json.
Add coverage with c8collect-coverage
{
"scripts": {
"test": "uvu tests",
"coverage": "c8 --all npm test"
},
"devDependencies": {
"c8": "^10.0.0",
"uvu": "^0.5.6"
}
}Coverage is not built into uvu. Pin a c8 version compatible with the Node versions your project actually supports.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| vitest | npm | You want active development, watch mode, mocking, coverage integration, and Vite-aware transforms |
| tape | npm | You prefer a streaming TAP test API with minimal global machinery and a long compatibility history |
| mocha | npm | You want a mature flexible runner with a broad reporter, hook, loader, and plugin ecosystem |