@service-broker/test-utils
Version:
Unit testing utility
206 lines (187 loc) • 6.5 kB
text/typescript
import assert from "assert/strict";
import util from "util";
import { green, red, yellowBright } from "yoctocolors";
import { assertRecord, lazy } from "./util.js";
interface Test {
name: string
run: Function
}
interface Suite {
name: string
beforeAll: Function[]
afterAll: Function[]
beforeEach: Function[]
afterEach: Function[]
tests: Test[]
}
const suites: Suite[] = []
const scheduleRun = lazy(() => setTimeout(run, 0))
const runAfterEverything: Function[] = []
export function describe(
suiteName: string,
setup: (opts: {
beforeAll: (run: Function) => void
afterAll: (run: Function) => void
beforeEach: (run: Function) => void
afterEach: (run: Function) => void
test: (name: string, run: Function) => void
}) => void
) {
const suite: Suite = {
name: suiteName,
beforeAll: [],
afterAll: [],
beforeEach: [],
afterEach: [],
tests: []
}
setup({
beforeAll: run => suite.beforeAll.push(run),
afterAll: run => suite.afterAll.push(run),
beforeEach: run => suite.beforeEach.push(run),
afterEach: run => suite.afterEach.push(run),
test: (name, run) => suite.tests.push({ name, run })
})
suites.push(suite)
scheduleRun()
}
export function afterEverything(run: Function) {
runAfterEverything.push(run)
}
class FailedExpectation {
expected: unknown
actual: unknown
stack?: string
constructor(readonly reason: string, readonly path: string[] = []) {
}
}
async function run() {
const suiteName = process.argv[2]
const testName = process.argv[3]
const suitesToRun = suiteName ? suites.filter(x => x.name == suiteName) : suites
try {
try {
for (const suite of suitesToRun) {
const testsToRun = testName ? suite.tests.filter(x => x.name == testName) : suite.tests
for (const run of suite.beforeAll) await run()
try {
for (const test of testsToRun) {
for (const run of suite.beforeEach) await run()
try {
console.log("Running test '%s' '%s'", suite.name, test.name)
await test.run()
} finally {
for (const run of suite.afterEach) await run()
}
}
} finally {
for (const run of suite.afterAll) await run()
}
}
} finally {
for (const run of runAfterEverything) await run()
}
console.log("Finished.")
} catch (err) {
if (err instanceof FailedExpectation) {
if (err.expected) console.error(red('EXPECT'), util.inspect(err.expected, { depth: Infinity }))
console.error(green('ACTUAL'), util.inspect(err.actual, { depth: Infinity }))
console.error('Error:', yellowBright('.' + err.path.join('.') + ' ' + err.reason))
console.error(err.stack?.replace(/^Error\n/, ''))
} else {
console.error(err)
}
}
}
export class Expectation {
constructor(operator: string, expected: unknown, assert: (actual: unknown) => void) {
Object.defineProperty(this, operator, { value: expected, enumerable: true })
Object.defineProperty(this, 'assert', { value: assert })
}
}
export function expect(actual: unknown, expected: unknown, path: string[] = []) {
try {
if (typeof expected == 'object' && expected != null) {
if (expected instanceof Expectation) {
try {
(expected as any).assert(actual)
} catch (err: any) {
if (err instanceof FailedExpectation) {
err.path.splice(0, 0, ...path)
throw err
} else {
throw new FailedExpectation(err.message || err, path)
}
}
}
else if (expected instanceof Set) {
try {
assert.deepStrictEqual(actual, expected)
} catch {
throw new FailedExpectation('!equalExpected', path)
}
}
else if (expected instanceof Map) {
if (!(actual instanceof Map)) throw new FailedExpectation('!isMap', path)
for (const [key] of actual) {
if (!expected.has(key)) throw new FailedExpectation(`hasExtraKey '${key}'`, path)
}
for (const [key, expectedValue] of expected) {
if (!actual.has(key)) throw new FailedExpectation(`missingKey '${key}'`, path)
expect(actual.get(key), expectedValue, [...path, key])
}
}
else if (Array.isArray(expected)) {
if (!Array.isArray(actual)) throw new FailedExpectation('!isArray', path)
if (actual.length != expected.length) throw new FailedExpectation('!ofExpectedLength', path)
for (let i = 0; i < actual.length; i++)
expect(actual[i], expected[i], [...path, String(i)])
}
else if (Buffer.isBuffer(expected)) {
if (!Buffer.isBuffer(actual)) throw new FailedExpectation('!isBuffer', path)
if (!actual.equals(expected)) throw new FailedExpectation('!equalExpected', path)
}
else {
if (!(typeof actual == 'object' && actual != null)) throw new FailedExpectation('!isObject', path)
assertRecord(expected)
assertRecord(actual)
for (const prop in actual) {
if (!(prop in expected)) throw new FailedExpectation(`hasExtraProp '${prop}'`, path)
}
for (const prop in expected) {
if (!(prop in actual)) throw new FailedExpectation(`missingProp '${prop}'`, path)
expect(actual[prop], expected[prop], [...path, prop])
}
}
} else {
if (actual !== expected) throw new FailedExpectation('!equalExpected', path)
}
} catch (err) {
if (err instanceof FailedExpectation && path.length == 0) {
err.actual = actual
err.expected = expected
Error.captureStackTrace(err, expect)
}
throw err
}
}
export function objectHaving(expectedProps: Record<string, unknown>) {
return new Expectation('have', expectedProps, actual => {
assert(typeof actual == 'object' && actual != null, '!isObject')
assertRecord(actual)
for (const prop in expectedProps) {
assert(prop in actual, `missingProp '${prop}'`)
expect(actual[prop], expectedProps[prop], [prop])
}
})
}
export function valueOfType(expectedType: string) {
return new Expectation('ofType', expectedType, actual => {
assert(typeof actual == expectedType, '!ofExpectedType')
})
}
export function oneOf(expectedValues: unknown[]) {
return new Expectation('oneOf', expectedValues, actual => {
assert(expectedValues.includes(actual), '!oneOfExpectedValues')
})
}