summaryrefslogtreecommitdiff
path: root/std/testing/mod.ts
diff options
context:
space:
mode:
Diffstat (limited to 'std/testing/mod.ts')
-rw-r--r--std/testing/mod.ts422
1 files changed, 422 insertions, 0 deletions
diff --git a/std/testing/mod.ts b/std/testing/mod.ts
new file mode 100644
index 000000000..3b6386d5b
--- /dev/null
+++ b/std/testing/mod.ts
@@ -0,0 +1,422 @@
+// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license.
+
+import {
+ bgRed,
+ white,
+ bold,
+ green,
+ red,
+ gray,
+ yellow,
+ italic
+} from "../fmt/colors.ts";
+export type TestFunction = () => void | Promise<void>;
+
+export interface TestDefinition {
+ fn: TestFunction;
+ name: string;
+}
+
+// Replacement of the global `console` function to be in silent mode
+const noop = function(): void {};
+
+// Clear the current line of the console.
+// see: http://ascii-table.com/ansi-escape-sequences-vt-100.php
+const CLEAR_LINE = "\x1b[2K\r";
+
+// Save Object of the global `console` in case of silent mode
+type Console = typeof window.console;
+// ref https://console.spec.whatwg.org/#console-namespace
+// For historical web-compatibility reasons, the namespace object for
+// console must have as its [[Prototype]] an empty object, created as if
+// by ObjectCreate(%ObjectPrototype%), instead of %ObjectPrototype%.
+const disabledConsole = Object.create({}) as Console;
+Object.assign(disabledConsole, {
+ log: noop,
+ debug: noop,
+ info: noop,
+ dir: noop,
+ warn: noop,
+ error: noop,
+ assert: noop,
+ count: noop,
+ countReset: noop,
+ table: noop,
+ time: noop,
+ timeLog: noop,
+ timeEnd: noop,
+ group: noop,
+ groupCollapsed: noop,
+ groupEnd: noop,
+ clear: noop
+});
+
+const originalConsole = window.console;
+
+function enableConsole(): void {
+ window.console = originalConsole;
+}
+
+function disableConsole(): void {
+ window.console = disabledConsole;
+}
+
+const encoder = new TextEncoder();
+function print(txt: string, newline = true): void {
+ if (newline) {
+ txt += "\n";
+ }
+ Deno.stdout.writeSync(encoder.encode(`${txt}`));
+}
+
+declare global {
+ interface Window {
+ /**
+ * A global property to collect all registered test cases.
+ *
+ * It is required because user's code can import multiple versions
+ * of `testing` module.
+ *
+ * If test cases aren't registered in a globally shared
+ * object, then imports from different versions would register test cases
+ * to registry from it's respective version of `testing` module.
+ */
+ __DENO_TEST_REGISTRY: TestDefinition[];
+ }
+}
+
+let candidates: TestDefinition[] = [];
+if (window["__DENO_TEST_REGISTRY"]) {
+ candidates = window.__DENO_TEST_REGISTRY as TestDefinition[];
+} else {
+ window["__DENO_TEST_REGISTRY"] = candidates;
+}
+let filterRegExp: RegExp | null;
+let filtered = 0;
+
+// Must be called before any test() that needs to be filtered.
+export function setFilter(s: string): void {
+ filterRegExp = new RegExp(s, "i");
+}
+
+function filter(name: string): boolean {
+ if (filterRegExp) {
+ return filterRegExp.test(name);
+ } else {
+ return true;
+ }
+}
+
+export function test(t: TestDefinition): void;
+export function test(fn: TestFunction): void;
+export function test(name: string, fn: TestFunction): void;
+export function test(
+ t: string | TestDefinition | TestFunction,
+ fn?: TestFunction
+): void {
+ let name: string;
+
+ if (typeof t === "string") {
+ if (!fn) {
+ throw new Error("Missing test function");
+ }
+ name = t;
+ } else {
+ fn = typeof t === "function" ? t : t.fn;
+ name = t.name;
+ }
+
+ if (!name) {
+ throw new Error("Test function may not be anonymous");
+ }
+ if (filter(name)) {
+ candidates.push({ fn, name });
+ } else {
+ filtered++;
+ }
+}
+
+const RED_FAILED = red("FAILED");
+const GREEN_OK = green("OK");
+const RED_BG_FAIL = bgRed(" FAIL ");
+
+interface TestStats {
+ filtered: number;
+ ignored: number;
+ measured: number;
+ passed: number;
+ failed: number;
+}
+
+interface TestResult {
+ timeElapsed?: number;
+ name: string;
+ error?: Error;
+ ok: boolean;
+ printed: boolean;
+}
+
+interface TestResults {
+ keys: Map<string, number>;
+ cases: Map<number, TestResult>;
+}
+
+function createTestResults(tests: TestDefinition[]): TestResults {
+ return tests.reduce(
+ (acc: TestResults, { name }: TestDefinition, i: number): TestResults => {
+ acc.keys.set(name, i);
+ acc.cases.set(i, { name, printed: false, ok: false, error: undefined });
+ return acc;
+ },
+ { cases: new Map(), keys: new Map() }
+ );
+}
+
+function formatTestTime(time = 0): string {
+ return `${time.toFixed(2)}ms`;
+}
+
+function promptTestTime(time = 0, displayWarning = false): string {
+ // if time > 5s we display a warning
+ // only for test time, not the full runtime
+ if (displayWarning && time >= 5000) {
+ return bgRed(white(bold(`(${formatTestTime(time)})`)));
+ } else {
+ return gray(italic(`(${formatTestTime(time)})`));
+ }
+}
+
+function report(result: TestResult): void {
+ if (result.ok) {
+ print(
+ `${GREEN_OK} ${result.name} ${promptTestTime(
+ result.timeElapsed,
+ true
+ )}`
+ );
+ } else if (result.error) {
+ print(`${RED_FAILED} ${result.name}\n${result.error.stack}`);
+ } else {
+ print(`test ${result.name} ... unresolved`);
+ }
+ result.printed = true;
+}
+
+function printFailedSummary(results: TestResults): void {
+ results.cases.forEach((v): void => {
+ if (!v.ok) {
+ console.error(`${RED_BG_FAIL} ${red(v.name)}`);
+ console.error(v.error);
+ }
+ });
+}
+
+function printResults(
+ stats: TestStats,
+ results: TestResults,
+ flush: boolean,
+ exitOnFail: boolean,
+ timeElapsed: number
+): void {
+ if (flush) {
+ for (const result of results.cases.values()) {
+ if (!result.printed) {
+ report(result);
+ if (result.error && exitOnFail) {
+ break;
+ }
+ }
+ }
+ }
+ // Attempting to match the output of Rust's test runner.
+ print(
+ `\ntest result: ${stats.failed ? RED_BG_FAIL : GREEN_OK} ` +
+ `${stats.passed} passed; ${stats.failed} failed; ` +
+ `${stats.ignored} ignored; ${stats.measured} measured; ` +
+ `${stats.filtered} filtered out ` +
+ `${promptTestTime(timeElapsed)}\n`
+ );
+}
+
+function previousPrinted(name: string, results: TestResults): boolean {
+ const curIndex: number = results.keys.get(name)!;
+ if (curIndex === 0) {
+ return true;
+ }
+ return results.cases.get(curIndex - 1)!.printed;
+}
+
+async function createTestCase(
+ stats: TestStats,
+ results: TestResults,
+ exitOnFail: boolean,
+ { fn, name }: TestDefinition
+): Promise<void> {
+ const result: TestResult = results.cases.get(results.keys.get(name)!)!;
+ try {
+ const start = performance.now();
+ await fn();
+ const end = performance.now();
+ stats.passed++;
+ result.ok = true;
+ result.timeElapsed = end - start;
+ } catch (err) {
+ stats.failed++;
+ result.error = err;
+ if (exitOnFail) {
+ throw err;
+ }
+ }
+ if (previousPrinted(name, results)) {
+ report(result);
+ }
+}
+
+function initTestCases(
+ stats: TestStats,
+ results: TestResults,
+ tests: TestDefinition[],
+ exitOnFail: boolean
+): Array<Promise<void>> {
+ return tests.map(createTestCase.bind(null, stats, results, exitOnFail));
+}
+
+async function runTestsParallel(
+ stats: TestStats,
+ results: TestResults,
+ tests: TestDefinition[],
+ exitOnFail: boolean
+): Promise<void> {
+ try {
+ await Promise.all(initTestCases(stats, results, tests, exitOnFail));
+ } catch (_) {
+ // The error was thrown to stop awaiting all promises if exitOnFail === true
+ // stats.failed has been incremented and the error stored in results
+ }
+}
+
+async function runTestsSerial(
+ stats: TestStats,
+ results: TestResults,
+ tests: TestDefinition[],
+ exitOnFail: boolean,
+ disableLog: boolean
+): Promise<void> {
+ for (const { fn, name } of tests) {
+ // Displaying the currently running test if silent mode
+ if (disableLog) {
+ print(`${yellow("RUNNING")} ${name}`, false);
+ }
+ try {
+ const start = performance.now();
+ await fn();
+ const end = performance.now();
+ if (disableLog) {
+ // Rewriting the current prompt line to erase `running ....`
+ print(CLEAR_LINE, false);
+ }
+ stats.passed++;
+ print(
+ GREEN_OK + " " + name + " " + promptTestTime(end - start, true)
+ );
+ results.cases.forEach((v): void => {
+ if (v.name === name) {
+ v.ok = true;
+ v.printed = true;
+ }
+ });
+ } catch (err) {
+ if (disableLog) {
+ print(CLEAR_LINE, false);
+ }
+ print(`${RED_FAILED} ${name}`);
+ print(err.stack);
+ stats.failed++;
+ results.cases.forEach((v): void => {
+ if (v.name === name) {
+ v.error = err;
+ v.ok = false;
+ v.printed = true;
+ }
+ });
+ if (exitOnFail) {
+ break;
+ }
+ }
+ }
+}
+
+/** Defines options for controlling execution details of a test suite. */
+export interface RunTestsOptions {
+ parallel?: boolean;
+ exitOnFail?: boolean;
+ only?: RegExp;
+ skip?: RegExp;
+ disableLog?: boolean;
+}
+
+/**
+ * Runs specified test cases.
+ * Parallel execution can be enabled via the boolean option; default: serial.
+ */
+// TODO: change return type to `Promise<boolean>` - ie. don't
+// exit but return value
+export async function runTests({
+ parallel = false,
+ exitOnFail = false,
+ only = /[^\s]/,
+ skip = /^\s*$/,
+ disableLog = false
+}: RunTestsOptions = {}): Promise<void> {
+ const tests: TestDefinition[] = candidates.filter(
+ ({ name }): boolean => only.test(name) && !skip.test(name)
+ );
+ const stats: TestStats = {
+ measured: 0,
+ ignored: candidates.length - tests.length,
+ filtered: filtered,
+ passed: 0,
+ failed: 0
+ };
+ const results: TestResults = createTestResults(tests);
+ print(`running ${tests.length} tests`);
+ const start = performance.now();
+ if (Deno.args.includes("--quiet")) {
+ disableLog = true;
+ }
+ if (disableLog) {
+ disableConsole();
+ }
+ if (parallel) {
+ await runTestsParallel(stats, results, tests, exitOnFail);
+ } else {
+ await runTestsSerial(stats, results, tests, exitOnFail, disableLog);
+ }
+ const end = performance.now();
+ if (disableLog) {
+ enableConsole();
+ }
+ printResults(stats, results, parallel, exitOnFail, end - start);
+ if (stats.failed) {
+ // Use setTimeout to avoid the error being ignored due to unhandled
+ // promise rejections being swallowed.
+ setTimeout((): void => {
+ console.error(`There were ${stats.failed} test failures.`);
+ printFailedSummary(results);
+ Deno.exit(1);
+ }, 0);
+ }
+}
+
+/**
+ * Runs specified test cases if the enclosing script is main.
+ * Execution mode is toggleable via opts.parallel, defaults to false.
+ */
+export async function runIfMain(
+ meta: ImportMeta,
+ opts?: RunTestsOptions
+): Promise<void> {
+ if (meta.main) {
+ return runTests(opts);
+ }
+}