Compare commits

...

3 Commits

Author SHA1 Message Date
Bauke 2e844596bd
Version 0.1.0! 2022-12-22 14:00:24 +01:00
Bauke 45d3689304
Add source code. 2022-12-22 14:00:03 +01:00
Bauke 7c0c1e1e9c
Add project configuration files. 2022-12-22 13:52:49 +01:00
12 changed files with 4936 additions and 0 deletions

34
README.md Normal file
View File

@ -0,0 +1,34 @@
# @holllo/test
> **Tiny testing library designed to run anywhere.**
## Example
```ts
import {setup} from "@holllo/test";
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);
});
});
```
```txt
# add
- 1 + 1 passed
- 2 + 2 failed
Failed equals assertion
| Actual: 4
| Expected: 5
```
## License
Distributed under the [AGPL-3.0-or-later](https://spdx.org/licenses/AGPL-3.0-or-later.html) license, see [LICENSE](https://git.bauke.xyz/Holllo/test/src/branch/main/LICENSE) for more information.

14
esbuild.ts Normal file
View File

@ -0,0 +1,14 @@
import {build} from "esbuild";
await build({
bundle: true,
entryPoints: ["source/index.ts"],
format: "esm",
logLevel: "info",
minify: true,
outdir: "build",
platform: "browser",
splitting: false,
target: ["es2022"],
treeShaking: true,
});

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "@holllo/test",
"description": "Tiny testing library designed to run anywhere.",
"license": "AGPL-3.0-or-later",
"version": "0.1.0",
"homepage": "https://git.bauke.xyz/Holllo/test",
"bugs": "https://github.com/Holllo/test/issues",
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "./build/index.js",
"types": "./build/index.d.ts",
"files": [
"build/"
],
"scripts": {
"build": "tsx esbuild.ts && tsc",
"dev": "vite",
"lint": "xo",
"test": "pnpm run build && tsx tests/index.ts && tsx tests/example.ts"
},
"devDependencies": {
"@bauke/eslint-config": "^0.1.2",
"@bauke/prettier-config": "^0.1.2",
"esbuild": "^0.16.10",
"tsx": "^3.12.1",
"typescript": "^4.9.4",
"vite": "^4.0.2",
"xo": "^0.53.1"
},
"prettier": "@bauke/prettier-config",
"xo": {
"extends": "@bauke/eslint-config",
"prettier": true,
"space": true
}
}

4564
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

47
source/group.ts Normal file
View File

@ -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<void>,
): Promise<Group> {
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<void> {
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();
}
}
}

2
source/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./group.js";
export * from "./test.js";

130
source/test.ts Normal file
View File

@ -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<T>(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<void>,
public options?: TestOptions,
) {}
/** Run the test and return its result. */
async run(test: TestContext): Promise<Result> {
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);
}
}

13
tests/example.ts Normal file
View File

@ -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);
});
});

22
tests/index.html Normal file
View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@holllo/test</title>
<style>
body {
background-color: #222;
color: #eee;
}
</style>
</head>
<body>
<script src="index.ts" type="module"></script>
<script src="example.ts" type="module"></script>
</body>
</html>

49
tests/index.ts Normal file
View File

@ -0,0 +1,49 @@
import {setup} from "../source/index.js";
async function add(a: number, b: number): Promise<number> {
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, Math.random() * 1000);
});
return a + b;
}
async function subtract(a: number, b: number): Promise<number> {
await new Promise<void>((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);
});
});

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"esModuleInterop": true,
"module": "ES2022",
"moduleResolution": "Node",
"outDir": "build",
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
},
"include": [
"source"
]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import {defineConfig} from "vite";
const relative = (path: string) => new URL(path, import.meta.url).pathname;
export default defineConfig({
root: relative("tests"),
});