From aa4695c4e532dff5863d26a0bf24ad82acd3cbdc Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Tue, 4 Apr 2023 14:01:29 -0400 Subject: [PATCH] refactor: add matchers to expectation dynamically (#11) * refactor: add matchers to expectation dynamically * feat: add toThrow * refactor: centralize types * docs: matchers * refactor: MatcherReport * chore: format before lint * refactor: simplify negated matchers * docs: inverse matchers --- package.json | 4 +- src/expect.ts | 66 +++++++++++++++++-------------- src/matchers.ts | 93 ++++++++++++++++++++++++++++++++++++++++++++ src/runner.ts | 1 - src/types.ts | 16 ++++++++ tests/expect.test.ts | 13 ++++++- 6 files changed, 159 insertions(+), 34 deletions(-) create mode 100644 src/matchers.ts diff --git a/package.json b/package.json index f11cde1..84c0439 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "scripts": { "prepack": "yarn build", "prebuild": "rm -rf dist", - "lint": "rome check src tests && rome format src tests", - "lint:fix": "rome check src tests --apply && rome format src tests --write", + "lint": "rome format src tests && rome check src tests", + "lint:fix": "rome format src tests --write && rome check src tests --apply", "test": "ts-node ./src/runner.ts ./tests", "build": "tsc --project ." }, diff --git a/src/expect.ts b/src/expect.ts index 31008e1..71ec84b 100644 --- a/src/expect.ts +++ b/src/expect.ts @@ -1,5 +1,8 @@ import assert from 'assert' +import { type ExpectBase, type Expect } from './types' +import matchers from './matchers' + class TestAssertionFailed extends Error { constructor(message: string) { super(message) @@ -7,41 +10,46 @@ class TestAssertionFailed extends Error { } } -class Expectation { - value: ValueType - negated: boolean +function expect(value: ValueType): Expect { + const expectation: ExpectBase = { + value, + negated: false, + not: {}, + addMatcher: function (this: any, matcher: any) { + return (other: unknown) => { + const out = matcher(this.value, other) - constructor(value: ValueType) { - this.value = value - this.negated = false + if (!out.pass) { + throw new TestAssertionFailed(out.message) + } + } + }, } + Object.entries(matchers.matchers).forEach(([label, matcher]) => { + Object.defineProperty(expectation, label, { + value: expectation.addMatcher(matcher), + enumerable: true, + }) - /* - * Negates the expectation. - */ - get not() { - this.negated = !this.negated - return this - } - - toEqual(value: ValueType) { - if (this.negated) { - assert.notDeepEqual(this.value, value, new TestAssertionFailed(`Equal! ${this.value} = ${value}`)) - } else { - assert.deepEqual(this.value, value, new TestAssertionFailed(`NotEqual! ${this.value} != ${value}`)) + if (label in matchers.matchersToInverseMap) { + const reverseMatcherName = matchers.matchersToInverseMap[ + label as keyof typeof matchers.matchersToInverseMap + ] as keyof typeof matchers.inverseMatchers + Object.defineProperty(expectation.not, label, { + value: expectation.addMatcher(matchers.inverseMatchers[reverseMatcherName]), + enumerable: true, + }) } - } + }) - toBe(value: ValueType) { - const isSame = Object.is(this.value, value) + Object.entries(matchers.inverseMatchers).forEach(([label, matcher]) => { + Object.defineProperty(expectation, label, { + value: expectation.addMatcher(matcher), + enumerable: true, + }) + }) - if ((isSame && !this.negated) || (!isSame && this.negated)) return - throw new TestAssertionFailed(`NotEqual! ${this.value} ${this.negated ? '===' : '!=='} ${value}`) - } -} - -function expect(value: ValueType) { - return new Expectation(value) + return expectation as Expect } export default expect diff --git a/src/matchers.ts b/src/matchers.ts new file mode 100644 index 0000000..a970854 --- /dev/null +++ b/src/matchers.ts @@ -0,0 +1,93 @@ +/* + * Built-in matchers + * + * All the matchers defined and exported as part of the default export + * of this file are available to each `expect` statement made in tests. + */ +import assert from 'assert' + +import { type MatcherReport } from './types' + +/* + * Asserts whether value and other are strictly equal. + */ +function toEqual(value: unknown, other: unknown): MatcherReport { + const output = { pass: false, message: '' } + + try { + assert.deepEqual(value, other) + output.pass = true + } catch (e) { + output.message = `${value} != ${other}` + } + + return output +} + +/* + * Inverse of toEqual. + */ +function toNotEqual(value: unknown, other: unknown): MatcherReport { + const out = toEqual(value, other) + + out.pass = !out.pass + out.message = out.pass ? '' : `${value} == ${other}` + + return out +} + +/* + * Asserts whether value and other are the same entity. + */ +function toBe(value: unknown, other: unknown): MatcherReport { + const isSame = Object.is(value, other) + return { pass: isSame, message: `${value} is not ${other}` } +} + +/* + * Inverse ot toBe. + */ +function toNotBe(value: unknown, other: unknown): MatcherReport { + const out = toBe(value, other) + + out.pass = !out.pass + out.message = out.pass ? '' : `${value} is ${other}` + + return out +} + +/* + * Asserts whether the provided function throws the provided error. + */ +function toThrow(func: () => unknown, error: Error): MatcherReport { + const report = { pass: false, message: '' } + + try { + func() + } catch (e) { + report.pass = true + } + + if (!report.pass) { + report.message = 'Function did not throw' + } + + return report +} + +/* + * Inverse of toThrow. + */ +function toNotThrow(func: () => unknown, error: Error): MatcherReport { + const out = toThrow(func, error) + + out.pass = !out.pass + out.message = out.pass ? '' : 'Function threw exception' + + return out +} + +const matchers = { toEqual, toBe, toThrow } +const inverseMatchers = { toNotEqual, toNotBe, toNotThrow } +const matchersToInverseMap = { toEqual: 'toNotEqual', toBe: 'toNotBe', toThrow: 'toNotThrow' } +export default { matchers, inverseMatchers, matchersToInverseMap } diff --git a/src/runner.ts b/src/runner.ts index 4a4fd97..eabbeb3 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -143,7 +143,6 @@ function parseArgs(args: Array): Args { await assignTestsToWorkers(context, collectedTests) if (server.failure) throw new Error() - } catch (e) { console.log(redText('Test run failed')) } finally { diff --git a/src/types.ts b/src/types.ts index 503c962..f7e120a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,3 +23,19 @@ export interface Args { runtimePath: string help: boolean } + +export interface MatcherReport { + pass: boolean + message: string +} + +export interface ExpectBase { + value?: ValueType + negated?: boolean + not: ExpectBase & any + addMatcher: (this: ExpectBase & any, matcher: any) => void +} + +export type ComparisonMatcher = (value: unknown) => boolean + +export type Expect = ExpectBase & { [key: string]: ComparisonMatcher } diff --git a/tests/expect.test.ts b/tests/expect.test.ts index b488f5b..d6f876b 100644 --- a/tests/expect.test.ts +++ b/tests/expect.test.ts @@ -57,8 +57,17 @@ describe('Identity', () => { }) }) -test('Stacked equality negation', () => { - assert.doesNotThrow(() => expect('yes').not.not.toEqual('yes')) +describe('Exception expectation', () => { + test('Expects error', () => { + const err = new Error('err') + expect(() => { + throw err + }).toThrow(err) + }) + + test('Expects no error', () => { + expect(() => {}).not.toThrow() + }) }) test('Identity negation', () => {