Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,29 @@ it.expectFailure('should do the thing', () => {
it('should do the thing', { expectFailure: true }, () => {
assert.strictEqual(doTheThing(), true);
});

it('should do the thing', { expectFailure: 'feature not implemented' }, () => {
assert.strictEqual(doTheThing(), true);
});

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ability to expect a specific failure deserves some explanation.

Suggested change
```
If the value of `expectFailure` is a
[<RegExp>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp) |
[<Function>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function) |
[<Object>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object) |
[<Error>](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error),
the tests will pass only if they throw a matching value.
See [`assert.throws`] for how the each value type is interpreted.
Each of the following tests will fail _despite_ being flagged `expectFailure`
because the failure was not the expected one.
```js

You'll need to add a link to the assert.throws documentation at the bottom of the file.

it('should fail with specific error', {
Copy link
Contributor

@vassudanagunta vassudanagunta Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finally, I think this example with the {match, label} value should be broken out as the last example with its own explanation, e.g. "To supply both a reason and specific error for assertFailure..."

expectFailure: {
match: /error message/,
label: 'reason for failure',
},
}, () => {
assert.strictEqual(doTheThing(), true);
});

it('should fail with regex', { expectFailure: /error message/ }, () => {
assert.strictEqual(doTheThing(), true);
});

it('should fail with function', {
expectFailure: (err) => err.code === 'ERR_CODE',
}, () => {
assert.strictEqual(doTheThing(), true);
});
```

`skip` and/or `todo` are mutually exclusive to `expectFailure`, and `skip` or `todo`
Expand Down Expand Up @@ -1683,6 +1706,12 @@ changes:
thread. If `false`, only one test runs at a time.
If unspecified, subtests inherit this value from their parent.
**Default:** `false`.
* `expectFailure` {boolean|string|Object} If truthy, the test is expected to
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing: that you can specify a specific error directly, without a wrapping {match: ...} object. And probably should reference the specific types with a link to assert.throws as I suggested above.

If it seems redundant to do that in two places, the I guess provide all the details in this section and link to here from Expecting tests to fail.

fail. If a string is provided, that string is displayed in the test results
as the reason why the test is expected to fail. If an object is provided,
it can contain a `label` property (string) for the failure reason and a
`match` property (RegExp, Function, Object, or Error) to validate the error
thrown. **Default:** `false`.
* `only` {boolean} If truthy, and the test context is configured to run
`only` tests, then this test will be run. Otherwise, the test is skipped.
**Default:** `false`.
Expand Down
2 changes: 1 addition & 1 deletion lib/internal/test_runner/reporter/tap.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function reportTest(nesting, testNumber, status, name, skip, todo, expectFailure
} else if (todo !== undefined) {
line += ` # TODO${typeof todo === 'string' && todo.length ? ` ${tapEscape(todo)}` : ''}`;
} else if (expectFailure !== undefined) {
line += ' # EXPECTED FAILURE';
line += ` # EXPECTED FAILURE${typeof expectFailure === 'string' && expectFailure.length ? ` ${tapEscape(expectFailure)}` : ''}`;
}

line += '\n';
Expand Down
81 changes: 77 additions & 4 deletions lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const {
MathMax,
Number,
NumberPrototypeToFixed,
ObjectKeys,
ObjectSeal,
Promise,
PromisePrototypeThen,
Expand Down Expand Up @@ -40,6 +41,7 @@ const {
AbortError,
codes: {
ERR_INVALID_ARG_TYPE,
ERR_INVALID_ARG_VALUE,
ERR_TEST_FAILURE,
},
} = require('internal/errors');
Expand All @@ -56,7 +58,8 @@ const {
once: runOnce,
setOwnProperty,
} = require('internal/util');
const { isPromise } = require('internal/util/types');
const assert = require('assert');
const { isPromise, isRegExp } = require('internal/util/types');
const {
validateAbortSignal,
validateFunction,
Expand Down Expand Up @@ -487,6 +490,39 @@ class SuiteContext {
}
}

function parseExpectFailure(expectFailure) {
if (expectFailure === undefined || expectFailure === false) {
return false;
}

if (typeof expectFailure === 'string') {
return { __proto__: null, label: expectFailure, match: undefined };
}

if (typeof expectFailure === 'function' || isRegExp(expectFailure)) {
return { __proto__: null, label: undefined, match: expectFailure };
}

if (typeof expectFailure !== 'object') {
return { __proto__: null, label: undefined, match: undefined };
}

const keys = ObjectKeys(expectFailure);
if (keys.length === 0) {
throw new ERR_INVALID_ARG_VALUE('options.expectFailure', expectFailure, 'must not be an empty object');
}

if (keys.every((k) => k === 'match' || k === 'label')) {
return {
__proto__: null,
label: expectFailure.label,
match: expectFailure.match,
};
}

return { __proto__: null, label: undefined, match: expectFailure };
}

class Test extends AsyncResource {
reportedType = 'test';
abortController;
Expand Down Expand Up @@ -636,7 +672,7 @@ class Test extends AsyncResource {
this.plan = null;
this.expectedAssertions = plan;
this.cancelled = false;
this.expectFailure = expectFailure !== undefined && expectFailure !== false;
this.expectFailure = parseExpectFailure(expectFailure);
this.skipped = skip !== undefined && skip !== false;
this.isTodo = (todo !== undefined && todo !== false) || this.parent?.isTodo;
this.startTime = null;
Expand Down Expand Up @@ -948,7 +984,27 @@ class Test extends AsyncResource {
return;
}

if (this.expectFailure === true) {
if (this.expectFailure) {
if (typeof this.expectFailure === 'object' &&
this.expectFailure.match !== undefined) {
const { match: validation } = this.expectFailure;
try {
const { throws } = assert;
const errorToCheck = (err?.code === 'ERR_TEST_FAILURE' &&
err?.failureType === kTestCodeFailure &&
err.cause) ?
err.cause : err;
throws(() => { throw errorToCheck; }, validation);
} catch (e) {
this.passed = false;
this.error = new ERR_TEST_FAILURE(
'The test failed, but the error did not match the expected validation',
kTestCodeFailure,
);
this.error.cause = e;
return;
}
}
this.passed = true;
} else {
this.passed = false;
Expand All @@ -970,6 +1026,20 @@ class Test extends AsyncResource {
return;
}

if (this.skipped || this.isTodo) {
this.passed = true;
return;
}

if (this.expectFailure) {
this.passed = false;
this.error = new ERR_TEST_FAILURE(
'Test passed but was expected to fail',
kTestCodeFailure,
);
return;
}

this.passed = true;
}

Expand Down Expand Up @@ -1359,7 +1429,10 @@ class Test extends AsyncResource {
} else if (this.isTodo) {
directive = this.reporter.getTodo(this.message);
} else if (this.expectFailure) {
directive = this.reporter.getXFail(this.expectFailure); // TODO(@JakobJingleheimer): support specifying failure
const message = typeof this.expectFailure === 'object' ?
this.expectFailure.label :
this.expectFailure;
directive = this.reporter.getXFail(message);
}

if (this.reportedType) {
Expand Down
4 changes: 2 additions & 2 deletions test/parallel/test-runner-expect-error-but-pass.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ if (!process.env.NODE_TEST_CONTEXT) {
stream.on('test:fail', common.mustCall((event) => {
assert.strictEqual(event.expectFailure, true);
assert.strictEqual(event.details.error.code, 'ERR_TEST_FAILURE');
assert.strictEqual(event.details.error.failureType, 'expectedFailure');
assert.strictEqual(event.details.error.cause, 'test was expected to fail but passed');
assert.strictEqual(event.details.error.failureType, 'testCodeFailure');
assert.strictEqual(event.details.error.cause, 'Test passed but was expected to fail');
}, 1));
} else {
test('passing test', { expectFailure: true }, () => {});
Expand Down
154 changes: 154 additions & 0 deletions test/parallel/test-runner-xfail.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
'use strict';
const common = require('../common');
const { test } = require('node:test');
const { spawn } = require('child_process');
const assert = require('node:assert');

if (process.env.CHILD_PROCESS === 'true') {
test('fail with message string', { expectFailure: 'reason string' }, () => {
assert.fail('boom');
});

test('fail with label object', { expectFailure: { label: 'reason object' } }, () => {
assert.fail('boom');
});

test('fail with match regex', { expectFailure: { match: /boom/ } }, () => {
assert.fail('boom');
});

test('fail with match object', { expectFailure: { match: { message: 'boom' } } }, () => {
assert.fail('boom');
});

test('fail with match class', { expectFailure: { match: assert.AssertionError } }, () => {
assert.fail('boom');
});

test('fail with match error (wrong error)', { expectFailure: { match: /bang/ } }, () => {
assert.fail('boom'); // Should result in real failure because error doesn't match
});

test('unexpected pass', { expectFailure: true }, () => {
// Should result in real failure because it didn't fail
});

test('fail with empty string', { expectFailure: '' }, () => {
assert.fail('boom');
});

// 1. Matcher: RegExp
test('fails with regex matcher', { expectFailure: /expected error/ }, () => {
throw new Error('this is the expected error');
});

test('fails with regex matcher (mismatch)', { expectFailure: /expected error/ }, () => {
throw new Error('wrong error'); // Should fail the test
});

// 2. Matcher: Class
test('fails with class matcher', { expectFailure: RangeError }, () => {
throw new RangeError('out of bounds');
});

test('fails with class matcher (mismatch)', { expectFailure: RangeError }, () => {
throw new TypeError('wrong type'); // Should fail the test
});

// 3. Matcher: Object (Properties)
test('fails with object matcher', { expectFailure: { code: 'ERR_TEST' } }, () => {
const err = new Error('boom');
err.code = 'ERR_TEST';
throw err;
});

test('fails with object matcher (mismatch)', { expectFailure: { code: 'ERR_TEST' } }, () => {
const err = new Error('boom');
err.code = 'ERR_WRONG';
throw err; // Should fail
});

// 4. Configuration Object: Reason + Validation
test('fails with config object (label + match)', {
expectFailure: {
label: 'Bug #124',
match: /boom/
}
}, () => {
throw new Error('boom');
});

test('fails with config object (label only)', {
expectFailure: { label: 'Bug #125' }
}, () => {
throw new Error('boom');
});

test('fails with config object (match only)', {
expectFailure: { match: /boom/ }
}, () => {
throw new Error('boom');
});

// 5. Edge Case: Empty Object (Should throw ERR_INVALID_ARG_VALUE during creation)
try {
test('invalid empty object', { expectFailure: {} }, () => {});
} catch (e) {
console.log(`CAUGHT_INVALID_ARG: ${e.code}`);
}

// 6. Primitives and Truthiness
test('fails with boolean true', { expectFailure: true }, () => {
throw new Error('any error');
});

// 7. Unexpected Pass (Enhanced)
test('unexpected pass (reason string)', { expectFailure: 'should fail' }, () => {
// Pass
});

test('unexpected pass (matcher)', { expectFailure: /boom/ }, () => {
// Pass
});

} else {
const child = spawn(process.execPath, ['--test-reporter', 'tap', __filename], {
env: { ...process.env, CHILD_PROCESS: 'true' },
stdio: 'pipe',
});

let stdout = '';
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => { stdout += chunk; });

child.on('close', common.mustCall((code) => {
// We expect exit code 1 because 'unexpected pass' and 'wrong error' should fail the test run
assert.strictEqual(code, 1);

assert.match(stdout, /ok \d+ - fail with message string # EXPECTED FAILURE reason string/);
assert.match(stdout, /ok \d+ - fail with label object # EXPECTED FAILURE reason object/);
assert.match(stdout, /ok \d+ - fail with match regex # EXPECTED FAILURE/);
assert.match(stdout, /ok \d+ - fail with match object # EXPECTED FAILURE/);
assert.match(stdout, /ok \d+ - fail with match class # EXPECTED FAILURE/);
assert.match(stdout, /not ok \d+ - fail with match error \(wrong error\) # EXPECTED FAILURE/);
assert.match(stdout, /not ok \d+ - unexpected pass # EXPECTED FAILURE/);
assert.match(stdout, /ok \d+ - fail with empty string # EXPECTED FAILURE/);

// New tests verification
assert.match(stdout, /ok \d+ - fails with regex matcher # EXPECTED FAILURE/);
assert.match(stdout, /not ok \d+ - fails with regex matcher \(mismatch\) # EXPECTED FAILURE/);
assert.match(stdout, /ok \d+ - fails with class matcher # EXPECTED FAILURE/);
assert.match(stdout, /not ok \d+ - fails with class matcher \(mismatch\) # EXPECTED FAILURE/);
assert.match(stdout, /ok \d+ - fails with object matcher # EXPECTED FAILURE/);
assert.match(stdout, /not ok \d+ - fails with object matcher \(mismatch\) # EXPECTED FAILURE/);
assert.match(stdout, /ok \d+ - fails with config object \(label \+ match\) # EXPECTED FAILURE Bug \\#124/);
assert.match(stdout, /ok \d+ - fails with config object \(label only\) # EXPECTED FAILURE Bug \\#125/);
assert.match(stdout, /ok \d+ - fails with config object \(match only\) # EXPECTED FAILURE/);
assert.match(stdout, /ok \d+ - fails with boolean true # EXPECTED FAILURE/);
assert.match(stdout, /not ok \d+ - unexpected pass \(reason string\) # EXPECTED FAILURE should fail/);
assert.match(stdout, /not ok \d+ - unexpected pass \(matcher\) # EXPECTED FAILURE/);

// Empty object error
assert.match(stdout, /CAUGHT_INVALID_ARG: ERR_INVALID_ARG_VALUE/);
}));
}
Loading