From f109172cc42d9157b3e8078e2d36e4d39b0840b1 Mon Sep 17 00:00:00 2001 From: "M. George Hansen" Date: Sat, 30 May 2020 11:41:23 -0700 Subject: [PATCH] test: add testing framework --- index.d.ts | 10 -- package.json | 3 +- src/index.ts | 10 ++ test/lib/expect.ts | 212 +++++++++++++++++++++++++++++++++ test/lib/harness.ts | 120 +++++++++++++++++++ test/lib/index.ts | 6 + test/lib/utils.ts | 106 +++++++++++++++++ test/run-tests.ts | 20 ++++ test/test-suites/rendering.tsx | 64 ++++++++++ test/tsconfig.json | 7 ++ tsconfig-base.json | 14 +++ tsconfig.json | 15 +-- 12 files changed, 563 insertions(+), 24 deletions(-) create mode 100644 test/lib/expect.ts create mode 100644 test/lib/harness.ts create mode 100644 test/lib/index.ts create mode 100644 test/lib/utils.ts create mode 100644 test/run-tests.ts create mode 100644 test/test-suites/rendering.tsx create mode 100644 test/tsconfig.json create mode 100644 tsconfig-base.json diff --git a/index.d.ts b/index.d.ts index 0a3b174..5d6345a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -4,13 +4,3 @@ */ export * from "./dist"; - -import { HTMLElement } from "./dist"; -import { IntrinsicElements as JsxIntrinsics } from "./dist/jsx"; - -declare global { - namespace JSX { - type Element = HTMLElement; - type IntrinsicElements = JsxIntrinsics; - } -} diff --git a/package.json b/package.json index 94e96e2..1b04b15 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "tsc", "clean": "ts-node scripts/clean.ts", - "prepublishOnly": "npm run clean && npm run build" + "prepublishOnly": "npm run clean && npm run build", + "test": "ts-node --project=test/tsconfig.json test/run-tests.ts" }, "devDependencies": { "@types/node": "~12", diff --git a/src/index.ts b/src/index.ts index a564807..c82e7dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,3 +6,13 @@ export { HTMLElement, Component } from "./component"; export { UserConfig as Config } from "./config"; export { createElement } from "./create-element"; + +import { HTMLElement } from "./component"; +import { IntrinsicElements as JsxIntrinsics } from "./jsx"; + +declare global { + namespace JSX { + type Element = HTMLElement; + type IntrinsicElements = JsxIntrinsics; + } +} diff --git a/test/lib/expect.ts b/test/lib/expect.ts new file mode 100644 index 0000000..7fe091f --- /dev/null +++ b/test/lib/expect.ts @@ -0,0 +1,212 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { areEqual, displayValue, matches } from "./utils"; + +class ExpectError extends Error { + public constructor(reason: string, expected: any, actual: any) { + super( + `${reason}\n` + + `\texpected: ${displayValue(expected)}\n` + + `\tactual : ${displayValue(actual)}` + ); + } +} + +/** + * A generic expectation builder which knows nothing about the type of value it + * is operating upon. + * + * This is the base class expectation that allows only type-agnostic assertions + * upon its contained value, and all other expectation classes inherit from + * Expect. + */ +export class Expect { + protected readonly value: T; + + /** + * Create a new expectation around a value. + * + * @param value Value to place assertions upon. + */ + public constructor(value: T) { + this.value = value; + } + + /** + * Expect the value to equal an expected value. + * + * Note that strict equality checking is used for primitives and structural + * equality is used for objects. + * + * @param expected Expected value. + * + * @throws ExpectError If the actual value does not equal the expected value. + */ + public toEqual(expected: T) { + if (!areEqual(this.value, expected)) { + throw new ExpectError( + `value does not equal expected`, + expected, + this.value + ); + } + } +} + +/** + * String-specific Expect assertions. + */ +export class StringExpect extends Expect { + /** + * Expect the string value to match a RegExp pattern. + * + * @param pattern Regular expression to match against. + * + * @throws ExpectError If the actual value does not match the expected + * RegExp pattern. + */ + public toMatch(pattern: RegExp): void { + if (!this.value.match(pattern)) { + throw new ExpectError( + `value does not match expected pattern`, + pattern, + this.value + ); + } + } + + /** + * Expect the string value to start with a particular prefix. + * + * @param prefix Prefix that the string is expected to start with. + * + * @throws ExpectError If the actual value does not start with the expected + * prefix. + */ + public toStartWith(prefix: string): void { + if (!this.value.startsWith(prefix)) { + throw new ExpectError( + `value does not start with expected prefix`, + prefix, + this.value + ); + } + } + + /** + * Expect the string value to end with a particular suffix. + * + * @param suffix Suffix that the string is expected to end with. + * + * @throws ExpectError If the actual value does not end with the expected + * suffix. + */ + public toEndWith(suffix: string): void { + if (!this.value.endsWith(suffix)) { + throw new ExpectError( + `value does not end with expected suffix`, + suffix, + this.value + ); + } + } +} + +/** + * Function-specific Expect assertions. + */ +export class FunctionExpect extends Expect<() => T> { + /** + * Expect the function to throw an Error with error message matching a + * string or pattern. + * + * @param pattern String that exactly matches the error message or RegExp + * that should match the error message. + * + * @throws ExpectError If the function does not throw an error, throws a + * non-Error value, or throws an Error whose message does not match + * the expected pattern. + */ + public toThrowErrorMatching(pattern: string | RegExp): void { + try { + this.value(); + } catch (error) { + if (!(error instanceof Error)) { + throw new ExpectError( + `function threw non-Error value`, + pattern, + error + ); + } + if (!matches(error.message, pattern)) { + throw new ExpectError( + `thrown Error's message does not match pattern`, + pattern, + error.message + ); + } + return; + } + throw new ExpectError( + `function did not throw expected error`, + pattern, + null + ); + } +} + +/** + * Create an Expect assertion builder on a string value. + * + * @param str String value to place expectations upon. + */ +export function expect(str: string): StringExpect; +/** + * Create an Expect assertion builder on a function value. + * + * Useful primarily for asserting that a function throws an expected Error, + * e.g.: + * + * ```ts + * // Passes assertion. + * expect(() => throw new Error('oh noes!')).toThrowErrorMatching('oh noes!'); + * + * // Fails assertion since non-error value was thrown in func. + * expect(() => throw "oh noes!").toThrow('oh noes!'); + * + * // Fails assertion since func doesn't throw. + * expect(() => 1 / 2).toThrowErrorMatching('oh noes!'); + * ``` + * + * @param fn Function to place expectations upon. + */ +export function expect(fn: () => T): FunctionExpect; +/** + * Create an Expect assertion upon some value. + * + * Expectations are declarative assertions on values that immediately throw an + * Error when the assertion is violated. This abstraction allows for readable + * test assertions like the following: + * + * ```ts + * // Doesn't throw since strings are equal. + * expect("hai").toEqual("hai"); + * + * // Throws an Error since 3 !== 2. + * expect(3).toEqual(2); + * ``` + * + * @param value Value to place expectations upon. + */ +export function expect(value: any): Expect { + if (typeof value === "string") { + return new StringExpect(value); + } + if (typeof value === "function") { + return new FunctionExpect(value); + } + return new Expect(value); +} diff --git a/test/lib/harness.ts b/test/lib/harness.ts new file mode 100644 index 0000000..3a6dde9 --- /dev/null +++ b/test/lib/harness.ts @@ -0,0 +1,120 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { expect } from "./expect"; +import { displayValue, shuffle } from "./utils"; + +interface Test { + readonly name: string; + + runTest(): void | Promise; +} + +type TestResult = { + testName: string; +} & ( + | { + result: "pass"; + } + | { + result: "fail"; + error: Error; + } +); + +const runTest = async (test: Test): Promise => { + let result: TestResult; + try { + await test.runTest(); + result = { + testName: test.name, + result: "pass", + }; + } catch (error) { + result = { + testName: test.name, + result: "fail", + error: + error instanceof Error + ? error + : new Error( + `threw non-error object: ${displayValue(error)}` + ), + }; + } + return result; +}; + +/** + * Context object that is passed into a test suite definition. + */ +export interface TestSuiteContext { + /** + * Define a test in this test suite. + * + * Tests are functions that pass if they are executed and don't throw (or + * that resolve for async tests), and that fail if they throw an error (or + * reject for async tests). + * + * Note that tests are executed in a random order within a test suite in + * order to prevent accidentally creating order dependencies between tests, + * which can result in brittle tests and is a code smell that might indicate + * that the code under test is also brittle. + */ + test: (name: string, def: () => void | Promise) => void; + /** + * Expectation builder function used to build human-readable assertions and + * errors. + */ + expect: typeof expect; +} + +/** + * Define a suite of tests to run as a single unit. + * + * A test suite executes immediately, running tests in a randomly determined + * order. + * + * Note that currently there is no support for having multiple test suites per + * test file; you CAN have multiple test suites in a file but if the first test + * suite fails any subsequent test suites won't be executed. + * + * @param suiteName Name of the test suite for reporting. + * @param def Function used to declare the tests + */ +export const testSuite = ( + suiteName: string, + def: (ctx: TestSuiteContext) => void +): void => { + const tests: Test[] = []; + const test = (name: string, runTest: () => void | Promise): void => { + tests.push({ name, runTest }); + }; + def({ test, expect }); + + // Randomly shuffle the tests so that we can catch accidental order + // dependencies. + shuffle(tests); + (async () => { + const results = await Promise.all(tests.map((test) => runTest(test))); + let passed = 0; + for (const testResult of results) { + if (testResult.result === "fail") { + console.error( + `[TEST FAILURE] "${suiteName}": "${testResult.testName}": ` + + `${testResult.error.message}\n` + ); + continue; + } + passed += 1; + } + console.info( + `[TEST] suite "${suiteName}": ${passed} of ${tests.length} succeeded\n\n` + ); + if (passed < tests.length) { + process.exit(1); + } + })(); +}; diff --git a/test/lib/index.ts b/test/lib/index.ts new file mode 100644 index 0000000..4d48acf --- /dev/null +++ b/test/lib/index.ts @@ -0,0 +1,6 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +export { testSuite } from "./harness"; diff --git a/test/lib/utils.ts b/test/lib/utils.ts new file mode 100644 index 0000000..5827c8c --- /dev/null +++ b/test/lib/utils.ts @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +/** + * Randomly rearrange the items of an array in-place. + * + * @param arr Array to shuffle. + */ +export const shuffle = (arr: T[]): void => { + let j: number; + let x: T; + for (let i = arr.length - 1; i > 0; i--) { + j = Math.floor(Math.random() * (i + 1)); + x = arr[i]; + arr[i] = arr[j]; + arr[j] = x; + } +}; + +const areArraysEqual = (a: T[], b: T[]): boolean => { + if (a.length != b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!areEqual(a[i], b[i])) { + return false; + } + } + return true; +}; + +const areObjectsEqual = (a: T, b: T): boolean => { + const aKeys = Object.keys(a) as Array; + const bKeys = Object.keys(b) as Array; + if (aKeys.length !== bKeys.length) { + return false; + } + for (const key of aKeys) { + if (!areEqual(a[key], b[key])) { + return false; + } + } + return true; +}; + +/** + * Return whether two values are structurally equal, with support for + * primitive values, arrays, deeply nested objects, and RegExp. + * + * @param a First value to test equality with. + * @param b Second value to test equality with. + * + * @return Whether the two values are structurally equal. + */ +export const areEqual = (a: T, b: T): boolean => { + if (Array.isArray(a) && Array.isArray(b)) { + return areArraysEqual(a, b); + } + if (a instanceof RegExp && b instanceof RegExp) { + return a.source === b.source; + } + if (typeof a === "object" && typeof b === "object") { + return areObjectsEqual(a as any, b); + } + return a === b; +}; + +/** + * Return whether a string exactly matches an expected string OR matches a + * RegExp pattern. + * + * If the passed pattern is a string this uses strict equality checking, and + * if the passed pattern is a RegExp object it tests the value against it. + * + * @param value String value to test. + * @param pattern String or RegExp pattern to match value against. + */ +export const matches = (value: string, pattern: string | RegExp): boolean => { + if (typeof pattern === "string") { + return value === pattern; + } + return pattern.test(value); +}; + +/** + * Render a JavaScript value for debugging and error messages. + * + * This is essentially JSON.stringify, but with special cases for undefined ( + * which normally isn't rendered with JSON.stringify) and RegExp (to display + * source for the regexp). + * + * @param value Value to render. + * + * @return Rendered value to display. + */ +export const displayValue = (value: any): string => { + if (value === undefined) { + return "undefined"; + } + if (value instanceof RegExp) { + return value.toString(); + } + return JSON.stringify(value); +}; diff --git a/test/run-tests.ts b/test/run-tests.ts new file mode 100644 index 0000000..f6b068e --- /dev/null +++ b/test/run-tests.ts @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { fork } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; + +import { shuffle } from "./lib/utils"; + +const TEST_SUITES_DIR = path.join(__dirname, "test-suites"); + +const files = fs.readdirSync(TEST_SUITES_DIR); +// Shuffle test suites to detect ordering dependencies between them. +shuffle(files); +for (const file of files) { + const fullPath = path.join(TEST_SUITES_DIR, file); + fork(path.relative(process.cwd(), fullPath)); +} diff --git a/test/test-suites/rendering.tsx b/test/test-suites/rendering.tsx new file mode 100644 index 0000000..8ab63af --- /dev/null +++ b/test/test-suites/rendering.tsx @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { createElement } from "../../dist"; +import { renderPage } from "../../dist/render"; +import { testSuite } from "../lib"; + +testSuite("renderPage", ({ test, expect }) => { + test("throws an Error when root elem is not html tag", () => { + expect(() => renderPage(
)).toThrowErrorMatching( + "attempted to render page with non-HTML root element div" + ); + }); + + test("outputs a HTML5 DOCTYPE declaration", () => { + const html = renderPage(); + expect(html).toStartWith(""); + }); + + test("escapes HTML in tag names", () => { + const html = renderPage( + {createElement("div> + ); + expect(html).toEqual( + "" + ); + }); + + test("renders html attributes", () => { + const html = renderPage( + +
+ + ); + expect(html).toEqual( + '
' + ); + }); + + test("renders common html tags", () => { + const html = renderPage( + + + + </head> + <body> + <div /> + </body> + </html> + ); + expect(html).toEqual( + "<!DOCTYPE html><html><head><title>
" + ); + }); + + test("renders text nodes", () => { + const html = renderPage(There are three lights!); + expect(html).toEqual( + "There are three lights!" + ); + }); +}); diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..e6edc8b --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig-base.json", + "compilerOptions": { + "jsx": "react", + "jsxFactory": "createElement" + } +} diff --git a/tsconfig-base.json b/tsconfig-base.json new file mode 100644 index 0000000..84df7a5 --- /dev/null +++ b/tsconfig-base.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "module": "CommonJS", + "moduleResolution": "node", + "target": "ES2019", + "lib": ["ES2019"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/tsconfig.json b/tsconfig.json index 8bc3e5f..c36fd09 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,18 +1,7 @@ { + "extends": "./tsconfig-base.json", "compilerOptions": { - "sourceMap": true, - "declaration": true, - "esModuleInterop": true, - "module": "CommonJS", - "moduleResolution": "node", - "target": "ES2019", - "lib": ["ES2019"], - "outDir": "./dist", - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true + "outDir": "./dist" }, "include": ["src/**/*.ts"] }