mongo/jstests/libs/mochalite.js

577 lines
14 KiB
JavaScript

/**
* Simple test framework for running tests in JS files.
*
* This is a simplified version of Mocha, designed to work with the Mongo shell.
* It provides a way to define test suites and individual tests, and to run them with
* a simple reporting format.
*
* Example usage:
*
* describe("My Test Suite", function() {
* it("should do something", function() {
* // Test code here
* });
* });
*
*
* Leverage the before/beforeEach/afterEach/after hooks to set up and tear down test environments.
*
* Example:
*
* before(function() {
* this.fixtureDB = startupNewDB();
* });
* beforeEach(function() {
* this.fixtureDB.seed();
* });
* afterEach(function() {
* this.fixtureDB.clear();
* });
* after(function() {
* this.fixtureDB.shutdown();
* });
* it("should do something", function() {
* this.fixtureDB.insert({ name: "test" });
* assert.eq(this.fixtureDB.find({ name: "test" }).count(), 1);
* });
*
* Content in any of the above (excluding `describe`) can be an async function (or otherwise return
* a Promise) and the framework will await its resolution.
*
* Example:
*
* before(async function() {
* this.fixtureDB = await startupNewDBAsync();
* });
* it("should do something", async function() {
* await this.fixtureDB.insertAsync({ name: "test" });
* await res = this.fixtureDB.find({ name: "test" }).count();
* assert.eq(res, 1);
* });
* after(function() {
* return this.fixtureDB.shutdownAsync(); // returns a promise
* });
*/
// use grep from global context if available, else match everything
let GREP = globalThis._mocha_grep ?? ".*";
try {
GREP = new RegExp(GREP);
} catch (e) {
throw new Error(`Failed to create regex from '${GREP}': ${e.message}`);
}
const redText = (msg) => `\x1b[31m${msg}\x1b[0m`;
const stdout = (msg) => jsTest.log.info(msg);
const stderr = (msg) => jsTest.log.error(redText(msg));
/**
* Reporter class for logging test results.
* It logs passing and failing tests without throwing exceptions until the final "report" call.
*/
class Reporter {
#passed;
#failed;
constructor() {
this.reset();
}
/**
* Reset the reporter state for a new test run.
*/
reset() {
this.#passed = [];
this.#failed = [];
}
/**
* Log a passing test/message
* @param {string} headline
*/
pass(headline) {
stdout(`${headline}`);
this.#passed.push(headline);
}
/**
* Log a failing test/message
* @param {string} headline
* @param {Error} error
*/
fail(headline, error) {
stderr(`${headline}`);
this.#failed.push({headline, error});
}
/**
* Report the final test results.
*
* Prints a summary of passing and failing tests.
* If there are any failures, it throws an error to signal the shell.
* @throws {Error} if there are any failing tests
*/
report() {
let msg = ["Test Report Summary:", ` ${this.#passed.length} passing`];
if (this.#failed.length > 0) {
msg.push(redText(` ${this.#failed.length} failing`));
msg.push("Failures and stacks are reprinted below.");
}
stdout(msg.join("\n"));
if (this.#failed.length > 0) {
this.#failed.forEach(({headline, error}) => {
stderr(`${headline}\n${error.message}\n${error.stack}`);
});
// finally throw to signal failure to the shell
throw new Error(`${this.#failed.length} failing tests detected`);
}
}
}
// Context to be passed into each test and hook
class Context {}
class Scope {
constructor(title = [], fn = null, modifiers = {only: false}) {
this.title = title;
this.fn = fn;
this.only = modifiers.only;
this.ctx = new Context();
this.parent = null;
this.children = [];
}
reset() {
this.ctx = new Context();
this.children = [];
}
/**
* Add a child scope to this scope.
* @param {Scope} scope
*/
addChild(scope) {
scope.ctx = this.ctx;
scope.parent = this;
this.children.push(scope);
}
/**
* Run all child scopes, async but serially.
* @async
*/
async run() {
let children = this.children;
// look for "only" marked scopes
children = children.filter((child) => child.containsOnly());
if (children.length > 0) {
// prioritize direct it.only scopes
let directTestOnly = children.filter((child) => child instanceof TestScope);
if (directTestOnly.length > 0) {
// this breaks any ties with sibling describe.only scopes
children = directTestOnly;
}
} else {
// Either no "only" was used, or came from ancestors: treat these all the same
children = this.children;
}
for (const child of children) {
let bail = await child.run();
if (bail) {
// If any child scope bailed out, we stop running further tests
return;
}
}
}
}
// Scope to group tests and hooks together with relevant context
// This is a Composite pattern where DescribeScope can contain other DescribeScopes or TestScopes
class DescribeScope extends Scope {
constructor(title = [], fn = null, modifiers = {only: false}) {
super(title, fn, modifiers);
// hooks
this.before = [];
this.beforeEach = [];
this.afterEach = [];
this.after = [];
}
/**
* Discover and gather nested content (nested hooks, describe, and it calls)
* @param {*} scope Current scope to discover from
* @param {string} title Title of this scope
* @param {function} fn Function to execute in this scope
*/
discover(scope) {
this.ctx = scope.ctx;
this.beforeEach = [...scope.beforeEach]; // queue
// change shared context and invoke the content
currScope = this;
this.fn.call(scope.ctx);
this.afterEach = [...this.afterEach, ...scope.afterEach]; // stack of queues
}
/**
* Add a hook to this scope.
* @param {string} hookname
* @param {Function} fn
*/
addHook(hookname, fn) {
assertNoFunctionArgs(fn);
this[hookname].push(fn);
}
/**
* Check if this scope or any nested scopes contain tests.
* @returns {boolean}
*/
containsTests() {
return this.children.some((child) => child.containsTests());
}
containsOnly() {
return this.only || this.children.some((child) => child.containsOnly());
}
/**
* Run all hooks of the given type for this scope.
* @param {string} hookname
* @async
*/
async runHook(hookname) {
const ctx = this.ctx;
for (const fn of this[hookname]) {
await fn.call(ctx);
}
}
/**
* Run the before-content-after workflow
* @async
*/
async run() {
if (!this.containsTests()) {
// no tests in this scope or nested scopes, skip running any hooks
return;
}
let bail = false;
try {
await this.runHook("before");
} catch (error) {
bail = true;
reporter.fail(`"before all" hook for "${this.title}"`, error);
}
if (!bail) {
await super.run();
}
try {
await this.runHook("after");
} catch (error) {
reporter.fail(`"after all" hook for "${this.title}"`, error);
// no explicit need to bail here, this is the end of the scope
}
return bail;
}
}
// This a Leaf node in the scope tree, representing a single test's scope.
class TestScope extends Scope {
static #titleSep = " > ";
/**
* Create a new TestScope for a single test.
* @param {Test} test test element
*/
constructor(title, fn, modifiers = {only: false}) {
super(title, fn, modifiers);
}
fullTitle() {
let titleArray = [this.title];
let child = this;
while (child.parent?.parent) {
titleArray.unshift(child.parent.title);
child = child.parent;
}
return titleArray.join(TestScope.#titleSep);
}
/**
* Check if this scope contains tests to run.
* @returns {boolean}
*/
containsTests() {
return GREP.test(this.fullTitle());
}
containsOnly() {
return this.only;
}
/**
* Run a specific hook for this scope.
* @param {string} hookname
* @async
*/
async runHook(hookname) {
// defer to the parent scope
await this.parent.runHook(hookname);
}
/**
* Run the beforeEach-test-afterEach workflow
* @async
*/
async run() {
const title = this.title;
const fullTitle = this.fullTitle();
let bail = false;
try {
await this.runHook("beforeEach");
} catch (error) {
bail = true;
reporter.fail(`"before each" hook for "${title}"`, error);
}
if (!bail) {
try {
await this.fn.call(this.ctx);
reporter.pass(fullTitle);
} catch (error) {
reporter.fail(fullTitle, error);
}
}
try {
await this.runHook("afterEach");
} catch (error) {
bail = true;
reporter.fail(`"after each" hook for "${title}"`, error);
}
return bail;
}
}
let currScope = new DescribeScope();
let reporter = new Reporter();
/**
* Define a test case.
* @param {string} title Test title
* @param {function} fn Test content
*
* @example
* it("should do addition", function() {
* assert.eq(1 + 2, 3);
* });
*
* @example
* it("should do async addition", async function() {
* const result = await asyncAdd(1, 2);
* assert.eq(result, 3);
* });
*/
function it(title, fn) {
addTest(title, fn, {});
}
/**
* Variant of "it" that runs only this test case.
* @param {string} title Test title
* @param {function} fn Test content
*/
it.only = function (title, fn) {
addTest(title, fn, {only: true});
};
/**
* Variant of "it" that skips a test case.
* @param {string} title Test title
* @param {function} fn Test content
*/
it.skip = function (title, fn) {
// no-op
};
function addTest(
title,
fn,
options = {
only: false,
},
) {
assertNoFunctionArgs(fn);
markUsage();
const scope = new TestScope(title, fn, options);
currScope.addChild(scope);
}
/**
* Group tests together in a suite.
* @param {string} title
* @param {function} fn
*
* @example
* describe("My Test Suite", function() {
* it("should do something", function() {
* // Test code here
* });
* });
*/
function describe(title, fn) {
addDescribe(title, fn, {});
}
/**
* Variant of "describe" that runs only this test suite.
*
* @param {*} title
* @param {*} fn
*/
describe.only = function (title, fn) {
addDescribe(title, fn, {only: true});
};
/**
* Variant of "describe" that skips a test suite.
*
* @param {*} title
* @param {*} fn
*/
describe.skip = function (title, fn) {
// no-op
};
function addDescribe(
title,
fn,
options = {
only: false,
},
) {
markUsage();
const scope = new DescribeScope(title, fn, options);
const oldScope = currScope;
scope.discover(currScope);
oldScope.addChild(scope);
currScope = oldScope;
}
/**
* Run a function before all tests in the current scope.
* @param {Function} fn Function to invoke
* @example
* before(function() {
* this.fixtureDB = startupNewDB();
* });
* it("should do something", function() {
* this.fixtureDB.insert({ name: "test" });
* // ...
* });
*/
function before(fn) {
currScope.addHook("before", fn);
}
/**
* Run a function before each test in the current scope.
* @param {Function} fn Function to invoke
* @example
* beforeEach(function() {
* this.fixtureDB = startupNewDB();
* });
* it("should do something", function() {
* this.fixtureDB.insert({ name: "test" });
* // ...
* });
*/
function beforeEach(fn) {
currScope.addHook("beforeEach", fn);
}
/**
* Run a function after each test in the current scope.
* @param {Function} fn Function to invoke
* @example
* beforeEach(function() {
* this.fixtureDB = startupNewDB();
* });
* afterEach(function() {
* this.fixtureDB.shutdown();
* });
* it("should do something", function() {
* this.fixtureDB.insert({ name: "test" });
* // ...
* });
*/
function afterEach(fn) {
currScope.addHook("afterEach", fn);
}
/**
* Run a function after all tests in the current scope.
* @param {Function} fn Function to invoke
* @example
* before(function() {
* this.fixtureDB = startupNewDB();
* });
* after(function() {
* this.fixtureDB.shutdown();
* });
* it("should do something", function() {
* this.fixtureDB.insert({ name: "test" });
* // ...
* });
*/
function after(fn) {
currScope.addHook("after", fn);
}
/**
* Run all defined tests.
* Returns a Promise.
*/
async function runTests() {
const rootScope = currScope;
await currScope.run();
try {
reporter.report();
} finally {
// reset
rootScope.reset();
reporter.reset();
currScope = rootScope;
}
}
function markUsage() {
// sentinel for shell to close
globalThis.__mochalite_closer = runTests;
}
function assertNoFunctionArgs(fn) {
if (fn.length > 0) {
throw new Error(
"Test content should not take parameters. If you intended to write callback-based content, use async functions instead.",
);
}
}
export {describe, it, before, beforeEach, afterEach, after};