Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add expect.addEqualityTesters API #4586

Closed
wants to merge 28 commits into from
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4307b02
feat: add custom equality api
eryue0220 Nov 24, 2023
2e6de20
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Nov 24, 2023
12b472a
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Nov 24, 2023
86b6338
fix: remove only
eryue0220 Nov 25, 2023
2a91904
Merge branch 'feat/jest-custom-testers' of github.com:eryue0220/vites…
eryue0220 Nov 25, 2023
5351a2d
fix: cr
eryue0220 Nov 27, 2023
a8e796c
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Nov 27, 2023
bb6d1c2
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Nov 27, 2023
8d285eb
fix: recover
eryue0220 Nov 27, 2023
88dc183
Merge branch 'feat/jest-custom-testers' of github.com:eryue0220/vites…
eryue0220 Nov 27, 2023
07482c6
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Nov 27, 2023
d8e9729
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Nov 28, 2023
e245c48
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Nov 29, 2023
da4d4db
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Nov 29, 2023
86adfa7
Merge branch 'main' into feat/jest-custom-testers
Dunqing Dec 2, 2023
7212d72
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Dec 17, 2023
9687afe
fix: cr
eryue0220 Dec 17, 2023
35421e9
fix: jest compat bug
eryue0220 Dec 21, 2023
fd98133
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Dec 21, 2023
1fc9d68
fix: ut
eryue0220 Dec 21, 2023
21c1d31
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Dec 21, 2023
26d0feb
fix: ut test
eryue0220 Dec 21, 2023
ab81683
fix: ut test
eryue0220 Dec 21, 2023
770c28e
fix: recover
eryue0220 Dec 22, 2023
4b1026b
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Dec 29, 2023
eccc6fc
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Jan 2, 2024
a2aaf99
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Jan 2, 2024
a6fb5fd
Merge branch 'main' into feat/jest-custom-testers
eryue0220 Jan 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions docs/api/expect.md
Original file line number Diff line number Diff line change
Expand Up @@ -1407,3 +1407,84 @@ Don't forget to include the ambient declaration file in your `tsconfig.json`.
:::tip
If you want to know more, checkout [guide on extending matchers](/guide/extending-matchers).
:::

## expect.addEqualityTesters

- **Type:** `(tester: Array<Tester>) => void`
sheremet-va marked this conversation as resolved.
Show resolved Hide resolved
- **Version:** Since Vitest 1.0.0

You can use this method to define custom matcher to test if two object equals or not. And this function is compatible with Jest's `expect.extend`, so any library that uses it to create custom matchers will work with Vitest.

```ts
class Duration {
public time: number
public unit: 'H' | 'M' | 'S'

constructor(time: number, unit: 'H' | 'M' | 'S') {
this.time = time
this.unit = unit
}

toString(): string {
return `[Duration: ${this.time.toString()}${this.unit}]`
}

equals(other: Duration): boolean {
if (this.unit === other.unit)
return this.time === other.time

else if (
(this.unit === 'H' && other.unit === 'M')
|| (this.unit === 'M' && other.unit === 'S')
)
return (this.time * 60) === other.time

else if (
(other.unit === 'H' && this.unit === 'M')
|| (other.unit === 'M' && this.unit === 'S')
)
return (other.time * 60) === this.time

return (this.time * 60 * 60) === other.time
}
}
```

```ts
function isDurationMatch(a: Duration, b: Duration) {
const isDurationA = a instanceof Duration
const isDurationB = b instanceof Duration

if (isDurationA && isDurationB)
return a.equals(b)

else if (isDurationA === isDurationB)
return undefined

return false
}

expect.addEqualityTesters([isDurationMatch])
```

```ts
it('basic test case', () => {
expect(new Duration(1, 'H')).toEqual(new Duration(3600, 'S'))
})
```

For custom matchers function, you can combine it with `expect.extend`, For example:

