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:
Marc 2023-04-04 14:01:29 -04:00
parent 40cc9fa664
commit aa4695c4e5
Signed by: marc
GPG key ID: 048E042F22B5DC79
6 changed files with 159 additions and 34 deletions

View file

@ -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 ."
},

View file

@ -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
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,
})
/*
* 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 (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,
})
}
}
})
toBe(value: ValueType) {
const isSame = Object.is(this.value, value)
Object.entries(matchers.inverseMatchers).forEach(([label, matcher]) => {
Object.defineProperty(expectation, label, {
value: expectation.addMatcher(matcher),
enumerable: true,
})
})
if ((isSame && !this.negated) || (!isSame && this.negated)) return
throw new TestAssertionFailed(`NotEqual! ${this.value} ${this.negated ? '===' : '!=='} ${value}`)
}
}
function expect<ValueType>(value: ValueType) {
return new Expectation(value)
return expectation as Expect<ValueType>
}
export default expect

93
src/matchers.ts Normal file
View 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 }

View file

@ -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 {

View file

@ -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 }

View file

@ -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', () => {