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:
parent
aa4695c4e5
commit
711d0097ce
6 changed files with 141 additions and 64 deletions
|
@ -1,12 +1,4 @@
|
|||
{
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"lineWidth": 120
|
||||
|
|
134
src/expect.ts
134
src/expect.ts
|
@ -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
|
||||
})()
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
|
|
24
src/types.ts
24
src/types.ts
|
@ -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 }
|
||||
|
|
|
@ -62,7 +62,7 @@ describe('Exception expectation', () => {
|
|||
const err = new Error('err')
|
||||
expect(() => {
|
||||
throw err
|
||||
}).toThrow(err)
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
test('Expects no error', () => {
|
||||
|
|
Reference in a new issue