```ts
expect.extend({
toEqualDuration(received: Duration, expected: Duration) {
const result = this.equals(received, expected, this.customTesters)
return {
message: () => `Expected object: ${received.toString()}. But expectedly got: ${expected.toString()}`,
pass: result,
}
},
})

expect(new Duration(1, 'H')).toEqualDuration(new Duration(3600, 'S'))
```
2 changes: 1 addition & 1 deletion examples/vitesse/src/auto-import.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,5 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, InjectionKey, PropType, Ref, VNode } from 'vue'
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
}
2 changes: 1 addition & 1 deletion packages/expect/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export * from './jest-asymmetric-matchers'
export * from './jest-utils'
export * from './constants'
export * from './types'
export { getState, setState } from './state'
export { getState, setState, addCustomEqualityTesters } from './state'
export { JestChaiExpect } from './jest-expect'
export { JestExtend } from './jest-extend'
export { setupColors } from '@vitest/utils'
4 changes: 2 additions & 2 deletions packages/expect/src/jest-asymmetric-matchers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ChaiPlugin, MatcherState } from './types'
import { GLOBAL_EXPECT } from './constants'
import { getState } from './state'
import { getCustomEqualityTesters, getState } from './state'
import { diff, getMatcherUtils, stringify } from './jest-matcher-utils'

