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
This commit is contained in:
parent
40cc9fa664
commit
aa4695c4e5
6 changed files with 159 additions and 34 deletions
|
@ -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 ."
|
||||
},
|
||||
|
|
|
@ -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<ValueType> {
|
||||
value: ValueType
|
||||
negated: boolean
|
||||
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)
|
||||
|
||||
constructor(value: ValueType) {
|
||||
this.value = value
|
||||
this.negated = false
|
||||
}
|
||||
|
||||
/*
|
||||
* 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 (!out.pass) {
|
||||
throw new TestAssertionFailed(out.message)
|
||||
}
|
||||
}
|
||||
|
||||
toBe(value: ValueType) {
|
||||
const isSame = Object.is(this.value, value)
|
||||
|
||||
if ((isSame && !this.negated) || (!isSame && this.negated)) return
|
||||
throw new TestAssertionFailed(`NotEqual! ${this.value} ${this.negated ? '===' : '!=='} ${value}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
Object.entries(matchers.matchers).forEach(([label, matcher]) => {
|
||||
Object.defineProperty(expectation, label, {
|
||||
value: expectation.addMatcher(matcher),
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
function expect<ValueType>(value: ValueType) {
|
||||
return new Expectation(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,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Object.entries(matchers.inverseMatchers).forEach(([label, matcher]) => {
|
||||
Object.defineProperty(expectation, label, {
|
||||
value: expectation.addMatcher(matcher),
|
||||
enumerable: true,
|
||||
})
|
||||
})
|
||||
|
||||
return expectation as Expect<ValueType>
|
||||
}
|
||||
|
||||
export default expect
|
||||
|
|
93
src/matchers.ts
Normal file
93
src/matchers.ts
Normal file
|
@ -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 }
|
|
@ -143,7 +143,6 @@ function parseArgs(args: Array<string>): Args {
|
|||
await assignTestsToWorkers(context, collectedTests)
|
||||
|
||||
if (server.failure) throw new Error()
|
||||
|
||||
} catch (e) {
|
||||
console.log(redText('Test run failed'))
|
||||
} finally {
|
||||
|
|
16
src/types.ts
16
src/types.ts
|
@ -23,3 +23,19 @@ export interface Args {
|
|||
runtimePath: string
|
||||
help: boolean
|
||||
}
|
||||
|
||||
export interface MatcherReport {
|
||||
pass: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ExpectBase<ValueType> {
|
||||
value?: ValueType
|
||||
negated?: boolean
|
||||
not: ExpectBase<ValueType> & any
|
||||
addMatcher: (this: ExpectBase<ValueType> & any, matcher: any) => void
|
||||
}
|
||||
|
||||
export type ComparisonMatcher = (value: unknown) => boolean
|
||||
|
||||
export type Expect<ValueType> = ExpectBase<ValueType> & { [key: string]: ComparisonMatcher }
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
Reference in a new issue