qunitx 1.2.19 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,90 +2,117 @@
2
2
 
3
3
  # QUnitX
4
4
 
5
+ **One test file. Node.js, Deno, and the browser. No config.**
6
+
5
7
  [![CI](https://github.com/izelnakri/qunitx/actions/workflows/ci.yml/badge.svg)](https://github.com/izelnakri/qunitx/actions/workflows/ci.yml)
6
8
  [![codecov](https://codecov.io/gh/izelnakri/qunitx/branch/main/graph/badge.svg)](https://codecov.io/gh/izelnakri/qunitx)
7
9
  [![npm](https://img.shields.io/npm/v/qunitx)](https://www.npmjs.com/package/qunitx)
10
+ [![JSR](https://jsr.io/badges/@izelnakri/qunitx)](https://jsr.io/@izelnakri/qunitx)
11
+ [![JSR score](https://jsr.io/badges/@izelnakri/qunitx/score)](https://jsr.io/@izelnakri/qunitx)
8
12
  [![npm downloads](https://img.shields.io/npm/dm/qunitx)](https://www.npmjs.com/package/qunitx)
9
13
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
10
- [![Ask me anything](https://img.shields.io/badge/ask%20me-anything-1abc9c.svg)](https://github.com/izelnakri/qunitx/issues)
11
14
  [![Sponsor](https://img.shields.io/badge/sponsor-%E2%99%A5-pink)](https://github.com/sponsors/izelnakri)
12
15
 
13
- </div>
16
+ ![QUnitX demo](https://raw.githubusercontent.com/izelnakri/qunitx/main/docs/demo.gif)
14
17
 
15
- **The oldest, most battle-tested JavaScript test API — now universal.**
18
+ <sub>Left: `node --test` and `deno test` on the same file, including watch mode going red → green.<br>
19
+ Right: the same file in real Chrome, with QUnit's filterable, shareable UI.</sub>
16
20
 
17
- Run the **same test file** in Node.js, Deno, and the browser without changes, their usual way.
18
- Zero dependencies. No config needed for Node. TypeScript works out of the box.
21
+ </div>
19
22
 
20
23
  ---
21
24
 
22
- ## Why QUnit?
25
+ Write a test once. Run it under Node's built-in runner, Deno's built-in runner, and a
26
+ real browser — the **same file**, unchanged, each runtime its usual way.
23
27
 
24
- QUnit was created in 2008 by the jQuery team. While newer frameworks come and go,
25
- QUnit has quietly accumulated 16+ years of real-world edge-case handling that younger
26
- tools are still catching up to. Its assertion API is the most mature in the JavaScript
27
- ecosystem:
28
-
29
- - **`assert.deepEqual`** — handles circular references, prototype chains, Sets, Maps,
30
- typed arrays, Dates, RegExps, and getters correctly
31
- - **`assert.throws` / `assert.rejects`** — match errors by constructor, regex, or custom validator
32
- - **`assert.step` / `assert.verifySteps`** — declarative execution-order verification;
33
- catches missing async callbacks that other frameworks silently swallow
34
- - **`assert.expect(n)`** — fails the test if exactly _n_ assertions didn't run;
35
- invaluable for async code where missing assertions would otherwise pass silently
36
- - **`assert.timeout(ms)`** — hard deadline per test; the test fails with a descriptive
37
- message if it doesn't complete in time (all three runtimes)
38
- - **Hooks** — `before`, `beforeEach`, `afterEach`, `after` with correct FIFO/LIFO ordering,
39
- properly scoped across nested modules
40
- - **Shareable browser URLs** — the QUnit browser UI filters tests via query params, so you can
41
- share `https://yourapp.test/?moduleId=abc123` with a colleague and they see exactly the same view
42
-
43
- QUnitX wraps this API to work with **Node.js's built-in `node:test` runner** and
44
- **Deno's native test runner** — no Jest, Vitest, or other framework needed.
45
-
46
- QUnit includes the fastest assertion and test runtime in JS world. I've previously contributed to some [speed optimizations](https://qunitjs.com/blog/2022/02/15/qunit-2-18-0/) to QUnit, we benchmark every possible thing to make it the fastest test
47
- runtime, faster than node.js and deno default assertions in most cases. Therefore I consider myself very objective
48
- when I say QUnit(X) is the best JS/TS testing tool out there.
28
+ ```ts
29
+ import { module, test } from 'qunitx';
49
30
 
50
- ---
31
+ module('Math', () => {
32
+ test('addition', (assert) => {
33
+ assert.equal(2 + 2, 4);
34
+ });
35
+ });
36
+ ```
51
37
 
52
- ## Demo
38
+ ```sh
39
+ node --test math-test.ts # Node's runner
40
+ deno test math-test.ts # Deno's runner
41
+ qunitx math-test.ts # real browser, TAP in your terminal
42
+ ```
53
43
 
54
- > Left window: `node --test` and `deno test` running the same file.
55
- > Right window: QUnit browser UI with filterable, shareable test results.
44
+ No `jest.config.js`. No `vitest.config.ts`. No setup files, no transform pipeline, no
45
+ globals to inject. TypeScript runs as-is. **Zero runtime dependencies.**
56
46
 
57
- <!-- Demo GIF: see docs/demo.tape (VHS script) for terminal portion.
58
- For the combined terminal + browser recording, see "Recording the demo" below. -->
59
- ![QUnitX demo](https://raw.githubusercontent.com/izelnakri/qunitx/main/docs/demo.gif)
47
+ ---
60
48
 
61
- Live browser UI example (click to see filterable QUnit test suite):
49
+ ## Why QUnit?
62
50
 
51
+ QUnit was created in 2008 by the jQuery team. Newer frameworks come and go; QUnit has
52
+ quietly accumulated 16+ years of real-world edge-case handling that younger tools are
53
+ still catching up to. Its assertion API is the most mature in the JavaScript ecosystem:
54
+
55
+ - **`assert.deepEqual`** handles circular references, prototype chains, Sets, Maps,
56
+ typed arrays, Dates, RegExps and getters correctly — the cases that quietly return
57
+ the wrong answer elsewhere.
58
+ - **`assert.throws` / `assert.rejects`** match errors by constructor, regex, error
59
+ instance, or a custom validator.
60
+ - **`assert.step` / `verifySteps`** turn execution order into a declarative assertion,
61
+ and catch missing async callbacks that other frameworks silently swallow.
62
+ - **`assert.expect(n)`** fails the test when exactly _n_ assertions did not run —
63
+ invaluable for async code, where a missing assertion otherwise looks like a pass.
64
+ - **`assert.timeout(ms)`** is a hard per-test deadline, on all three runtimes.
65
+ - **Hooks** — `before`, `beforeEach`, `afterEach`, `after` with correct FIFO/LIFO
66
+ ordering, properly scoped across nested modules.
67
+ - **Shareable browser URLs** — the QUnit UI keeps filter state in the query string, so
68
+ `?moduleId=abc123` reproduces exactly what you were looking at for a colleague.
69
+
70
+ It is also fast. I've contributed [speed optimisations to QUnit itself](https://qunitjs.com/blog/2022/02/15/qunit-2-18-0/),
71
+ and we benchmark every part of this wrapper — in most cases the assertions beat Node's
72
+ and Deno's built-in ones. So I am not especially objective when I say QUnit(X) is the
73
+ best testing tool in JS/TS. But I have the benchmarks.
74
+
75
+ QUnitX wraps that API over **`node:test`** and **Deno's native runner**. No Jest, no
76
+ Vitest, no second framework underneath.
77
+
78
+ **Live example** — a real suite in the browser UI, filtered and linkable:
63
79
  [objectmodel.js.org/test/?moduleId=6e15ed5f](https://objectmodel.js.org/test/?moduleId=6e15ed5f&moduleId=950ec9c5)
64
80
 
65
81
  ---
66
82
 
67
- ## Installation
83
+ ## Install
68
84
 
69
85
  ```sh
70
86
  npm install qunitx --save-dev
71
87
  ```
72
88
 
73
- Requires **Node.js >= 22** (LTS) or **Deno >= 2**.
89
+ Deno users can install from JSR instead — same library, published from CI with
90
+ [provenance](https://jsr.io/@izelnakri/qunitx):
74
91
 
75
- No config file needed. No `jest.config.js`, no `vitest.config.ts`, no setup files — just import and run.
76
- TypeScript is supported natively: pass `.ts` files directly to `node --test` or `deno test`.
92
+ ```sh
93
+ deno add jsr:@izelnakri/qunitx
94
+ ```
95
+
96
+ Requires **Node.js >= 22** (LTS) or **Deno >= 2**. For a Deno project with no
97
+ `package.json`, an import map is enough:
98
+
99
+ ```sh
100
+ echo '{"imports": {"qunitx": "npm:qunitx"}}' > deno.json
101
+ ```
77
102
 
78
103
  ---
79
104
 
80
105
  ## Quick start
81
106
 
107
+ One file, every runtime:
108
+
82
109
  ```ts
83
- // math-test.ts (works in Node, Deno, and browser unchanged)
110
+ // math-test.ts
84
111
  import { module, test } from 'qunitx';
85
112
 
86
113
  module('Math utilities', (hooks) => {
87
- hooks.before((assert) => {
88
- assert.step('setup complete');
114
+ hooks.beforeEach(function () {
115
+ this.numbers = [1, 2, 3];
89
116
  });
90
117
 
91
118
  test('addition', (assert) => {
@@ -93,14 +120,17 @@ module('Math utilities', (hooks) => {
93
120
  assert.notEqual(2 + 2, 5);
94
121
  });
95
122
 
96
- test('deepEqual', (assert) => {
97
- assert.deepEqual({ a: 1, b: [2, 3] }, { a: 1, b: [2, 3] });
123
+ test('deepEqual understands Sets and Maps', (assert) => {
124
+ assert.deepEqual(
125
+ { tags: new Set(['js']), scores: new Map([['a', 1]]) },
126
+ { tags: new Set(['js']), scores: new Map([['a', 1]]) },
127
+ );
98
128
  });
99
129
 
100
130
  module('Async', () => {
101
- test('resolves correctly', async (assert) => {
102
- const result = await Promise.resolve(42);
103
- assert.strictEqual(result, 42);
131
+ test('resolves', async (assert) => {
132
+ const user = await Promise.resolve({ id: 1, name: 'Alice' });
133
+ assert.propContains(user, { name: 'Alice' });
104
134
  });
105
135
  });
106
136
  });
@@ -109,55 +139,34 @@ module('Math utilities', (hooks) => {
109
139
  ### Node.js
110
140
 
111
141
  ```sh
112
- # No extra dependencies — uses the Node built-in test runner
113
- node --test math-test.ts
114
-
115
- # Watch mode (re-runs on save)
116
- node --test --watch math-test.ts
117
-
118
- # Glob pattern
119
- node --test --watch 'test/**/*.ts'
120
-
121
- # Code coverage
122
- npx c8 node --test math-test.ts
142
+ node --test math-test.ts # built-in runner, no extra deps
143
+ node --test --watch math-test.ts # rerun on save
144
+ node --test --watch 'test/**/*.ts' # globs
145
+ npx c8 node --test math-test.ts # coverage
123
146
  ```
124
147
 
125
148
  ### Deno
126
149
 
127
150
  ```sh
128
- # Install qunitx once (same command as Node)
129
- npm install qunitx --save-dev
130
-
131
- # Run
132
151
  deno test math-test.ts
133
-
134
- # With explicit permissions
135
152
  deno test --allow-read --allow-env math-test.ts
136
- ```
137
-
138
- For Deno-only projects without a `package.json`, use an import map instead:
139
-
140
- ```sh
141
- echo '{"imports": {"qunitx": "npm:qunitx"}}' > deno.json
142
- deno test math-test.ts
153
+ deno test --coverage=cov math-test.ts
143
154
  ```
144
155
 
145
156
  ### Browser
146
157
 
147
- Use [qunitx-cli](https://github.com/izelnakri/qunitx-cli) to get browser test output
148
- in your terminal / CI, or to open the live QUnit UI during development:
158
+ [qunitx-cli](https://github.com/izelnakri/qunitx-cli) bundles your test file, runs it in
159
+ a real browser, and streams TAP back to your terminal:
149
160
 
150
161
  ```sh
151
162
  npm install -g qunitx-cli
152
163
 
153
- # Headless (CI-friendly — outputs TAP to stdout)
154
- qunitx math-test.ts
155
-
156
- # Open QUnit browser UI alongside terminal output
157
- qunitx math-test.ts --debug
164
+ qunitx math-test.ts # headless — TAP on stdout, CI-friendly
165
+ qunitx math-test.ts --debug # opens the live QUnit UI too
158
166
  ```
159
167
 
160
168
  The browser UI lets you:
169
+
161
170
  - Filter by module or test name (filter state is preserved in the URL)
162
171
  - Share a link that reproduces the exact filtered view with a colleague
163
172
  - Re-run individual tests by clicking them
@@ -170,206 +179,387 @@ The browser UI lets you:
170
179
 
171
180
  ---
172
181
 
173
- ## Migrating from QUnit
182
+ ## Assertions
183
+
184
+ Every assertion takes an optional trailing `message` used in the failure output. This is
185
+ the complete list.
186
+
187
+ ### Truthiness
188
+
189
+ | | |
190
+ |---|---|
191
+ | `assert.ok(value)` | passes if `value` is truthy |
192
+ | `assert.notOk(value)` | passes if `value` is falsy |
193
+ | `assert.true(value)` | passes only for exactly `true` |
194
+ | `assert.false(value)` | passes only for exactly `false` |
174
195
 
175
- One import line is all that changes:
196
+ ### Equality
197
+
198
+ | | |
199
+ |---|---|
200
+ | `assert.equal(actual, expected)` | loose `==`, after QUnit's coercion rules |
201
+ | `assert.notEqual(actual, expected)` | loose `!=` |
202
+ | `assert.strictEqual(actual, expected)` | strict `===` — the one you usually want |
203
+ | `assert.notStrictEqual(actual, expected)` | strict `!==` |
204
+ | `assert.deepEqual(actual, expected)` | deep structural equality: nested objects, arrays, `Set`, `Map`, typed arrays, `Date`, `RegExp`, circular references |
205
+ | `assert.notDeepEqual(actual, expected)` | inverse of `deepEqual` |
206
+ | `assert.propEqual(actual, expected)` | compares **own enumerable properties only** — ignores prototype and class identity, so a class instance can equal a plain object |
207
+ | `assert.notPropEqual(actual, expected)` | inverse of `propEqual` |
208
+ | `assert.propContains(actual, expected)` | **partial** match: every property in `expected` must match, extras in `actual` are ignored |
209
+ | `assert.notPropContains(actual, expected)` | inverse of `propContains` |
210
+
211
+ `propContains` is the one to reach for when asserting on an API response you only partly
212
+ care about:
176
213
 
177
214
  ```js
178
- // Before:
179
- import { module, test } from 'qunit';
215
+ test('api response', async (assert) => {
216
+ const user = await fetchUser(1);
217
+ // passes regardless of createdAt, updatedAt, avatarUrl, …
218
+ assert.propContains(user, { id: 1, role: 'admin' });
219
+ });
220
+ ```
180
221
 
181
- // After:
182
- import { module, test } from 'qunitx';
222
+ ### Errors
223
+
224
+ | | |
225
+ |---|---|
226
+ | `assert.throws(fn, expected?)` | `fn` must throw |
227
+ | `await assert.rejects(promise, expected?)` | `promise` must reject |
228
+
229
+ `expected` matches by constructor, by `RegExp` against the message, against another
230
+ `Error` instance (same constructor, name and message), or by a custom validator:
231
+
232
+ ```js
233
+ test('error matching', (assert) => {
234
+ assert.throws(() => JSON.parse('{'), SyntaxError); // constructor
235
+ assert.throws(() => parse(''), /unexpected end/i); // message regex
236
+ assert.throws(() => parse(''), new SyntaxError('unexpected')); // Error instance
237
+ assert.throws(() => run(), (err) => err.code === 'EAGAIN'); // validator
238
+ });
239
+
240
+ test('async errors', async (assert) => {
241
+ await assert.rejects(fetchMissing(), TypeError);
242
+ });
183
243
  ```
184
244
 
185
- ---
245
+ > Passing an `async` function to `assert.throws` is a mistake that silently passes in
246
+ > most frameworks — it returns a rejected promise rather than throwing. QUnitX detects
247
+ > it and tells you to use `assert.rejects`.
186
248
 
187
- ## QUnit compatibility
249
+ ### Execution order
188
250
 
189
- qunitx follows the same test-environment model as QUnit:
251
+ | | |
252
+ |---|---|
253
+ | `assert.step(label)` | records a step |
254
+ | `assert.verifySteps(labels)` | asserts the recorded steps equal `labels` in order, then clears them |
190
255
 
191
- - **Fresh context per test** — each test gets its own `this` object. Writes in one test never bleed into a sibling.
192
- - **Prototype-chain inheritance** — a parent module's `before()` hook sets properties on the module context. Each test inherits those properties, so reads work naturally (`this.x`) while writes stay local to the test.
193
- - **`before()` assertions** — attributed to the first test in the module (matching QUnit's attribution model).
194
- - **`after()` assertions** — attributed to the last test in the module.
195
- - **Hook ordering** — `before`/`beforeEach` run FIFO; `afterEach`/`after` run LIFO, exactly as in QUnit.
256
+ ```js
257
+ test('callbacks fire in order', (assert) => {
258
+ emitter.on('start', () => assert.step('start'));
259
+ emitter.on('done', () => assert.step('done'));
196
260
 
197
- > **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.
261
+ emitter.run();
198
262
 
199
- ### `context` — arrow-function-friendly shared state
263
+ assert.verifySteps(['start', 'done']);
264
+ });
265
+ ```
200
266
 
201
- QUnit exposes the shared test context as `this` inside hooks and test bodies. This works with regular functions but not with arrow functions, since arrow functions capture `this` lexically from the surrounding scope.
267
+ ### Async control
202
268
 
203
- QUnitX adds `context` to the meta second argument of every hook and test callback. It points to the same shared object as `this`, so you can use whichever style you prefer:
269
+ | | |
270
+ |---|---|
271
+ | `assert.expect(n)` | the test fails unless exactly `n` assertions ran |
272
+ | `assert.timeout(ms)` | hard deadline for this test; calling again resets it |
273
+ | `assert.async()` | returns a `done` callback; the test waits until every `done` is called |
204
274
 
205
275
  ```js
206
- // QUnit style — regular functions, uses `this`
207
- module('Suite', function (hooks) {
208
- hooks.before(function () { this.db = createDb(); });
209
- hooks.afterEach(function (assert) { this.db.reset(); });
276
+ test('callback-style async', (assert) => {
277
+ const done = assert.async();
278
+ assert.expect(1);
210
279
 
211
- test('query', function (assert) {
212
- assert.ok(this.db.query('SELECT 1'));
280
+ fs.readFile('data.json', (err, buf) => {
281
+ assert.ok(buf.length > 0);
282
+ done();
213
283
  });
214
284
  });
215
285
 
216
- // QUnitX style — arrow functions, uses `context`
217
- module('Suite', (hooks, { context }) => {
218
- context.shared = 'module-level value';
286
+ test('deadline', async (assert) => {
287
+ assert.timeout(500);
288
+ assert.ok(await fetchSomething());
289
+ });
290
+ ```
219
291
 
220
- hooks.before((assert, { context }) => { context.db = createDb(); });
221
- hooks.afterEach((assert, { context }) => { context.db.reset(); });
292
+ `assert.expect()` is what turns "the callback never fired" from a silent pass into a
293
+ failure. For `async`/`await` tests just use an `async` callback — `assert.async()` is for
294
+ callback APIs.
222
295
 
223
- test('query', (assert, { context }) => {
224
- assert.ok(context.db.query('SELECT 1'));
296
+ ### Custom assertions
297
+
298
+ `assert.pushResult` is the building block every other assertion is made of. The plain
299
+ version is a function that takes `assert`:
300
+
301
+ ```js
302
+ function assertIsEven(assert, value) {
303
+ assert.pushResult({
304
+ result: value % 2 === 0,
305
+ actual: value,
306
+ expected: 'an even number',
307
+ message: `${value} is even`,
225
308
  });
309
+ }
310
+
311
+ test('even numbers', (assert) => {
312
+ assertIsEven(assert, 4);
226
313
  });
227
314
  ```
228
315
 
229
- Context inheritance follows QUnit's prototype-chain model regardless of which style you use: `before()` writes are visible to all tests in the module; each test's own writes stay local to that test.
316
+ Better: put it **on `assert` itself**, so it reads like every built-in assertion and
317
+ needs no import at the call site. The assertion class is exported from `qunitx/assert`,
318
+ and adding to its prototype once — in a setup file, or at the top of your test helper —
319
+ makes it available in every test:
320
+
321
+ ```js
322
+ // test-helpers.js
323
+ import Assert from 'qunitx/assert';
324
+
325
+ Assert.prototype.isEven = function (value, message) {
326
+ this.pushResult({
327
+ result: value % 2 === 0,
328
+ actual: value,
329
+ expected: 'an even number',
330
+ message: message ?? `${value} is even`,
331
+ });
332
+ };
333
+ ```
334
+
335
+ ```js
336
+ import './test-helpers.js';
337
+
338
+ test('even numbers', (assert) => {
339
+ assert.isEven(4); // passes
340
+ assert.isEven(3, 'three is even'); // fails, with your message
341
+ });
342
+ ```
343
+
344
+ In TypeScript, declare the extended shape and assign through it:
345
+
346
+ ```ts
347
+ import BaseAssert from 'qunitx/assert';
348
+
349
+ export interface Assert extends BaseAssert {
350
+ isEven(value: number, message?: string): void;
351
+ }
352
+
353
+ (BaseAssert.prototype as Assert).isEven = function (value, message) {
354
+ this.pushResult({
355
+ result: value % 2 === 0,
356
+ actual: value,
357
+ expected: 'an even number',
358
+ message: message ?? `${value} is even`,
359
+ });
360
+ };
361
+ ```
362
+
363
+ > `qunitx/assert` default-exports the class, so `declare module 'qunitx/assert'` cannot
364
+ > merge an interface into it — TypeScript can only augment *named* exports. Hence the
365
+ > explicit `extends` above. Test callbacks still receive the base type, so annotate the
366
+ > parameter (`(assert: Assert) => …`) where you call a custom assertion.
367
+
368
+ `qunitx/assert` touches no runner API, so it resolves the same way in Node, Deno and the
369
+ browser — a helper written against it is as portable as the tests that use it.
230
370
 
231
371
  ---
232
372
 
233
- ## Skip and todo
373
+ ## Modules and hooks
234
374
 
235
- Mark individual tests or entire modules as skipped or pending:
375
+ `module()` groups tests and can nest. Hooks are declared on the `hooks` argument:
236
376
 
237
377
  ```js
238
- import { module, test } from 'qunitx';
239
-
240
- module('Suite', () => {
241
- // Skip a single test — body never runs
242
- test.skip('not yet implemented', (assert) => {
243
- assert.ok(false); // never executes
378
+ module('Database', (hooks) => {
379
+ hooks.before(async function () { this.db = await connect(); }); // once, first
380
+ hooks.beforeEach(function () { this.tx = this.db.begin(); }); // before each test
381
+ hooks.afterEach(function () { this.tx.rollback(); }); // after each test
382
+ hooks.after(async function () { await this.db.close(); }); // once, last
383
+
384
+ test('insert', function (assert) {
385
+ assert.ok(this.tx.insert({ id: 1 }));
244
386
  });
245
387
 
246
- // todo — body runs but failures do not fail the suite
247
- test.todo('multiplication', (assert) => {
248
- assert.equal(2 * 3, 6);
388
+ module('Nested', () => {
389
+ // parent hooks still apply, outermost first
390
+ test('inherits the transaction', function (assert) {
391
+ assert.ok(this.tx);
392
+ });
249
393
  });
394
+ });
395
+ ```
250
396
 
251
- // todo with no body — registers the test as pending
252
- test.todo('division');
397
+ `before` / `beforeEach` run FIFO (outermost first); `afterEach` / `after` run LIFO
398
+ (innermost first) — exactly as in QUnit.
253
399
 
254
- // Skip via runtime options (useful for conditional or cross-runtime skipping)
255
- test('flaky on CI', { skip: true }, (assert) => { assert.ok(true); });
256
- test('not ready', { todo: 'needs implementation' }, (assert) => { assert.ok(false); });
257
- });
400
+ ### `describe` / `it` — BDD aliases
258
401
 
259
- // Skip or todo an entire module
260
- module.skip('Legacy API', () => {
261
- test('old behaviour', (assert) => { assert.ok(false); }); // never runs
262
- });
402
+ If you prefer BDD naming, `describe` and `it` are exported as aliases of `module` and
403
+ `test`. They are the *same function objects*, so everything that works on one works on
404
+ the other — options, `.skip`, `.todo`, nesting, and mixing the two styles in one file:
405
+
406
+ ```js
407
+ import { describe, it } from 'qunitx';
408
+
409
+ describe('Math utilities', (hooks) => {
410
+ hooks.beforeEach(function () {
411
+ this.numbers = [1, 2, 3];
412
+ });
263
413
 
264
- module.todo('Future features', () => {
265
- test('planned', (assert) => { assert.ok(false); }); // never runs
414
+ it('adds', (assert) => {
415
+ assert.equal(2 + 2, 4);
416
+ });
417
+
418
+ it.skip('not yet implemented', (assert) => { /* never runs */ });
419
+ it('flaky on CI', { skip: true }, (assert) => {});
420
+
421
+ describe('Nested', () => {
422
+ it('inherits parent hooks', function (assert) {
423
+ assert.deepEqual(this.numbers, [1, 2, 3]);
424
+ });
425
+ });
266
426
  });
267
427
  ```
268
428
 
269
- Top-level `skip` and `todo` aliases are also exported for QUnit-style usage:
429
+ ### `context` — for arrow functions
430
+
431
+ QUnit exposes shared state as `this`, which arrow functions cannot see. QUnitX passes the
432
+ same object as `context` in the second argument of every hook and test, so both styles
433
+ work:
270
434
 
271
435
  ```js
272
- import { module, test, skip, todo } from 'qunitx';
436
+ // QUnit style — regular functions, `this`
437
+ module('Suite', function (hooks) {
438
+ hooks.before(function () { this.db = createDb(); });
439
+ test('query', function (assert) { assert.ok(this.db.query('SELECT 1')); });
440
+ });
273
441
 
274
- module('Suite', () => {
275
- skip('skipped test', (assert) => { assert.ok(false); });
276
- todo('pending test', (assert) => { assert.ok(false); });
442
+ // QUnitX style — arrow functions, `context`
443
+ module('Suite', (hooks, { context }) => {
444
+ hooks.before((assert, { context }) => { context.db = createDb(); });
445
+ test('query', (assert, { context }) => { assert.ok(context.db.query('SELECT 1')); });
277
446
  });
278
447
  ```
279
448
 
280
- > **Node vs. Deno behaviour:** Node's `test.todo` runs the body but does not count failures
281
- > against the suite. Deno has no native todo concept and maps both `skip` and `todo` to
282
- > "ignored" — the body does not run.
449
+ Either way the state follows QUnit's prototype-chain model: each test gets a fresh object
450
+ inheriting from the module's, so `before()` writes are visible everywhere and a test's own
451
+ writes never leak into its siblings.
283
452
 
284
453
  ---
285
454
 
286
- ## Timeouts
455
+ ## Skip and todo
287
456
 
288
457
  ```js
289
- test('slow async operation', async (assert) => {
290
- assert.timeout(500); // fail after 500 ms if not complete
458
+ test.skip('not yet implemented', (assert) => { /* never runs */ });
459
+ test.todo('known broken', (assert) => { /* runs; failures do not fail the suite */ });
460
+ test.todo('not even started'); // no body — registers as pending
291
461
 
292
- const result = await fetchSomething();
293
- assert.ok(result);
294
- });
462
+ test('flaky on CI', { skip: true }, (assert) => {}); // conditional, via options
463
+ test('waiting on API', { todo: 'needs endpoint' }, (assert) => {});
295
464
 
296
- test('adjustable deadline', async (assert) => {
297
- assert.timeout(100);
298
- assert.timeout(500); // calling again resets the deadline
299
- await doWork();
300
- assert.ok(true);
301
- });
465
+ module.skip('Legacy API', () => { /* whole module skipped */ });
466
+ module.todo('Future features', () => { /* whole module pending */ });
467
+ ```
468
+
469
+ `skip` and `todo` are also exported top-level for QUnit-style usage:
470
+
471
+ ```js
472
+ import { module, test, skip, todo } from 'qunitx';
302
473
  ```
303
474
 
304
- `assert.timeout(ms)` sets a hard deadline for the current test. If the test does not complete
305
- within `ms` milliseconds the runner aborts it with a descriptive error message.
475
+ The BDD aliases carry the same helpers — `describe.skip`, `describe.todo`, `it.skip`,
476
+ and `it.todo` are the exact same functions.
306
477
 
307
- `assert.timeout()` works across all three runtimes. In Node.js and Deno it is handled by
308
- qunitx's internal deadline mechanism; in the browser it delegates to QUnit's native
309
- `assert.timeout()` (available since QUnit 2.16).
478
+ > **Runtime difference:** Node's `todo` runs the body and ignores its failures. Deno has
479
+ > no todo concept and maps both `skip` and `todo` to "ignored" — the body does not run.
310
480
 
311
481
  ---
312
482
 
313
- ## Concurrency
483
+ ## Runtime options
314
484
 
315
- Tests run **sequentially by default** — matching QUnit's browser behavior where tests run one at a time. You can enable concurrency by passing options to the underlying Node / Deno runner:
485
+ The optional second argument to `module()` and `test()` is passed straight through to the
486
+ underlying runner, so anything `node:test` or Deno BDD accepts works:
316
487
 
317
488
  ```js
318
- import { module, test } from 'qunitx';
319
-
320
- // Enable parallel execution for this module (Node/Deno only)
321
- module('Parallel suite', { concurrency: true }, (hooks) => {
322
- test('first', (assert) => { assert.ok(true); });
323
- test('second', (assert) => { assert.ok(true); });
324
- });
489
+ // Tests run sequentially by default, matching QUnit's browser behaviour.
490
+ module('Parallel suite', { concurrency: true }, () => { /* … */ });
325
491
 
326
- // Deno-specific: permissions, sanitizeExit, etc.
327
- module('Deno file access', { permissions: { read: true }, sanitizeExit: false }, (hooks) => {
492
+ // Deno-specific options
493
+ module('File access', { permissions: { read: true }, sanitizeExit: false }, () => {
328
494
  test('reads a file', async (assert) => {
329
- const text = await Deno.readTextFile('./README.md');
330
- assert.ok(text.length > 0);
495
+ assert.ok((await Deno.readTextFile('./README.md')).length > 0);
331
496
  });
332
497
  });
333
498
  ```
334
499
 
500
+ Options a runtime does not recognise are ignored, which is what keeps one file portable
501
+ across all three.
502
+
335
503
  ---
336
504
 
337
- ## How it works
505
+ ## Migrating from QUnit
338
506
 
339
- | Runtime | Adapter |
340
- |---------|---------|
341
- | Node.js | Wraps `node:test` `describe` / `it` with QUnit lifecycle |
342
- | Deno | Wraps Deno BDD helpers with the same QUnit lifecycle |
343
- | Browser | Thin re-export of QUnit's native browser API |
507
+ One line:
508
+
509
+ ```diff
510
+ - import { module, test } from 'qunit';
511
+ + import { module, test } from 'qunitx';
512
+ ```
344
513
 
345
- The browser path is literally QUnit itself, so you get full QUnit compatibility:
346
- plugins, custom reporters, the event API (`QUnit.on`, `QUnit.done`, etc.), and the
347
- familiar browser UI with zero extra layers.
514
+ Coming from a BDD runner instead? Import `describe` / `it` — they are aliases of
515
+ `module` / `test`, see [`describe` / `it`](#describe--it--bdd-aliases).
348
516
 
349
517
  ---
350
518
 
351
- ## Code coverage
519
+ ## QUnit compatibility
352
520
 
353
- qunitx loads as a regular dependency rather than wrapping the runtime process, so
354
- Node.js and Deno's built-in v8 coverage instruments it naturally — no special configuration needed.
521
+ qunitx follows the same test-environment model as QUnit:
355
522
 
356
- ```sh
357
- # Node (any c8-compatible reporter)
358
- npx c8 node --test test/
523
+ - **Fresh context per test** — each test gets its own `this` object. Writes in one test never bleed into a sibling.
524
+ - **Prototype-chain inheritance** — a parent module's `before()` hook sets properties on the module context. Each test inherits those properties, so reads work naturally (`this.x`) while writes stay local to the test.
525
+ - **`before()` assertions** — attributed to the first test in the module (matching QUnit's attribution model).
526
+ - **`after()` assertions** — attributed to the last test in the module.
527
+ - **Hook ordering** — `before`/`beforeEach` run FIFO; `afterEach`/`after` run LIFO, exactly as in QUnit.
528
+
529
+ > **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.
359
530
 
360
- # View HTML report
531
+ ---
532
+
533
+ ## How it works
534
+
535
+ | Runtime | Adapter |
536
+ |---|---|
537
+ | Node.js | wraps `node:test`'s `describe` / `it` with the QUnit lifecycle |
538
+ | Deno | wraps Deno's BDD helpers with the same lifecycle |
539
+ | Browser | a thin re-export of QUnit itself |
540
+
541
+ Because the browser path *is* QUnit, you get the whole thing there: plugins, custom
542
+ reporters, the event API (`QUnit.on`, `QUnit.done`, …) and the familiar UI, with no extra
543
+ layer in between.
544
+
545
+ QUnitX loads as an ordinary dependency instead of wrapping the runtime process, so v8
546
+ coverage instruments it with no special configuration:
547
+
548
+ ```sh
549
+ npx c8 node --test test/ # Node
361
550
  npx c8 --reporter=html node --test test/ && open coverage/index.html
551
+ deno test --coverage=cov test/ && deno coverage cov # Deno
362
552
  ```
363
553
 
364
- Browser-mode coverage is limited because qunitx-cli bundles test files with esbuild.
365
- Native ES import maps support in Puppeteer/Chrome would eliminate the bundling step
366
- and unlock v8 instrumentation for browser coverage.
554
+ Browser coverage is the one gap — qunitx-cli bundles test files with esbuild, which
555
+ loses the mapping. Native import-map support in headless Chrome would remove the bundling
556
+ step and with it the limitation.
367
557
 
368
558
  ---
369
559
 
370
560
  ## Links
371
561
 
372
- - [QUnit API reference](https://api.qunitjs.com)
373
- - [qunitx-cli](https://github.com/izelnakri/qunitx-cli) — browser runner, CI reporter, and all HTML/runner configuration options
374
- - [Node.js test runner docs](https://nodejs.org/api/test.html)
375
- - [Deno testing docs](https://docs.deno.com/runtime/fundamentals/testing/)
562
+ - [QUnit API reference](https://api.qunitjs.com) — every assertion, in depth
563
+ - [qunitx-cli](https://github.com/izelnakri/qunitx-cli) — the browser runner and all of its options
564
+ - [`@izelnakri/qunitx` on JSR](https://jsr.io/@izelnakri/qunitx) · [`qunitx` on npm](https://www.npmjs.com/package/qunitx)
565
+ - [Node.js test runner](https://nodejs.org/api/test.html) · [Deno testing](https://docs.deno.com/runtime/fundamentals/testing/)