import { equals, isA, iterableEquality, pluralize, subsetEquality } from './jest-utils'
Expand All @@ -26,7 +26,7 @@ export abstract class AsymmetricMatcher<
...getState(expect || (globalThis as any)[GLOBAL_EXPECT]),
equals,
isNot: this.inverse,
customTesters: [],
customTesters: getCustomEqualityTesters(),
utils: {
...getMatcherUtils(),
diff,
Expand Down
11 changes: 7 additions & 4 deletions packages/expect/src/jest-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { AsymmetricMatcher } from './jest-asymmetric-matchers'
import { diff, stringify } from './jest-matcher-utils'
import { JEST_MATCHERS_OBJECT } from './constants'
import { recordAsyncExpect, wrapSoft } from './utils'
import { getCustomEqualityTesters } from './state'

// polyfill globals because expect can be used in node environment
declare class Node {
Expand Down Expand Up @@ -80,7 +81,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
const equal = jestEquals(
sheremet-va marked this conversation as resolved.
Show resolved Hide resolved
actual,
expected,
[iterableEquality],
[...getCustomEqualityTesters(), iterableEquality],
)

return this.assert(
Expand All @@ -98,6 +99,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
obj,
expected,
[
...getCustomEqualityTesters(),
iterableEquality,
typeEquality,
sparseArrayEquality,
Expand Down Expand Up @@ -125,6 +127,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
actual,
expected,
[
...getCustomEqualityTesters(),
iterableEquality,
typeEquality,
sparseArrayEquality,
Expand All @@ -140,7 +143,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
const toEqualPass = jestEquals(
actual,
expected,
[iterableEquality],
[...getCustomEqualityTesters(), iterableEquality],
)

if (toEqualPass)
Expand All @@ -159,7 +162,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
def('toMatchObject', function (expected) {
const actual = this._obj
return this.assert(
jestEquals(actual, expected, [iterableEquality, subsetEquality]),
jestEquals(actual, expected, [...getCustomEqualityTesters(), iterableEquality, subsetEquality]),
'expected #{this} to match object #{exp}',
'expected #{this} to not match object #{exp}',
expected,
Expand Down Expand Up @@ -208,7 +211,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
def('toContainEqual', function (expected) {
const obj = utils.flag(this, 'object')
const index = Array.from(obj).findIndex((item) => {
return jestEquals(item, expected)
return jestEquals(item, expected, [...getCustomEqualityTesters(), iterableEquality])
})

this.assert(
Expand Down
5 changes: 2 additions & 3 deletions packages/expect/src/jest-extend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
} from './types'
import { ASYMMETRIC_MATCHERS_OBJECT, JEST_MATCHERS_OBJECT } from './constants'
import { AsymmetricMatcher } from './jest-asymmetric-matchers'
import { getState } from './state'
import { getCustomEqualityTesters, getState } from './state'

import { diff, getMatcherUtils, stringify } from './jest-matcher-utils'

Expand All @@ -33,8 +33,7 @@ function getMatcherState(assertion: Chai.AssertionStatic & Chai.Assertion, expec

const matcherState: MatcherState = {
...getState(expect),
// TODO: implement via expect.addEqualityTesters
customTesters: [],
customTesters: getCustomEqualityTesters(),
isNot,
utils: jestUtils,
promise,
Expand Down
53 changes: 37 additions & 16 deletions packages/expect/src/jest-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,13 @@ function hasIterator(object: any) {
return !!(object != null && object[IteratorSymbol])
}

export function iterableEquality(a: any, b: any, aStack: Array<any> = [], bStack: Array<any> = []): boolean | undefined {
export function iterableEquality(
a: any,
b: any,
customTesters: Array<Tester> = [],
aStack: Array<any> = [],
bStack: Array<any> = [],
): boolean | undefined {
if (
typeof a !== 'object'
|| typeof b !== 'object'
Expand All @@ -324,7 +330,20 @@ export function iterableEquality(a: any, b: any, aStack: Array<any> = [], bStack
aStack.push(a)
bStack.push(b)

const iterableEqualityWithStack = (a: any, b: any) => iterableEquality(a, b, [...aStack], [...bStack])
const filteredCustomTesters: Array<Tester> = [
...customTesters.filter(t => t !== iterableEquality),
iterableEqualityWithStack,
]

function iterableEqualityWithStack(a: any, b: any) {
return iterableEquality(
a,
b,
[...filteredCustomTesters],
[...aStack],
[...bStack],
)
}

if (a.size !== undefined) {
if (a.size !== b.size) {
Expand All @@ -336,7 +355,7 @@ export function iterableEquality(a: any, b: any, aStack: Array<any> = [], bStack
if (!b.has(aValue)) {
let has = false
for (const bValue of b) {
const isEqual = equals(aValue, bValue, [iterableEqualityWithStack])
const isEqual = equals(aValue, bValue, filteredCustomTesters)
if (isEqual === true)
has = true
}
Expand All @@ -357,20 +376,16 @@ export function iterableEquality(a: any, b: any, aStack: Array<any> = [], bStack
for (const aEntry of a) {
if (
!b.has(aEntry[0])
|| !equals(aEntry[1], b.get(aEntry[0]), [iterableEqualityWithStack])
|| !equals(aEntry[1], b.get(aEntry[0]), filteredCustomTesters)
) {
let has = false
for (const bEntry of b) {
const matchedKey = equals(aEntry[0], bEntry[0], [
iterableEqualityWithStack,
])
const matchedKey = equals(aEntry[0], bEntry[0], filteredCustomTesters)

let matchedValue = false
if (matchedKey === true) {
matchedValue = equals(aEntry[1], bEntry[1], [
iterableEqualityWithStack,
])
}
if (matchedKey === true)
matchedValue = equals(aEntry[1], bEntry[1], filteredCustomTesters)

if (matchedValue === true)
has = true
}
Expand All @@ -394,7 +409,7 @@ export function iterableEquality(a: any, b: any, aStack: Array<any> = [], bStack
const nextB = bIterator.next()
if (
nextB.done
|| !equals(aValue, nextB.value, [iterableEqualityWithStack])
|| !equals(aValue, nextB.value, filteredCustomTesters)
)
return false
}
Expand Down Expand Up @@ -430,7 +445,13 @@ function isObjectWithKeys(a: any) {
&& !(a instanceof Date)
}

export function subsetEquality(object: unknown, subset: unknown): boolean | undefined {
export function subsetEquality(
object: unknown,
subset: unknown,
customTesters: Array<Tester> = [],
): boolean | undefined {
const filteredCustomTesters = customTesters.filter(t => t !== subsetEquality)

// subsetEquality needs to keep track of the references
// it has already visited to avoid infinite loops in case
// there are circular references in the subset passed to it.
Expand All @@ -443,15 +464,15 @@ export function subsetEquality(object: unknown, subset: unknown): boolean | unde
return Object.keys(subset).every((key) => {
if (isObjectWithKeys(subset[key])) {
if (seenReferences.has(subset[key]))
return equals(object[key], subset[key], [iterableEquality])
return equals(object[key], subset[key], filteredCustomTesters)

seenReferences.set(subset[key], true)
}
const result
= object != null
&& hasPropertyInObject(object, key)
&& equals(object[key], subset[key], [
iterableEquality,
...filteredCustomTesters,
subsetEqualityWithContext(seenReferences),
])
// The main goal of using seenReference is to avoid circular node on tree.
Expand Down
18 changes: 17 additions & 1 deletion packages/expect/src/state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ExpectStatic, MatcherState } from './types'
import { getType } from '@vitest/utils'
import type { ExpectStatic, MatcherState, Tester } from './types'
import { ASYMMETRIC_MATCHERS_OBJECT, GLOBAL_EXPECT, JEST_MATCHERS_OBJECT, MATCHERS_OBJECT } from './constants'

if (!Object.prototype.hasOwnProperty.call(globalThis, MATCHERS_OBJECT)) {
Expand Down Expand Up @@ -33,3 +34,18 @@ export function setState<State extends MatcherState = MatcherState>(
Object.assign(current, state)
map.set(expect, current)
}

export function getCustomEqualityTesters(): Array<Tester> {
const { customTesters } = getState((globalThis as any)[GLOBAL_EXPECT])
return customTesters || []
}

export function addCustomEqualityTesters(testers: Array<Tester>): void {
if (!Array.isArray(testers)) {
throw new TypeError(
`expect.customEqualityTesters should receive array type, but got: ${getType(testers)}`,
)
}

setState({ customTesters: [...testers] }, (globalThis as any)[GLOBAL_EXPECT])
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks compatibility with Jest, there it doesn't override testers, but pushes a new one

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks~ Done

1 change: 1 addition & 0 deletions packages/expect/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export interface ExpectStatic extends Chai.ExpectStatic, AsymmetricMatchersConta
getState(): MatcherState
setState(state: Partial<MatcherState>): void
not: AsymmetricMatchersContaining
addEqualityTesters(tester: Array<Tester>): void
}

export interface AsymmetricMatchersContaining {
Expand Down
4 changes: 3 additions & 1 deletion packages/vitest/src/integrations/chai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as chai from 'chai'
import './setup'
import type { TaskPopulated, Test } from '@vitest/runner'
import { getCurrentTest } from '@vitest/runner'
import { ASYMMETRIC_MATCHERS_OBJECT, GLOBAL_EXPECT, getState, setState } from '@vitest/expect'
import { ASYMMETRIC_MATCHERS_OBJECT, GLOBAL_EXPECT, addCustomEqualityTesters, getState, setState } from '@vitest/expect'
import type { Assertion, ExpectStatic } from '@vitest/expect'
import type { MatcherState } from '../../types/chai'
import { getFullName } from '../../utils/tasks'
Expand Down Expand Up @@ -59,6 +59,8 @@ export function createExpect(test?: TaskPopulated) {
chai.assert.fail(`expected${message ? ` "${message}" ` : ' '}not to be reached`)
}

expect.addEqualityTesters = addCustomEqualityTesters

function assertions(expected: number) {
const errorGen = () => new Error(`expected number of assertions to be ${expected}, but got ${expect.getState().assertionCalls}`)
if (Error.captureStackTrace)
Expand Down
Loading
Loading