diff --git a/source/group.ts b/source/group.ts new file mode 100644 index 0000000..8dabe9c --- /dev/null +++ b/source/group.ts @@ -0,0 +1,47 @@ +import {type Result, Test, TestContext} from "./test.js"; + +/** Create a new test group and run it. */ +export async function setup( + name: string, + fn: (group: Group) => Promise, +): Promise { + const group = new Group(name); + await fn(group); + await group.run(); + return group; +} + +/** A collection of tests. */ +export class Group { + public context: TestContext = new TestContext(); + public results: Result[] = []; + public tests: Test[] = []; + + constructor(public name: string) {} + + /** Create a new test case that doesn't get run. */ + skip(name: Test["name"], fn: Test["fn"]): void { + this.tests.push(new Test(name, fn, {skip: true})); + } + + /** Create a new test case. */ + test(name: Test["name"], fn: Test["fn"]): void { + this.tests.push(new Test(name, fn)); + } + + /** Run all the tests from this group and display their results. */ + async run(): Promise { + const results = await Promise.all( + this.tests.map(async (test) => test.run(this.context)), + ); + + console.log( + `# %c${this.name}`, + "font-weight: bold; text-decoration: underline;", + ); + this.results = results; + for (const result of results) { + result.display(); + } + } +} diff --git a/source/index.ts b/source/index.ts new file mode 100644 index 0000000..58eaac1 --- /dev/null +++ b/source/index.ts @@ -0,0 +1,2 @@ +export * from "./group.js"; +export * from "./test.js"; diff --git a/source/test.ts b/source/test.ts new file mode 100644 index 0000000..ddaa5af --- /dev/null +++ b/source/test.ts @@ -0,0 +1,130 @@ +export class AssertionError extends Error { + public readonly actual: string; + public readonly expected: string; + + constructor(message: string, actual: any, expected: any) { + super(message); + + this.actual = JSON.stringify(actual); + this.expected = JSON.stringify(expected); + } +} + +/** Test execution context with assertions. */ +export class TestContext { + /** Assert strict equality with `===`. */ + equals(actual: T, expected: T): void { + if (actual === expected) { + return; + } + + throw new AssertionError("Failed equals assertion", actual, expected); + } +} + +/** Special options for test cases. */ +export type TestOptions = { + skip?: boolean; +}; + +/** A test case. */ +export class Test { + constructor( + public name: string, + public fn: (test: TestContext) => Promise, + public options?: TestOptions, + ) {} + + /** Run the test and return its result. */ + async run(test: TestContext): Promise { + const data: ResultData = { + duration: undefined, + error: undefined, + name: this.name, + status: "unknown", + }; + + const start = performance.now(); + try { + if (this.options?.skip) { + data.status = "skipped"; + } else { + await this.fn(test); + data.duration = performance.now() - start; + data.status = "passed"; + } + } catch (_error: unknown) { + data.duration = performance.now() - start; + if (_error instanceof AssertionError) { + data.error = _error; + } else { + throw _error; + } + + data.status = "failed"; + } + + return new Result(data); + } +} + +/** Data created by a test case. */ +type ResultData = { + duration: number | undefined; + error: AssertionError | undefined; + name: string; + status: "failed" | "passed" | "skipped" | "unknown"; +}; + +/** The result of a test case. */ +export class Result implements ResultData { + public duration: ResultData["duration"]; + public error: ResultData["error"]; + public name: ResultData["name"]; + public status: ResultData["status"]; + + constructor(data: ResultData) { + this.duration = data.duration; + this.error = data.error; + this.name = data.name; + this.status = data.status; + } + + /** Get the color associated with a result status. */ + statusColor(): string { + if (this.status === "failed") { + return "red"; + } + + if (this.status === "passed") { + return "green"; + } + + if (this.status === "skipped") { + return "yellow"; + } + + return "white"; + } + + /** Print the result to the console. */ + display(): void { + const bold = "font-weight: bold;"; + const styles = [bold, `color: ${this.statusColor()}; ${bold}`]; + + let message = `- %c${this.name} %c${this.status}`; + if (this.duration !== undefined && this.duration > 1) { + message += ` %c${this.duration}ms`; + styles.push("color: white;"); + } + + if (this.error !== undefined) { + message += `\n %c${this.error.message}`; + message += `\n | Actual: ${this.error.actual}`; + message += `\n | Expected: ${this.error.expected}`; + styles.push("color: pink;"); + } + + console.log(message, ...styles); + } +} diff --git a/tests/example.ts b/tests/example.ts new file mode 100644 index 0000000..9337d8e --- /dev/null +++ b/tests/example.ts @@ -0,0 +1,13 @@ +import {setup} from "../build/index.js"; + +const add = (a: number, b: number): number => a + b; + +void setup("add", async (group) => { + group.test("1 + 1", async (test) => { + test.equals(add(1, 1), 2); + }); + + group.test("2 + 2", async (test) => { + test.equals(add(2, 2), 5); + }); +}); diff --git a/tests/index.html b/tests/index.html new file mode 100644 index 0000000..9a436ab --- /dev/null +++ b/tests/index.html @@ -0,0 +1,22 @@ + + + + + + + + @holllo/test + + + + + + + + + diff --git a/tests/index.ts b/tests/index.ts new file mode 100644 index 0000000..c930e64 --- /dev/null +++ b/tests/index.ts @@ -0,0 +1,49 @@ +import {setup} from "../source/index.js"; + +async function add(a: number, b: number): Promise { + await new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, Math.random() * 1000); + }); + + return a + b; +} + +async function subtract(a: number, b: number): Promise { + await new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, Math.random() * 1000); + }); + + return a - b; +} + +void setup("add", async (group) => { + group.test("add(1, 1) = 2", async (test) => { + test.equals(await add(1, 1), 2); + }); + + group.skip("add(1, 1) = 3", async (test) => { + test.equals(await add(1, 1), 3); + }); + + group.test("add(1, 1) = 4", async (test) => { + test.equals(await add(1, 1), 4); + }); +}); + +void setup("subtract", async (group) => { + group.test("subtract(1, 1) = 0", async (test) => { + test.equals(await subtract(1, 1), 0); + }); + + group.skip("subtract(1, 1) = 1", async (test) => { + test.equals(await subtract(1, 1), 1); + }); + + group.test("subtract(1, 1) = 2", async (test) => { + test.equals(await subtract(1, 1), 2); + }); +});