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": {
"enabled": true,
"lineWidth": 120

View file

@ -1,6 +1,16 @@
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'
class TestAssertionFailed extends Error {
@ -10,46 +20,106 @@ class TestAssertionFailed extends Error {
}
}
function expect<ValueType>(value: ValueType): Expect<ValueType> {
const expectation: ExpectBase<ValueType> = {
value,
negated: false,
not: {},
addMatcher: function (this: any, matcher: any) {
return (other: unknown) => {
const out = matcher(this.value, other)
class Expect<ValueType> {
static #rawMatchers: RawMatchersMap = {
comparisonMatchers: [],
noArgMatchers: [],
}
/*
* 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) {
throw new TestAssertionFailed(out.message)
}
}
},
}
Object.entries(matchers.matchers).forEach(([label, matcher]) => {
Object.defineProperty(expectation, label, {
value: expectation.addMatcher(matcher),
enumerable: true,
})
}) as NoArgMatcher
} else if (matcher.length === 2) {
return ((other: unknown) => {
const out = (matcher as RawComparisonMatcher)(this.value, other, negated)
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,
})
if (!out.pass) {
throw new TestAssertionFailed(out.message)
}
}) as ComparisonMatcher
}
})
Object.entries(matchers.inverseMatchers).forEach(([label, matcher]) => {
Object.defineProperty(expectation, label, {
value: expectation.addMatcher(matcher),
throw Error('Unknown matcher layout')
}
/*
* Adds a matcher to the current Expect instance.
*/
#extendWithMatcher(matcher: RawMatcher) {
Object.defineProperty(this, matcher.name, {
value: this.#prepareMatcher(matcher),
enumerable: true,
})
})
return expectation as Expect<ValueType>
Object.defineProperty(this.not, matcher.name, {
value: this.#prepareMatcher(matcher, true),
enumerable: true,
})
}
constructor(value: ValueType) {
this.value = value
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.
*/
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: '' }
try {
@ -27,7 +28,9 @@ function toEqual(value: unknown, other: unknown): MatcherReport {
/*
* 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)
out.pass = !out.pass
@ -39,7 +42,9 @@ function toNotEqual(value: unknown, other: unknown): MatcherReport {
/*
* 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)
return { pass: isSame, message: `${value} is not ${other}` }
}
@ -47,7 +52,8 @@ function toBe(value: unknown, other: unknown): MatcherReport {
/*
* 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)
out.pass = !out.pass
@ -59,7 +65,9 @@ function toNotBe(value: unknown, other: unknown): MatcherReport {
/*
* 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: '' }
try {
@ -78,8 +86,10 @@ function toThrow(func: () => unknown, error: Error): MatcherReport {
/*
* Inverse of toThrow.
*/
function toNotThrow(func: () => unknown, error: Error): MatcherReport {
const out = toThrow(func, error)
function toNotThrow(func: () => unknown, negated: boolean = false): MatcherReport {
if (negated) return toThrow(func)
const out = toThrow(func)
out.pass = !out.pass
out.message = out.pass ? '' : 'Function threw exception'
@ -87,7 +97,4 @@ function toNotThrow(func: () => unknown, error: Error): MatcherReport {
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 }
export default [toEqual, toBe, toThrow, toNotEqual, toNotBe, toNotThrow]

View file

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

View file

@ -29,13 +29,19 @@ export interface MatcherReport {
message: string
}
export interface ExpectBase<ValueType> {
value?: ValueType
negated?: boolean
not: ExpectBase<ValueType> & any
addMatcher: (this: ExpectBase<ValueType> & any, matcher: any) => void
export type MatcherName = string
export type ComparisonMatcher = (value: unknown) => void
export type NoArgMatcher = () => 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')
expect(() => {
throw err
}).toThrow(err)
}).toThrow()
})
test('Expects no error', () => {