Skip to content

Commit 4bfccd3

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 4bfccd3

9 files changed

Lines changed: 226 additions & 34 deletions

File tree

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/shared.js

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

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)', () => {

test/test-scratch.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ suite.only('assert.deepEqual', () => {
3333
});
3434
});
3535

36+
suite.only('assert.throws', () => {
37+
test('exercises the public assert.throws export', () => {
38+
assert.throws(() => { throw new Error('boom'); }, /boom/);
39+
});
40+
});
41+
42+
suite.only('assert.rejects', () => {
43+
test('exercises the public assert.rejects export', async () => {
44+
await assert.rejects(async () => { throw new Error('boom'); }, /boom/);
45+
});
46+
});
47+
3648
suite.only('interval', () => {
3749
test.todo('times out after interval - this is supposed to fail', async () => {
3850
await new Promise(resolve => setTimeout(resolve, 1_000));

types/x-test.d.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,28 @@ export namespace assert {
1313
* @returns {asserts actual is T} Throws if values are not deeply equal.
1414
*/
1515
function deepEqual<T>(actual: unknown, expected: T, message?: string): asserts actual is T;
16+
/**
17+
* Asserts that a function throws, testing the thrown value against a RegExp via `String(thrown)`.
18+
* @example
19+
* assert.throws(() => { throw new Error('boom'); }, /^Error: boom$/);
20+
* assert.throws(() => { throw new Error('boom'); }, new RegExp('.*')); // match anything — just assert it throws
21+
* @param {() => void} fn - The function expected to throw
22+
* @param {RegExp} error - Tested against `String(thrown)`
23+
* @param {string} [message] - The assertion message
24+
* @returns {void}
25+
*/
26+
function throws(fn: () => void, error: RegExp, message?: string): void;
27+
/**
28+
* Asserts that an async function rejects, testing the rejection against a RegExp via `String(thrown)`.
29+
* @example
30+
* await assert.rejects(async () => { throw new Error('boom'); }, /^Error: boom$/);
31+
* await assert.rejects(() => Promise.reject(new Error('boom')), new RegExp('.*')); // match anything — just assert it rejects
32+
* @param {() => Promise<unknown>} fn - The function expected to reject
33+
* @param {RegExp} error - Tested against `String(thrown)`
34+
* @param {string} [message] - The assertion message
35+
* @returns {Promise<void>}
36+
*/
37+
function rejects(fn: () => Promise<unknown>, error: RegExp, message?: string): Promise<void>;
1638
}
1739
export function load(href: string): void;
1840
export function suite(text: string, callback: () => void): void;

0 commit comments

Comments
 (0)