One test file. Node.js, Deno, and the browser. No config.
Left: node --test and deno test on the same file, including watch mode going red β green.
Right: the same file in real Chrome, with QUnit's filterable, shareable UI.
Write a test once. Run it under Node's built-in runner, Deno's built-in runner, and a real browser β the same file, unchanged, each runtime its usual way.
import { module, test } from 'qunitx';
module('Math', () => {
test('addition', (assert) => {
assert.equal(2 + 2, 4);
});
});node --test math-test.ts # Node's runner
deno test math-test.ts # Deno's runner
qunitx math-test.ts # real browser, TAP in your terminalNo jest.config.js. No vitest.config.ts. No setup files, no transform pipeline, no
globals to inject. TypeScript runs as-is. Zero runtime dependencies.
QUnit was created in 2008 by the jQuery team. Newer frameworks come and go; QUnit has quietly accumulated 16+ years of real-world edge-case handling that younger tools are still catching up to. Its assertion API is the most mature in the JavaScript ecosystem:
assert.deepEqualhandles circular references, prototype chains, Sets, Maps, typed arrays, Dates, RegExps and getters correctly β the cases that quietly return the wrong answer elsewhere.assert.throws/assert.rejectsmatch errors by constructor, regex, error instance, or a custom validator.assert.step/verifyStepsturn execution order into a declarative assertion, and catch missing async callbacks that other frameworks silently swallow.assert.expect(n)fails the test when exactly n assertions did not run β invaluable for async code, where a missing assertion otherwise looks like a pass.assert.timeout(ms)is a hard per-test deadline, on all three runtimes.- Hooks β
before,beforeEach,afterEach,afterwith correct FIFO/LIFO ordering, properly scoped across nested modules. - Shareable browser URLs β the QUnit UI keeps filter state in the query string, so
?moduleId=abc123reproduces exactly what you were looking at for a colleague.
It is also fast. I've contributed speed optimisations to QUnit itself, and we benchmark every part of this wrapper β in most cases the assertions beat Node's and Deno's built-in ones. So I am not especially objective when I say QUnit(X) is the best testing tool in JS/TS. But I have the benchmarks.
QUnitX wraps that API over node:test and Deno's native runner. No Jest, no
Vitest, no second framework underneath.
Live example β a real suite in the browser UI, filtered and linkable: objectmodel.js.org/test/?moduleId=6e15ed5f
npm install --save-dev qunitxDeno second β and under the same name:
deno add qunitxThat writes qunitx into your deno.json import map, so a test file copied between a
Node project and a Deno project keeps working. qunitx is the one specifier that
resolves on both runtimes; installing as deno add jsr:@izelnakri/qunitx instead writes
@izelnakri/qunitx, which Node cannot resolve. The
JSR package is the same library, published from CI
with provenance, and is worth importing directly only in Deno-only code.
Requires Node.js >= 22 (LTS) or Deno >= 2. qunitx ships ESM only, so a Node
project created with npm init -y needs one line before its first import will load:
npm pkg set type=moduleOne file, every runtime:
// math-test.ts
import { module, test } from 'qunitx';
module('Math utilities', (hooks) => {
let numbers: number[];
hooks.beforeEach(() => {
numbers = [1, 2, 3];
});
test('addition', (assert) => {
assert.equal(numbers.reduce((sum, n) => sum + n, 0), 6);
});
test('deepEqual understands Sets and Maps', (assert) => {
assert.deepEqual(
{ tags: new Set(['js']), scores: new Map([['a', 1]]) },
{ tags: new Set(['js']), scores: new Map([['a', 1]]) },
);
});
module('Async', () => {
test('resolves', async (assert) => {
const user = await Promise.resolve({ id: 1, name: 'Alice' });
assert.propContains(user, { name: 'Alice' });
});
});
});Then run it with whichever runner you have. Nothing in the file changes between them.
node --test math-test.ts # built-in runner, no extra deps
node --test --watch math-test.ts # rerun on save
node --test --watch 'test/**/*.ts' # globs
npx c8 node --test math-test.ts # coveragedeno test math-test.ts
deno test --allow-read --allow-env math-test.ts
deno test --coverage=cov math-test.tsqunitx-cli bundles your test file, runs it in a real browser, and streams TAP back to your terminal:
npm install -g qunitx-cli
qunitx math-test.ts # headless β TAP on stdout, CI-friendly
qunitx math-test.ts --debug # opens the live QUnit UI tooThe browser UI lets you:
- Filter by module or test name (filter state is preserved in the URL)
- Share a link that reproduces the exact filtered view with a colleague
- Re-run individual tests by clicking them
- See full assertion diffs inline
All browser runner options are documented in qunitx-cli. This includes:
test.html(optional custom HTML template), watch mode,--port,--browser(chromium / firefox / webkit),--failFast,--timeout,--before/--afterlifecycle hooks, and more.
Every assertion takes an optional trailing message used in the failure output. This is
the complete list.
assert.ok(value) |
passes if value is truthy |
assert.notOk(value) |
passes if value is falsy |
assert.true(value) |
passes only for exactly true |
assert.false(value) |
passes only for exactly false |
assert.equal(actual, expected) |
loose ==, after QUnit's coercion rules |
assert.notEqual(actual, expected) |
loose != |
assert.strictEqual(actual, expected) |
strict === β the one you usually want |
assert.notStrictEqual(actual, expected) |
strict !== |
assert.deepEqual(actual, expected) |
deep structural equality: nested objects, arrays, Set, Map, typed arrays, Date, RegExp, circular references |
assert.notDeepEqual(actual, expected) |
inverse of deepEqual |
assert.propEqual(actual, expected) |
compares own enumerable properties only β ignores prototype and class identity, so a class instance can equal a plain object |
assert.notPropEqual(actual, expected) |
inverse of propEqual |
assert.propContains(actual, expected) |
partial match: every property in expected must match, extras in actual are ignored |
assert.notPropContains(actual, expected) |
inverse of propContains |
assert.closeTo(actual, expected, delta) |
actual is within delta of expected, inclusive β for floating-point results |
propContains is the one to reach for when asserting on an API response you only partly
care about:
test('api response', async (assert) => {
const user = await fetchUser(1);
// passes regardless of createdAt, updatedAt, avatarUrl, β¦
assert.propContains(user, { id: 1, role: 'admin' });
});assert.throws(fn, expected?) |
fn must throw; returns the error |
await assert.rejects(promise, expected?) |
promise must reject; resolves to the reason |
expected matches by constructor, by RegExp against the message, against another
Error instance (same constructor, name and message), or by a custom validator:
test('error matching', (assert) => {
assert.throws(() => JSON.parse('{'), SyntaxError); // constructor
assert.throws(() => parse(''), /unexpected end/i); // message regex
assert.throws(() => parse(''), new SyntaxError('unexpected')); // Error instance
assert.throws(() => run(), (err) => err.code === 'EAGAIN'); // validator
});
test('async errors', async (assert) => {
await assert.rejects(fetchMissing(), TypeError);
});Both hand back what they caught, so you can assert on it further. In TypeScript, passing
a constructor as expected types the result as an instance of it:
test('error details', async (assert) => {
const error = assert.throws(() => validate({}), ValidationError);
assert.deepEqual(error.fields, ['email']);
const reason = await assert.rejects(fetchMissing(), HttpError);
assert.strictEqual(reason.status, 404);
});Passing an
asyncfunction toassert.throwsis a mistake that silently passes in most frameworks β it returns a rejected promise rather than throwing. QUnitX detects it and tells you to useassert.rejects.
assert.step(label) |
records a step; not an assertion of its own |
assert.verifySteps(labels) |
asserts the recorded steps equal labels in order, then clears them β one assertion |
test('callbacks fire in order', (assert) => {
emitter.on('start', () => assert.step('start'));
emitter.on('done', () => assert.step('done'));
emitter.run();
assert.verifySteps(['start', 'done']);
});assert.expect(n) |
the test fails unless exactly n assertions ran (steps not included) |
assert.timeout(ms) |
hard deadline for this test; calling again resets it, 0 requires it to finish synchronously |
assert.async() |
returns a done callback; the test waits until every done is called |
test('callback-style async', (assert) => {
const done = assert.async();
assert.expect(1);
fs.readFile('data.json', (err, buf) => {
assert.ok(buf.length > 0);
done();
});
});
test('deadline', async (assert) => {
assert.timeout(500);
assert.ok(await fetchSomething());
});assert.expect() is what turns "the callback never fired" from a silent pass into a
failure. For async/await tests just use an async callback β assert.async() is for
callback APIs.
Without assert.timeout(), a test times out after 3 seconds, as in QUnit 3. Change the
default with config.testTimeout (0 turns it off), which each test reads as it starts:
import { config } from 'qunitx';
config.testTimeout = 30_000; // a suite of slow integration testsIn the browser, config is QUnit.config itself, and qunitx-cli sets testTimeout from
its --timeout option. On Node.js, a test given Node's own { timeout } option is left
to Node.
assert.pushResult is the building block every other assertion is made of. The plain
version is a function that takes assert:
function assertIsEven(assert, value) {
assert.pushResult({
result: value % 2 === 0,
actual: value,
expected: 'an even number',
message: `${value} is even`,
});
}
test('even numbers', (assert) => {
assertIsEven(assert, 4);
});Better: put it on assert itself, so it reads like every built-in assertion and
needs no import at the call site. The assertion class is exported from qunitx/assert,
and adding to its prototype once β in a setup file, or at the top of your test helper β
makes it available in every test:
// test-helpers.js
import Assert from 'qunitx/assert';
Assert.prototype.isEven = function (value, message) {
this.pushResult({
result: value % 2 === 0,
actual: value,
expected: 'an even number',
message: message ?? `${value} is even`,
});
};import './test-helpers.js';
test('even numbers', (assert) => {
assert.isEven(4); // passes
assert.isEven(3, 'three is even'); // fails, with your message
});In TypeScript, declare the extended shape and assign through it:
import BaseAssert from 'qunitx/assert';
export interface Assert extends BaseAssert {
isEven(value: number, message?: string): void;
}
(BaseAssert.prototype as Assert).isEven = function (value, message) {
this.pushResult({
result: value % 2 === 0,
actual: value,
expected: 'an even number',
message: message ?? `${value} is even`,
});
};
qunitx/assertdefault-exports the class, sodeclare module 'qunitx/assert'cannot merge an interface into it β TypeScript can only augment named exports. Hence the explicitextendsabove. Test callbacks still receive the base type, so annotate the parameter ((assert: Assert) => β¦) where you call a custom assertion.
qunitx/assert touches no runner API, so it resolves the same way in Node, Deno and the
browser β a helper written against it is as portable as the tests that use it.
module() groups tests and can nest. Hooks are declared on the hooks argument:
module('Database', (hooks) => {
hooks.before(async function () { this.db = await connect(); }); // once, first
hooks.beforeEach(function () { this.tx = this.db.begin(); }); // before each test
hooks.afterEach(function () { this.tx.rollback(); }); // after each test
hooks.after(async function () { await this.db.close(); }); // once, last
test('insert', function (assert) {
assert.ok(this.tx.insert({ id: 1 }));
});
module('Nested', () => {
// parent hooks still apply, outermost first
test('inherits the transaction', function (assert) {
assert.ok(this.tx);
});
});
});before / beforeEach run FIFO (outermost first); afterEach / after run LIFO
(innermost first) β exactly as in QUnit.
hooks, imported rather than taken from a module, adds a beforeEach / afterEach to
every test in the run, as QUnit.hooks does: the global beforeEach runs before any
module's, the global afterEach after all of them.
import { hooks } from 'qunitx';
hooks.afterEach(() => {
sinon.restore(); // whatever a test stubbed, for every test in every file
});A global hook runs for every test, including other files' β keep assertions out of it, or
they count towards every test's assert.expect().
If you prefer BDD naming, describe and it are exported as aliases of module and
test. They are the same function objects, so everything that works on one works on
the other β options, .skip, .todo, nesting, and mixing the two styles in one file:
import { describe, it } from 'qunitx';
describe('Math utilities', (hooks) => {
hooks.beforeEach(function () {
this.numbers = [1, 2, 3];
});
it('adds', (assert) => {
assert.equal(2 + 2, 4);
});
it.skip('not yet implemented', (assert) => { /* never runs */ });
it('flaky on CI', { skip: true }, (assert) => {});
describe('Nested', () => {
it('inherits parent hooks', function (assert) {
assert.deepEqual(this.numbers, [1, 2, 3]);
});
});
});QUnit exposes shared state as this, which arrow functions cannot see. QUnitX passes the
same object as context in the second argument of every hook and test, so both styles
work:
// QUnit style β regular functions, `this`
module('Suite', function (hooks) {
hooks.before(function () { this.db = createDb(); });
test('query', function (assert) { assert.ok(this.db.query('SELECT 1')); });
});
// QUnitX style β arrow functions, `context`
module('Suite', (hooks, { context }) => {
hooks.before((assert, { context }) => { context.db = createDb(); });
test('query', (assert, { context }) => { assert.ok(context.db.query('SELECT 1')); });
});Either way the state follows QUnit 3's model: before() and after() run with the
module's context, which inherits from its parent module's, and each test starts from a
fresh copy of it β so before() writes are visible everywhere, and a test's own writes
never leak into its siblings or into after().
In TypeScript, both bags are deliberately untyped, so
this.dbandcontext.dbdo not type-check underdeno test's built-in checker. For a typed.tssuite, declare the state as a variable in themodule()closure and assign it from the hook β the pattern used in Quick start above.
test.each() defines one test per case. Each receives its case as the second argument,
and the usual meta object (with the case's own testName) as the third:
test.each('is even', [2, 4, 6], (assert, n) => {
assert.strictEqual(n % 2, 0);
});
test.each('adds', { 'small numbers': [1, 2, 3], 'large numbers': [1e6, 2e6, 3e6] }, (assert, [a, b, sum]) => {
assert.strictEqual(a + b, sum);
});Cases are named as QUnit names them, on every runtime: is even [2] for a short value,
is even [0: 2] for other primitives, adds [small numbers] for an object's keys.
test.skip.each, test.todo.each, test.only.each and test.if.each combine the two.
test.skip('not yet implemented', (assert) => { /* never runs */ });
test.todo('known broken', (assert) => { /* runs; failures do not fail the suite */ });
test.todo('not even started'); // no body β registers as pending
test('flaky on CI', { skip: true }, (assert) => {}); // conditional, via options
test('waiting on API', { todo: 'needs endpoint' }, (assert) => {});
module.skip('Legacy API', () => { /* whole module skipped */ });
module.todo('Future features', () => { /* whole module pending */ });
test.if('formats lists', typeof Intl.ListFormat === 'function', (assert) => {}); // skipped when false
module.if('Clipboard', typeof navigator !== 'undefined', () => { /* β¦ */ });
test.only('the one being debugged', (assert) => {}); // every other test is skipped
module.only('The module being worked on', () => { /* β¦ */ });skip, todo and only are also exported top-level for QUnit-style usage:
import { module, test, only, skip, todo } from 'qunitx';only works as in QUnit on every runtime: once any test or module is only, every other
test is skipped. Node.js needs no --test-only flag for it. Deno runs only those tests,
then fails the run on purpose β its guard against committing an only.
The BDD aliases carry the same helpers β describe.skip, describe.todo, it.skip,
and it.todo are the exact same functions.
Runtime difference: Node's
todoruns the body and ignores its failures. Deno has no todo concept and maps bothskipandtodoto "ignored" β the body does not run.
The optional second argument to module() and test() is passed straight through to the
underlying runner, so anything node:test or Deno BDD accepts works:
// Tests run sequentially by default, matching QUnit's browser behaviour.
module('Parallel suite', { concurrency: true }, () => { /* β¦ */ });
// Deno-specific options
module('File access', { permissions: { read: true }, sanitizeExit: false }, () => {
test('reads a file', async (assert) => {
assert.ok((await Deno.readTextFile('./README.md')).length > 0);
});
});Options a runtime does not recognise are ignored, which is what keeps one file portable across all three.
One line:
- import { module, test } from 'qunit';
+ import { module, test } from 'qunitx';Coming from a BDD runner instead? Import describe / it β they are aliases of
module / test, see describe / it.
qunitx follows QUnit 3's test-environment model on every runtime:
- Module context β
before()andafter()run with the module's own context. A nested module's context inherits from its parent's, so what a parent'sbefore()sets is visible below it. - Fresh context per test β each test starts from a copy of its module's context, taken after the
before()hooks ran. Writes in one test never bleed into a sibling, or intoafter(). before()assertions β attributed to the first test in the module (matching QUnit's attribution model).after()assertions β attributed to the last test in the module.- Hook ordering β
before/beforeEachrun FIFO;afterEach/afterrun LIFO, exactly as in QUnit. A hook that callsassert.async()finishes before the next one starts. - Hooks belong to their module β adding a hook to a parent module from inside a child, or from inside a test, throws, as does an
asyncmodule callback. Module setup that needs to wait goes inbefore(). - Steps β
assert.step()is not an assertion of its own; theverifySteps()that checks it is. - Default timeout β 3 seconds per test, configurable with
config.testTimeout. - QUnit 2.x APIs β
test.each,test.if/module.if,test.only/module.only, globalhooks,assert.closeTo, and the error thatassert.throws/assert.rejectsreturn all work on Node.js and Deno too.
The same file of QUnit 3's own hook tests runs on all three runtimes, in
test/hook-context-test.ts.
Known difference: Inside a module that contains a
module.only(), QUnit also ignores atest.only()declared after it (qunitjs/qunit#1645); the Node/Deno adapters run that test.
Known difference: In QUnit's browser runner,
before()hook assertions are attributed to the first test in the entire subtree (including nested modules). In the Node/Deno adapters, they are attributed to the first direct test of the module. In the common case where direct tests appear before nested modules, the behavior is identical.
| Runtime | Adapter |
|---|---|
| Node.js | wraps node:test's describe / it with the QUnit lifecycle |
| Deno | wraps Deno's BDD helpers with the same lifecycle |
| Browser | a thin re-export of QUnit itself |
Because the browser path is QUnit, you get the whole thing there: plugins, custom
reporters, the event API (QUnit.on, QUnit.done, β¦) and the familiar UI, with no extra
layer in between.
QUnitX loads as an ordinary dependency instead of wrapping the runtime process, so v8 coverage instruments it with no special configuration:
npx c8 node --test test/ # Node
npx c8 --reporter=html node --test test/ && open coverage/index.html
deno test --coverage=cov test/ && deno coverage cov # DenoBrowser coverage is the one gap β qunitx-cli bundles test files with esbuild, which loses the mapping. Native import-map support in headless Chrome would remove the bundling step and with it the limitation.
- QUnit API reference β every assertion, in depth
- qunitx-cli β the browser runner and all of its options
@izelnakri/qunitxon JSR Β·qunitxon npm- Node.js test runner Β· Deno testing
