Skip to content
Merged
1 change: 1 addition & 0 deletions lib/cli/run-option-metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const TYPES = (exports.types = {
"diff",
"dry-run",
"exit",
"fail-hook-affected-tests",
"pass-on-failing-test-suite",
"fail-zero",
"forbid-only",
Expand Down
5 changes: 5 additions & 0 deletions lib/cli/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ exports.builder = (yargs) =>
description: "Not fail test run if tests were failed",
group: GROUPS.RULES,
},
"fail-hook-affected-tests": {
description:
"Report tests as failed when affected by hook failures (before/beforeEach)",
group: GROUPS.RULES,
},
"fail-zero": {
description: "Fail test run if no test(s) encountered",
group: GROUPS.RULES,
Expand Down
15 changes: 15 additions & 0 deletions lib/mocha.js
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,20 @@ Mocha.prototype.dryRun = function (dryRun) {
return this;
};

/**
* Reports tests as failed when they are skipped due to a hook failure.
*
* @public
* @see [CLI option](../#-fail-hook-affected-tests)
* @param {boolean} [failHookAffectedTests=true] - Whether to fail tests affected by hook failures.
* @return {Mocha} this
* @chainable
*/
Mocha.prototype.failHookAffectedTests = function (failHookAffectedTests) {
this.options.failHookAffectedTests = failHookAffectedTests !== false;
return this;
};

/**
* Fails test run if no tests encountered with exit-code 1.
*
Expand Down Expand Up @@ -966,6 +980,7 @@ Mocha.prototype.run = function (fn) {
cleanReferencesAfterRun: this._cleanReferencesAfterRun,
delay: options.delay,
dryRun: options.dryRun,
failHookAffectedTests: options.failHookAffectedTests,
failZero: options.failZero,
});
createStatsCollector(runner);
Expand Down
117 changes: 116 additions & 1 deletion lib/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ class Runner extends EventEmitter {
* @param {boolean} [opts.delay] - Whether to delay execution of root suite until ready.
* @param {boolean} [opts.dryRun] - Whether to report tests without running them.
* @param {boolean} [opts.failZero] - Whether to fail test run if zero tests encountered.
* @param {boolean} [opts.failHookAffectedTests] - Whether to fail all tests affected by hook failures.
*/
constructor(suite, opts = {}) {
super();
Expand Down Expand Up @@ -441,6 +442,71 @@ Runner.prototype.checkGlobals = function (test) {
}
};

/**
* Create an error object for a test that was skipped due to a hook failure.
* Handles cases where the thrown value is not an Error object.
*
* @private
* @param {string} hookTitle - The title of the failed hook
* @param {*} hookError - The error from the failed hook (may not be an Error object)
* @returns {Error} The error object for the skipped test
*/
function createHookSkipError(hookTitle, hookError) {
// Handle falsy or undefined exceptions
if (!hookError) {
hookError = createInvalidExceptionError(
'Hook "' + hookTitle + '" failed with falsy or undefined exception.',
hookError
);
}
// Convert non-Error objects to Error
else if (!isError(hookError)) {
hookError = thrown2Error(hookError);
}

var errorMessage =
'Test skipped due to failure in "' + hookTitle + '": ' + hookError.message;
var testError = new Error(errorMessage);
testError.stack = hookError.stack;
return testError;
}

/**
* Fail all tests that are affected by a hook failure.
* This is used when the `failHookAffectedTests` option is enabled.
*
* @private
* @param {Suite} suite - The suite containing the affected tests
* @param {Error} hookError - The error from the failed hook
* @param {string} hookTitle - The title of the failed hook
*/
Runner.prototype.failAffectedTests = function (suite, hookError, hookTitle) {
if (!this._opts.failHookAffectedTests) {
return;
}

var self = this;
var testError = createHookSkipError(hookTitle, hookError);

// Recursively fail all tests in this suite and its child suites
function failTestsInSuite(s) {
s.tests.forEach(function (test) {
// Only fail tests that haven't been executed yet
if (!test.state) {
test.state = STATE_FAILED;
self.failures++;
self.emit(constants.EVENT_TEST_BEGIN, test);
self.emit(constants.EVENT_TEST_FAIL, test, testError);
self.emit(constants.EVENT_TEST_END, test);
}
});

s.suites.forEach(failTestsInSuite);
}

failTestsInSuite(suite);
};

/**
* Fail the given `test`.
*
Expand Down Expand Up @@ -583,6 +649,28 @@ Runner.prototype.hook = function (name, fn) {
}
} else if (err) {
self.fail(hook, err);
// If failHookAffectedTests is enabled, mark affected tests as failed
if (self._opts.failHookAffectedTests) {
if (name === HOOK_TYPE_BEFORE_ALL) {
self.failAffectedTests(self.suite, err, hook.title);
} else if (name === HOOK_TYPE_BEFORE_EACH) {
// Fail the current test
if (self.test && !self.test.state) {
var testError = createHookSkipError(hook.title, err);

self.test.state = STATE_FAILED;
self.failures++;
self.emit(constants.EVENT_TEST_BEGIN, self.test);
self.emit(constants.EVENT_TEST_FAIL, self.test, testError);
self.emit(constants.EVENT_TEST_END, self.test);
}
// Store the hook error info for remaining tests
self._failedBeforeEachHook = {
error: err,
title: hook.title,
};
}
}
// stop executing hooks, notify callee of hook err
return fn(err);
}
Expand Down Expand Up @@ -734,10 +822,37 @@ Runner.prototype.runTests = function (suite, fn) {
var tests = suite.tests.slice();
var test;

function hookErr(_, errSuite, after) {
function hookErr(err, errSuite, after) {
// before/after Each hook for errSuite failed:
var orig = self.suite;

// If failHookAffectedTests is enabled and this is a beforeEach failure,
// mark remaining tests as failed
if (
self._opts.failHookAffectedTests &&
!after &&
self._failedBeforeEachHook
) {
// Fail all remaining tests in the suite
var remainingTests = tests.slice();
remainingTests.forEach(function (t) {
if (!t.state) {
var testError = createHookSkipError(
self._failedBeforeEachHook.title,
self._failedBeforeEachHook.error
);

t.state = STATE_FAILED;
self.failures++;
self.emit(constants.EVENT_TEST_BEGIN, t);
self.emit(constants.EVENT_TEST_FAIL, t, testError);
self.emit(constants.EVENT_TEST_END, t);
}
});
// Clear the stored hook info
delete self._failedBeforeEachHook;
}

// for failed 'after each' hook start from errSuite parent,
// otherwise start from errSuite itself
self.suite = after ? errSuite.parent : errSuite;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict';

describe('fails `beforeEach` hook', function () {
beforeEach(function () {
throw new Error('error in `beforeEach` hook');
});
it('test 1', function () {
// This should be reported as failed due to beforeEach hook failure
});
it('test 2', function () {
// This should be reported as failed due to beforeEach hook failure
});
});
describe('passes normally', function () {
it('test 3', function () {
// This should pass normally
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use strict';

describe('throws non-Error in `beforeEach` hook', function () {
describe('throws null', function () {
beforeEach(function () {
throw null;
});
it('test 1', function () {
// Should be reported as failed due to beforeEach hook failure
});
});

describe('throws undefined', function () {
beforeEach(function () {
throw undefined;
});
it('test 2', function () {
// Should be reported as failed due to beforeEach hook failure
});
});

describe('throws string', function () {
beforeEach(function () {
throw 'string error';
});
it('test 3', function () {
// Should be reported as failed due to beforeEach hook failure
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict';

describe('fails `before` hook', function () {
before(function () {
throw new Error('error in `before` hook');
});
it('test 1', function () {
// This should be reported as failed due to before hook failure
});
it('test 2', function () {
// This should be reported as failed due to before hook failure
});
});
describe('passes normally', function () {
it('test 3', function () {
// This should pass normally
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use strict';

describe('throws non-Error in `before` hook', function () {
describe('throws null', function () {
before(function () {
throw null;
});
it('test 1', function () {
// Should be reported as failed due to before hook failure
});
});

describe('throws undefined', function () {
before(function () {
throw undefined;
});
it('test 2', function () {
// Should be reported as failed due to before hook failure
});
});

describe('throws string', function () {
before(function () {
throw 'string error';
});
it('test 3', function () {
// Should be reported as failed due to before hook failure
});
});

describe('throws number', function () {
before(function () {
throw 42;
});
it('test 4', function () {
// Should be reported as failed due to before hook failure
});
});
});
87 changes: 87 additions & 0 deletions test/integration/hook-err.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,93 @@ describe("hook error handling", function () {
});
});

describe("--fail-hook-affected-tests", function () {
describe("error in `before` hook", function () {
it("should fail all affected tests", function (done) {
runMochaJSON(
"hooks/before-hook-error-with-fail-affected",
["--fail-hook-affected-tests"],
(err, res) => {
if (err) {
return done(err);
}
expect(res, "to have failed")
.and("to have failed test count", 3)
.and("to have failed test", '"before all" hook for "test 1"')
.and("to have failed test", "test 1")
.and("to have failed test", "test 2")
.and("to have passed test count", 1)
.and("to have passed test", "test 3");
done();
},
);
});
});

describe("error in `beforeEach` hook", function () {
it("should fail all affected tests", function (done) {
runMochaJSON(
"hooks/before-each-hook-error-with-fail-affected",
["--fail-hook-affected-tests"],
(err, res) => {
if (err) {
return done(err);
}
expect(res, "to have failed")
.and("to have failed test count", 3)
.and("to have failed test", '"before each" hook for "test 1"')
.and("to have failed test", "test 1")
.and("to have failed test", "test 2")
.and("to have passed test count", 1)
.and("to have passed test", "test 3");
done();
},
);
});
});

describe("non-Error thrown in `before` hook", function () {
it("should handle null, undefined, and other non-Error values", function (done) {
runMochaJSON(
"hooks/before-hook-throw-non-error",
["--fail-hook-affected-tests"],
(err, res) => {
if (err) {
return done(err);
}
expect(res, "to have failed")
.and("to have failed test count", 8) // 4 hooks + 4 affected tests
.and("to have failed test", "test 1")
.and("to have failed test", "test 2")
.and("to have failed test", "test 3")
.and("to have failed test", "test 4");
done();
},
);
});
});

describe("non-Error thrown in `beforeEach` hook", function () {
it("should handle null, undefined, and other non-Error values", function (done) {
runMochaJSON(
"hooks/before-each-hook-throw-non-error",
["--fail-hook-affected-tests"],
(err, res) => {
if (err) {
return done(err);
}
expect(res, "to have failed")
.and("to have failed test count", 6) // 3 hooks + 3 affected tests
.and("to have failed test", "test 1")
.and("to have failed test", "test 2")
.and("to have failed test", "test 3");
done();
},
);
});
});
});

function run(fnPath, outputFilter) {
return (done) =>
runMocha(fnPath, ["--reporter", "dot"], (err, res) => {
Expand Down