Pewarta Proxy

πŸ“‹ Server Request & Proxy Logs

Loading active proxy settings...
Developed by Khairul Adha (adhexa.id)
Mengambil log server...

βš™οΈ Pengaturan Proxy Target

Skip to content
izelnakriPublic

About

Worlds first JS/TS universal automated testing library for any JavaScript/TypeScript runtime(s), only one to my knowledge.

Resources

Stars

36 stars

Watchers

2 watching

Forks

Latest commit

Β 

History

547 Commits

Folders and files

Repository files navigation

QUnitX

One test file. Node.js, Deno, and the browser. No config.

CI codecov npm JSR JSR score npm downloads License: MIT Sponsor

QUnitX demo

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 terminal

No jest.config.js. No vitest.config.ts. No setup files, no transform pipeline, no globals to inject. TypeScript runs as-is. Zero runtime dependencies.


Why QUnit?

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.deepEqual handles 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.rejects match errors by constructor, regex, error instance, or a custom validator.
  • assert.step / verifySteps turn 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, after with correct FIFO/LIFO ordering, properly scoped across nested modules.
  • Shareable browser URLs β€” the QUnit UI keeps filter state in the query string, so ?moduleId=abc123 reproduces 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


Install

npm install --save-dev qunitx

Deno second β€” and under the same name:

deno add qunitx

That 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=module

Quick start

One 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.js

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       # coverage

Deno

deno test math-test.ts
deno test --allow-read --allow-env math-test.ts
deno test --coverage=cov math-test.ts

Browser

qunitx-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 too

The 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 / --after lifecycle hooks, and more.


Assertions

Every assertion takes an optional trailing message used in the failure output. This is the complete list.

Truthiness

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

Equality

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' });
});

Errors

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 async function to assert.throws is a mistake that silently passes in most frameworks β€” it returns a rejected promise rather than throwing. QUnitX detects it and tells you to use assert.rejects.

Execution order

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']);
});

Async control

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 tests

In 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.

Custom assertions

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/assert default-exports the class, so declare module 'qunitx/assert' cannot merge an interface into it β€” TypeScript can only augment named exports. Hence the explicit extends above. 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.


Modules and hooks

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.

Global hooks

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().

describe / it β€” BDD aliases

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]);
    });
  });
});

context β€” for arrow functions

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.db and context.db do not type-check under deno test's built-in checker. For a typed .ts suite, declare the state as a variable in the module() closure and assign it from the hook β€” the pattern used in Quick start above.


Data-driven tests

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.

Skip, todo, only and if

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 todo runs the body and ignores its failures. Deno has no todo concept and maps both skip and todo to "ignored" β€” the body does not run.


Runtime options

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.


Migrating from QUnit

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.


QUnit compatibility

qunitx follows QUnit 3's test-environment model on every runtime:

  • Module context β€” before() and after() run with the module's own context. A nested module's context inherits from its parent's, so what a parent's before() 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 into after().
  • 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/beforeEach run FIFO; afterEach/after run LIFO, exactly as in QUnit. A hook that calls assert.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 async module callback. Module setup that needs to wait goes in before().
  • Steps β€” assert.step() is not an assertion of its own; the verifySteps() 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, global hooks, assert.closeTo, and the error that assert.throws / assert.rejects return 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 a test.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.


How it works

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         # Deno

Browser 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.


Links

About

Worlds first JS/TS universal automated testing library for any JavaScript/TypeScript runtime(s), only one to my knowledge.

Resources

Stars

36 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages