refactor: Expect with static matcher registration (#12)

* refactor: Expect with static matcher registration

* refactor: types

* refactor: mark private properties as such

* refactor: consolidate matcher adding and remove redundancy

* chore: lint

* refactor: more consolidation

* refactor: simply matchers exports

* docs: documentation on Expect

* refactor: remove dependencies around inverse matchers

* refactor: move cruft from constructor
This commit is contained in:
Marc 2023-04-07 19:04:38 -04:00
parent aa4695c4e5
commit 711d0097ce
Signed by: marc
GPG key ID: 048E042F22B5DC79
6 changed files with 141 additions and 64 deletions

View file

@ -1,12 +1,4 @@
{ {
"linter": {
"enabled": true,
"rules": {
"suspicious": {
"noExplicitAny": "warn"
}
}
},
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"lineWidth": 120 "lineWidth": 120

View file

@ -1,6 +1,16 @@
import assert from 'assert' import assert from 'assert'
import { type ExpectBase, type Expect } from './types' import {
type Matcher,
type NoArgMatcher,
type ComparisonMatcher,
type RawMatcher,
type RawNoArgMatcher,
type RawComparisonMatcher,
type RawMatchersMap,
type MatcherName,
} from './types'
import matchers from './matchers' import matchers from './matchers'
class TestAssertionFailed extends Error { class TestAssertionFailed extends Error {
@ -10,46 +20,106 @@ class TestAssertionFailed extends Error {
} }
} }
function expect<ValueType>(value: ValueType): Expect<ValueType> { class Expect<ValueType> {
const expectation: ExpectBase<ValueType> = { static #rawMatchers: RawMatchersMap = {
value, comparisonMatchers: [],
negated: false, noArgMatchers: [],
not: {}, }
addMatcher: function (this: any, matcher: any) {
return (other: unknown) => { /*
const out = matcher(this.value, other) * Value the expectation is run against.
*/
value: ValueType
/*
* Collection of inverted matchers. Any matchers registered
* is also available negated under .not.
*/
not: { [key: MatcherName]: Matcher } = {}
/*
* Registers matchers with Expect. At this point, Expect knows of them, but
* still needs to prepare them on instantiation so they can be used.
*/
static addMatcher(matcher: RawMatcher) {
if (matcher.length === 1) Expect.#rawMatchers.noArgMatchers.push(matcher as RawNoArgMatcher)
else Expect.#rawMatchers.comparisonMatchers.push(matcher as RawComparisonMatcher)
}
/*
* Returns all registered matchers.
*/
static #getRawMatchers(): Array<RawMatcher> {
return [...Expect.#rawMatchers.comparisonMatchers, ...Expect.#rawMatchers.noArgMatchers]
}
/*
* Prepares a raw matchers for the current
* Expect instance.
*/
#prepareMatcher(matcher: RawMatcher, negated: boolean = false): Matcher {
if (matcher.length === 1) {
return (() => {
const out = (matcher as RawNoArgMatcher)(this.value, negated)
if (!out.pass) { if (!out.pass) {
throw new TestAssertionFailed(out.message) throw new TestAssertionFailed(out.message)
} }
}) as NoArgMatcher
} else if (matcher.length === 2) {
return ((other: unknown) => {
const out = (matcher as RawComparisonMatcher)(this.value, other, negated)
if (!out.pass) {
throw new TestAssertionFailed(out.message)
} }
}, }) as ComparisonMatcher
} }
Object.entries(matchers.matchers).forEach(([label, matcher]) => {
Object.defineProperty(expectation, label, { throw Error('Unknown matcher layout')
value: expectation.addMatcher(matcher), }
/*
* Adds a matcher to the current Expect instance.
*/
#extendWithMatcher(matcher: RawMatcher) {
Object.defineProperty(this, matcher.name, {
value: this.#prepareMatcher(matcher),
enumerable: true, enumerable: true,
}) })
if (label in matchers.matchersToInverseMap) { Object.defineProperty(this.not, matcher.name, {
const reverseMatcherName = matchers.matchersToInverseMap[ value: this.#prepareMatcher(matcher, true),
label as keyof typeof matchers.matchersToInverseMap
] as keyof typeof matchers.inverseMatchers
Object.defineProperty(expectation.not, label, {
value: expectation.addMatcher(matchers.inverseMatchers[reverseMatcherName]),
enumerable: true, enumerable: true,
}) })
} }
})
Object.entries(matchers.inverseMatchers).forEach(([label, matcher]) => { constructor(value: ValueType) {
Object.defineProperty(expectation, label, { this.value = value
value: expectation.addMatcher(matcher),
enumerable: true,
})
})
return expectation as Expect<ValueType> Expect.#getRawMatchers().forEach((matcher) => {
this.#extendWithMatcher(matcher)
})
}
} }
export default expect type ExpectWithMatchers<ValueType> = Expect<ValueType> & {
[key: MatcherName]: Matcher
}
/*
* The `expect` function returned is the main access point
* to create Expect objects. On import, all the built-in matchers
* are registered, but more can be registered ad-hoc via `addMatcher`.
*/
export default (() => {
matchers.forEach((matcher) => {
Expect.addMatcher(matcher)
})
function expect<ValueType>(value: ValueType): ExpectWithMatchers<ValueType> {
return new Expect<ValueType>(value) as ExpectWithMatchers<ValueType>
}
return expect
})()

View file

@ -11,7 +11,8 @@ import { type MatcherReport } from './types'
/* /*
* Asserts whether value and other are strictly equal. * Asserts whether value and other are strictly equal.
*/ */
function toEqual(value: unknown, other: unknown): MatcherReport { function toEqual(value: unknown, other: unknown, negated: boolean = false): MatcherReport {
if (negated) return toNotEqual(value, other)
const output = { pass: false, message: '' } const output = { pass: false, message: '' }
try { try {
@ -27,7 +28,9 @@ function toEqual(value: unknown, other: unknown): MatcherReport {
/* /*
* Inverse of toEqual. * Inverse of toEqual.
*/ */
function toNotEqual(value: unknown, other: unknown): MatcherReport { function toNotEqual(value: unknown, other: unknown, negated: boolean = false): MatcherReport {
if (negated) return toEqual(value, other)
const out = toEqual(value, other) const out = toEqual(value, other)
out.pass = !out.pass out.pass = !out.pass
@ -39,7 +42,9 @@ function toNotEqual(value: unknown, other: unknown): MatcherReport {
/* /*
* Asserts whether value and other are the same entity. * Asserts whether value and other are the same entity.
*/ */
function toBe(value: unknown, other: unknown): MatcherReport { function toBe(value: unknown, other: unknown, negated: boolean = false): MatcherReport {
if (negated) return toNotBe(value, other)
const isSame = Object.is(value, other) const isSame = Object.is(value, other)
return { pass: isSame, message: `${value} is not ${other}` } return { pass: isSame, message: `${value} is not ${other}` }
} }
@ -47,7 +52,8 @@ function toBe(value: unknown, other: unknown): MatcherReport {
/* /*
* Inverse ot toBe. * Inverse ot toBe.
*/ */
function toNotBe(value: unknown, other: unknown): MatcherReport { function toNotBe(value: unknown, other: unknown, negated: boolean = false): MatcherReport {
if (negated) return toBe(value, other)
const out = toBe(value, other) const out = toBe(value, other)
out.pass = !out.pass out.pass = !out.pass
@ -59,7 +65,9 @@ function toNotBe(value: unknown, other: unknown): MatcherReport {
/* /*
* Asserts whether the provided function throws the provided error. * Asserts whether the provided function throws the provided error.
*/ */
function toThrow(func: () => unknown, error: Error): MatcherReport { function toThrow(func: () => unknown, negated: boolean = false): MatcherReport {
if (negated) return toNotThrow(func)
const report = { pass: false, message: '' } const report = { pass: false, message: '' }
try { try {
@ -78,8 +86,10 @@ function toThrow(func: () => unknown, error: Error): MatcherReport {
/* /*
* Inverse of toThrow. * Inverse of toThrow.
*/ */
function toNotThrow(func: () => unknown, error: Error): MatcherReport { function toNotThrow(func: () => unknown, negated: boolean = false): MatcherReport {
const out = toThrow(func, error) if (negated) return toThrow(func)
const out = toThrow(func)
out.pass = !out.pass out.pass = !out.pass
out.message = out.pass ? '' : 'Function threw exception' out.message = out.pass ? '' : 'Function threw exception'
@ -87,7 +97,4 @@ function toNotThrow(func: () => unknown, error: Error): MatcherReport {
return out return out
} }
const matchers = { toEqual, toBe, toThrow } export default [toEqual, toBe, toThrow, toNotEqual, toNotBe, toNotThrow]
const inverseMatchers = { toNotEqual, toNotBe, toNotThrow }
const matchersToInverseMap = { toEqual: 'toNotEqual', toBe: 'toNotBe', toThrow: 'toNotThrow' }
export default { matchers, inverseMatchers, matchersToInverseMap }

View file

@ -99,9 +99,11 @@ function parseArgs(args: Array<string>): Args {
argsWithoutFlags, argsWithoutFlags,
shortFlags, shortFlags,
longFlags, longFlags,
}: { argsWithoutFlags: Array<string>; longFlags: Array<string>; shortFlags: Array<string> } = ( }: {
userArgs as Array<string> argsWithoutFlags: Array<string>
).reduce( longFlags: Array<string>
shortFlags: Array<string>
} = (userArgs as Array<string>).reduce(
(acc, arg: string) => { (acc, arg: string) => {
if (arg.startsWith('--')) acc.longFlags.push(arg) if (arg.startsWith('--')) acc.longFlags.push(arg)
else if (arg.startsWith('-')) acc.shortFlags.push(arg) else if (arg.startsWith('-')) acc.shortFlags.push(arg)

View file

@ -29,13 +29,19 @@ export interface MatcherReport {
message: string message: string
} }
export interface ExpectBase<ValueType> { export type MatcherName = string
value?: ValueType
negated?: boolean export type ComparisonMatcher = (value: unknown) => void
not: ExpectBase<ValueType> & any export type NoArgMatcher = () => void
addMatcher: (this: ExpectBase<ValueType> & any, matcher: any) => void
export type RawComparisonFuncMatcher = (value: () => unknown, other: unknown) => MatcherReport
export type RawComparisonMatcher = (value: unknown, other: unknown, negated?: boolean) => MatcherReport
export type RawNoArgMatcher = (value: unknown | (() => unknown), negated?: boolean) => MatcherReport
export type RawNoArgFuncMatcher = (value: () => unknown, negated?: boolean) => MatcherReport
export type Matcher = (...rest: Array<unknown>) => void
export type RawMatcher = RawComparisonMatcher | RawNoArgMatcher | RawNoArgFuncMatcher
export interface RawMatchersMap {
comparisonMatchers: Array<RawComparisonMatcher>
noArgMatchers: Array<RawNoArgMatcher>
} }
export type ComparisonMatcher = (value: unknown) => boolean
export type Expect<ValueType> = ExpectBase<ValueType> & { [key: string]: ComparisonMatcher }

View file

@ -62,7 +62,7 @@ describe('Exception expectation', () => {
const err = new Error('err') const err = new Error('err')
expect(() => { expect(() => {
throw err throw err
}).toThrow(err) }).toThrow()
}) })
test('Expects no error', () => { test('Expects no error', () => {