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": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"lineWidth": 120
|
"lineWidth": 120
|
||||||
|
|
126
src/expect.ts
126
src/expect.ts
|
@ -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
|
||||||
|
})()
|
||||||
|
|
|
@ -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 }
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
24
src/types.ts
24
src/types.ts
|
@ -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 }
|
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
Reference in a new issue