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 @@ -104,6 +104,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 @@ -846,6 +846,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 @@ -974,6 +988,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
119 changes: 118 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,73 @@ Runner.prototype.checkGlobals = function (test) {
}
};

/**
* Create an error object for a test that was skipped due to a hook failure.
*
* @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 exception: ' + hookError,
hookError,
);
}
// Convert non-Error objects to Error
else if (!isError(hookError)) {
hookError = thrown2Error(hookError);
}

var errorMessage =
'Test skipped due to failure in hook "' +
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 +651,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 +824,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
});
});
});
Loading