mirror of https://github.com/mongodb/mongo
577 lines
14 KiB
JavaScript
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};
|