Skip to content

Commit 2c976df

Browse files
committed
Add “assert.throws” and “assert.rejects”.
In general, we only support a _strict_ subset of the `node:assert` interface. We aim to support things that are genuinely tricky to encode yourself or are genuinely annoying to write over-and-over in your own test code (e.g., try-catch around throws / rejections). This brings our assert interface to: - `assert(value[, message])` - `assert.deepEqual(actual, expected[, message])` - `assert.rejects(asyncFn[, error][, message])` - `assert.throws(fn[, error][, message])` It’s not clear that we really need more than this. Everything else is either esoteric (e.g., `ifError`), an inverse (e.g., `notDeepEqual`), trivially implemented by other means (e.g., `match`), or literally just an alias for something else (e.g., `ok`). We may choose to flesh some more things out simply for agents trying to do pattern matching… but it all ends up being surface area to maintain.
1 parent 63024cf commit 2c976df

12 files changed

Lines changed: 312 additions & 40 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- `assert.throws(fn, error, message?)` and `assert.rejects(fn, error, message?)`
12+
for asserting that a function throws or an async function rejects. The `error`
13+
argument is a `RegExp` tested against `String(thrown)` (consistent with
14+
`node:assert`) (#99).
15+
916
## [2.0.0-rc.10] - 2026-04-28
1017

1118
### Changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ suite('important feature', () => {
1919
test('should match interface', () => {
2020
assert.deepEqual(MyFeature.interface, { version: '123' }, 'does not match');
2121
});
22+
23+
test('should throw on bad input', () => {
24+
assert.throws(() => MyFeature.doThing(null), /^Error: bad input$/);
25+
});
26+
27+
test('should reject on bad async input', async () => {
28+
await assert.rejects(() => MyFeature.doThingAsync(null), /^Error: bad input$/);
29+
});
2230
});
2331
```
2432

@@ -47,6 +55,8 @@ The following are exposed in the testing interface:
4755
- `suite.todo`: Mark all `test` tests within this group as _todo_.
4856
- `assert`: Simple assertion call that throws if the boolean input is false-y.
4957
- `assert.deepEqual`: Strict deep-equality assertion for primitives, plain objects, and arrays.
58+
- `assert.throws`: Asserts that a function throws, matching the thrown value against a RegExp.
59+
- `assert.rejects`: Asserts that an async function rejects, matching the rejection against a RegExp.
5060

5161
### Parameters
5262

test/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ load('./test-frame.html');
55
load('./test-root.html');
66
load('./test-tap.html');
77
load('./test-common.html');
8+
load('./test-assert.html');
89
load('./test-boot.html');
910
load('./test-scratch.html');

test/shared.js

Lines changed: 0 additions & 17 deletions
This file was deleted.

test/test-assert.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<meta http-equiv="content-security-policy" content="default-src 'self';">
6+
</head>
7+
<body>
8+
<h3>test-assert</h3>
9+
<script type="module" src="test-assert.js"></script>
10+
</body>
11+
</html>

test/test-assert.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { test, suite, assert } from '../x-test.js';
2+
3+
suite('assert', () => {
4+
test('passes for truthy values', () => {
5+
assert(true);
6+
assert(1);
7+
assert('non-empty');
8+
});
9+
10+
test('fails for falsy values', () => {
11+
assert.throws(() => assert(false), /^Error: not ok$/);
12+
assert.throws(() => assert(0), /^Error: not ok$/);
13+
assert.throws(() => assert(''), /^Error: not ok$/);
14+
});
15+
16+
test('uses custom message on failure', () => {
17+
assert.throws(() => assert(false, 'custom'), /^Error: custom$/);
18+
});
19+
});
20+
21+
suite('assert.deepEqual', () => {
22+
test('passes for deeply equal structures', () => {
23+
assert.deepEqual({ a: [1, 2] }, { a: [1, 2] });
24+
assert.deepEqual([1, { b: 'c' }], [1, { b: 'c' }]);
25+
});
26+
27+
test('fails for unequal values', () => {
28+
assert.throws(() => assert.deepEqual({ a: 1 }, { a: 2 }), /^Error: not deep equal$/);
29+
});
30+
31+
test('uses custom message on failure', () => {
32+
assert.throws(() => assert.deepEqual(1, 2, 'custom'), /^Error: custom$/);
33+
});
34+
35+
test('throws for unsupported types', () => {
36+
assert.throws(() => assert.deepEqual(new Map(), new Map()), /^Error: deepEqual only supports primitives, plain objects, and arrays \(got Map\)$/);
37+
assert.throws(() => assert.deepEqual(new Date(0), new Date(0)), /^Error: deepEqual only supports primitives, plain objects, and arrays \(got Date\)$/);
38+
});
39+
40+
test('throws for symbol-keyed properties', () => {
41+
const sym = Symbol('x');
42+
assert.throws(() => assert.deepEqual({ [sym]: 1 }, {}), /^Error: deepEqual does not support symbol-keyed properties\.$/);
43+
});
44+
});
45+
46+
suite('assert.throws', () => {
47+
test('exercises the public assert.throws export', () => {
48+
assert.throws(() => { throw new Error('boom'); }, /boom/);
49+
assert.throws(() => { throw new Error('boom'); }, /^Error: boom$/);
50+
});
51+
52+
test('throws early if fn is not a function', () => {
53+
assert.throws(() => assert.throws('not a function', /boom/), /^Error: unexpected fn value "not a function"$/);
54+
});
55+
56+
test('throws early if error is not a RegExp', () => {
57+
assert.throws(() => assert.throws(() => {}, 'not a regexp'), /^Error: unexpected error value "not a regexp"$/);
58+
});
59+
});
60+
61+
suite('assert.rejects', () => {
62+
test('exercises the public assert.rejects export', async () => {
63+
await assert.rejects(async () => { throw new Error('boom'); }, /boom/);
64+
await assert.rejects(async () => { throw new Error('boom'); }, /^Error: boom$/);
65+
});
66+
67+
test('throws early if fn is not a function', async () => {
68+
await assert.rejects(() => assert.rejects('not a function', /boom/), /^Error: unexpected fn value "not a function"$/);
69+
});
70+
71+
test('throws early if error is not a RegExp', async () => {
72+
await assert.rejects(() => assert.rejects(() => {}, 'not a regexp'), /^Error: unexpected error value "not a regexp"$/);
73+
});
74+
});

test/test-frame.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,79 @@ suite('deepEqual', () => {
619619
});
620620
});
621621

622+
suite('throws', () => {
623+
const makeContext = () => ({ state: { bailed: false } });
624+
const expectFail = (fn) => {
625+
let message;
626+
try { fn(); } catch (error) { message = error.message; }
627+
return message;
628+
};
629+
630+
test('fails when function does not throw', () => {
631+
const message = expectFail(() => XTestFrame.throws(makeContext(), null, () => {}, /foo/));
632+
assert(message === 'expected function to throw');
633+
});
634+
635+
test('passes when RegExp matches', () => {
636+
XTestFrame.throws(makeContext(), null, () => { throw new Error('boom'); }, /boom/);
637+
});
638+
639+
test('fails when RegExp does not match', () => {
640+
const message = expectFail(() => XTestFrame.throws(makeContext(), null, () => { throw new Error('boom'); }, /nope/));
641+
assert(message === 'expected thrown value to match "/nope/"');
642+
});
643+
644+
test('uses custom message on failure', () => {
645+
const message = expectFail(() => XTestFrame.throws(makeContext(), null, () => {}, /foo/, 'custom'));
646+
assert(message === 'custom');
647+
});
648+
649+
test('matches non-Error throws via String()', () => {
650+
XTestFrame.throws(makeContext(), null, () => { throw 2; }, /2/);
651+
XTestFrame.throws(makeContext(), null, () => { throw undefined; }, /undefined/);
652+
});
653+
654+
test('is a no-op when context is bailed', () => {
655+
XTestFrame.throws({ state: { bailed: true } }, null, () => {}, /anything/, 'should not throw');
656+
});
657+
});
658+
659+
suite('rejects', () => {
660+
const makeContext = () => ({ state: { bailed: false } });
661+
const expectFail = async (fn) => {
662+
let message;
663+
try { await fn(); } catch (error) { message = error.message; }
664+
return message;
665+
};
666+
667+
test('fails when function does not reject', async () => {
668+
const message = await expectFail(() => XTestFrame.rejects(makeContext(), null, async () => {}, /foo/));
669+
assert(message === 'expected function to reject');
670+
});
671+
672+
test('passes when RegExp matches', async () => {
673+
await XTestFrame.rejects(makeContext(), null, async () => { throw new Error('boom'); }, /boom/);
674+
});
675+
676+
test('fails when RegExp does not match', async () => {
677+
const message = await expectFail(() => XTestFrame.rejects(makeContext(), null, async () => { throw new Error('boom'); }, /nope/));
678+
assert(message === 'expected rejection value to match "/nope/"');
679+
});
680+
681+
test('uses custom message on failure', async () => {
682+
const message = await expectFail(() => XTestFrame.rejects(makeContext(), null, async () => {}, /foo/, 'custom'));
683+
assert(message === 'custom');
684+
});
685+
686+
test('works with a function returning a rejected Promise', async () => {
687+
await XTestFrame.rejects(makeContext(), null, () => Promise.reject(new Error('boom')), /boom/);
688+
});
689+
690+
test('is a no-op when context is bailed', async () => {
691+
await XTestFrame.rejects({ state: { bailed: true } }, null, async () => {}, /anything/, 'should not throw');
692+
});
693+
});
694+
622695
suite('bail', () => {
623696
test('marks state as ended', () => {
624697
const error = new Error('error test');

test/test-root.js

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { test, suite, assert } from '../x-test.js';
22
import { XTestRoot } from '../x-test-root.js';
3-
import { assertThrows } from './shared.js';
43

54
// Dependency injection.
65
const getContext = () => {
@@ -170,7 +169,8 @@ suite('onDefer', () => {
170169
test('throws on unknown defer type', () => {
171170
const { context } = getContext();
172171
const event = { data: { data: { type: 'unknown' } } };
173-
assertThrows(() => XTestRoot.onDefer(context, event), /Unexpected defer type "unknown"\./);
172+
const fn = () => XTestRoot.onDefer(context, event);
173+
assert.throws(fn, /Unexpected defer type "unknown"\./);
174174
});
175175
});
176176

@@ -207,7 +207,8 @@ suite('handleEmptyPlan', () => {
207207
test('throws when no subtest line is in the queue', () => {
208208
const { context } = getContext();
209209
context.state.queue = [];
210-
assertThrows(() => XTestRoot.handleEmptyPlan(context), /Expected to find matching subtest/);
210+
const fn = () => XTestRoot.handleEmptyPlan(context);
211+
assert.throws(fn, /Expected to find matching subtest/);
211212
});
212213
});
213214

@@ -336,7 +337,8 @@ suite('handleFilteredOutput', () => {
336337
context.state.queue = [];
337338
context.state.stepIds.push('s');
338339
context.state.steps['s'] = { type: 'unknown' };
339-
assertThrows(() => XTestRoot.handleFilteredOutput(context, [], 's'), /Unexpected step type "unknown"/);
340+
const fn = () => XTestRoot.handleFilteredOutput(context, [], 's');
341+
assert.throws(fn, /Unexpected step type "unknown"/);
340342
});
341343
});
342344

@@ -347,7 +349,8 @@ suite('onResult', () => {
347349
context.state.stepIds.push('ts');
348350
context.state.steps['ts'] = { type: 'test', testId: 't', status: 'waiting' };
349351
const event = { data: { data: { testId: 't' } } };
350-
assertThrows(() => XTestRoot.onResult(context, event), /step to complete is not running/);
352+
const fn = () => XTestRoot.onResult(context, event);
353+
assert.throws(fn, /step to complete is not running/);
351354
});
352355
});
353356

@@ -368,7 +371,8 @@ suite('onReady', () => {
368371
context.state.frames['f'] = { href: 'http://test.html', children: [] };
369372
context.state.stepIds.push('fs');
370373
context.state.steps['fs'] = { type: 'frame-start', frameId: 'f', status: 'waiting' };
371-
assertThrows(() => XTestRoot.onReady(context, { data: { data: { frameId: 'f' } } }), /frame to ready is not running/);
374+
const fn = () => XTestRoot.onReady(context, { data: { data: { frameId: 'f' } } });
375+
assert.throws(fn, /frame to ready is not running/);
372376
});
373377

374378
test('inherits suite directive onto tests without one', () => {
@@ -387,7 +391,8 @@ suite('onRegister', () => {
387391
test('throws on unknown registration type', () => {
388392
const { context } = getContext();
389393
const event = { data: { data: { type: 'unknown' } } };
390-
assertThrows(() => XTestRoot.onRegister(context, event), /Unexpected registration type "unknown"\./);
394+
const fn = () => XTestRoot.onRegister(context, event);
395+
assert.throws(fn, /Unexpected registration type "unknown"\./);
391396
});
392397
});
393398

@@ -470,7 +475,8 @@ suite('registerTest', () => {
470475
suite('childOk', () => {
471476
test('throws on unknown child type', () => {
472477
const { context } = getContext();
473-
assertThrows(() => XTestRoot.childOk(context, { type: 'unknown' }), /Unexpected type "unknown"/);
478+
const fn = () => XTestRoot.childOk(context, { type: 'unknown' });
479+
assert.throws(fn, /Unexpected type "unknown"/);
474480
});
475481
});
476482

@@ -479,7 +485,8 @@ suite('href', () => {
479485
const { context } = getContext();
480486
context.state.stepIds.push('s');
481487
context.state.steps['s'] = { type: 'unknown' };
482-
assertThrows(() => XTestRoot.href(context, 's'), /Unexpected type "unknown"/);
488+
const fn = () => XTestRoot.href(context, 's');
489+
assert.throws(fn, /Unexpected type "unknown"/);
483490
});
484491
});
485492

@@ -488,7 +495,8 @@ suite('directive', () => {
488495
const { context } = getContext();
489496
context.state.stepIds.push('s');
490497
context.state.steps['s'] = { type: 'unknown' };
491-
assertThrows(() => XTestRoot.directive(context, 's'), /Unexpected type "unknown"/);
498+
const fn = () => XTestRoot.directive(context, 's');
499+
assert.throws(fn, /Unexpected type "unknown"/);
492500
});
493501
});
494502

@@ -497,7 +505,8 @@ suite('level', () => {
497505
const { context } = getContext();
498506
context.state.stepIds.push('s');
499507
context.state.steps['s'] = { type: 'unknown' };
500-
assertThrows(() => XTestRoot.level(context, 's'), /Unexpected type "unknown"/);
508+
const fn = () => XTestRoot.level(context, 's');
509+
assert.throws(fn, /Unexpected type "unknown"/);
501510
});
502511
});
503512

@@ -506,7 +515,8 @@ suite('count', () => {
506515
const { context } = getContext();
507516
context.state.stepIds.push('s');
508517
context.state.steps['s'] = { type: 'unknown' };
509-
assertThrows(() => XTestRoot.count(context, 's'), /Unexpected type "unknown"/);
518+
const fn = () => XTestRoot.count(context, 's');
519+
assert.throws(fn, /Unexpected type "unknown"/);
510520
});
511521
});
512522

@@ -515,7 +525,8 @@ suite('yaml', () => {
515525
const { context } = getContext();
516526
context.state.stepIds.push('s');
517527
context.state.steps['s'] = { type: 'unknown' };
518-
assertThrows(() => XTestRoot.yaml(context, 's'), /Unexpected type "unknown"/);
528+
const fn = () => XTestRoot.yaml(context, 's');
529+
assert.throws(fn, /Unexpected type "unknown"/);
519530
});
520531
});
521532

@@ -524,7 +535,8 @@ suite('text', () => {
524535
const { context } = getContext();
525536
context.state.stepIds.push('s');
526537
context.state.steps['s'] = { type: 'unknown' };
527-
assertThrows(() => XTestRoot.text(context, 's'), /Unexpected type "unknown"/);
538+
const fn = () => XTestRoot.text(context, 's');
539+
assert.throws(fn, /Unexpected type "unknown"/);
528540
});
529541
});
530542

@@ -533,7 +545,8 @@ suite('number', () => {
533545
const { context } = getContext();
534546
context.state.stepIds.push('s');
535547
context.state.steps['s'] = { type: 'unknown' };
536-
assertThrows(() => XTestRoot.number(context, 's'), /Unexpected type "unknown"/);
548+
const fn = () => XTestRoot.number(context, 's');
549+
assert.throws(fn, /Unexpected type "unknown"/);
537550
});
538551

539552
test('returns position for a test whose direct parent is a frame', () => {
@@ -551,7 +564,8 @@ suite('ok', () => {
551564
const { context } = getContext();
552565
context.state.stepIds.push('s');
553566
context.state.steps['s'] = { type: 'unknown' };
554-
assertThrows(() => XTestRoot.ok(context, 's'), /Unexpected type "unknown"/);
567+
const fn = () => XTestRoot.ok(context, 's');
568+
assert.throws(fn, /Unexpected type "unknown"/);
555569
});
556570
});
557571

@@ -569,7 +583,8 @@ suite('check', () => {
569583
const { context } = getContext();
570584
context.state.stepIds.push('s');
571585
context.state.steps['s'] = { type: 'unknown', status: 'waiting' };
572-
assertThrows(() => XTestRoot.check(context), /Unexpected step type "unknown"/);
586+
const fn = () => XTestRoot.check(context);
587+
assert.throws(fn, /Unexpected step type "unknown"/);
573588
});
574589

575590
test('does not kick off the exit step once the run has ended (e.g. after a bail)', () => {

0 commit comments

Comments
 (0)