Skip to content

Commit

Permalink
feat(expect): add toSatisfy asymmetric matcher (#7022)
Browse files Browse the repository at this point in the history
  • Loading branch information
hi-ogawa authored Dec 9, 2024
1 parent b3e43d0 commit f691ad7
Show file tree
Hide file tree
Showing 7 changed files with 158 additions and 17 deletions.
29 changes: 29 additions & 0 deletions packages/expect/src/custom-matchers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { MatchersObject } from './types'

// selectively ported from https://github.com/jest-community/jest-extended
export const customMatchers: MatchersObject = {
toSatisfy(actual: unknown, expected: (actual: unknown) => boolean, message?: string) {
const { printReceived, printExpected, matcherHint } = this.utils
const pass = expected(actual)
return {
pass,
message: () =>
pass
? `\
${matcherHint('.not.toSatisfy', 'received', '')}
Expected value to not satisfy:
${message || printExpected(expected)}
Received:
${printReceived(actual)}`
: `\
${matcherHint('.toSatisfy', 'received', '')}
Expected value to satisfy:
${message || printExpected(expected)}
Received:
${printReceived(actual)}`,
}
},
}
1 change: 1 addition & 0 deletions packages/expect/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './constants'
export { customMatchers } from './custom-matchers'
export * from './jest-asymmetric-matchers'
export { JestChaiExpect } from './jest-expect'
export { JestExtend } from './jest-extend'
Expand Down
3 changes: 0 additions & 3 deletions packages/expect/src/jest-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1029,9 +1029,6 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
)
})
})
def('toSatisfy', function (matcher: Function, message?: string) {
return this.be.satisfy(matcher, message)
})

// @ts-expect-error @internal
def('withContext', function (this: any, context: Record<string, any>) {
Expand Down
29 changes: 16 additions & 13 deletions packages/expect/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,21 @@ export interface ExpectStatic
not: AsymmetricMatchersContaining
}

export interface AsymmetricMatchersContaining {
interface CustomMatcher {
/**
* Checks that a value satisfies a custom matcher function.
*
* @param matcher - A function returning a boolean based on the custom condition
* @param message - Optional custom error message on failure
*
* @example
* expect(age).toSatisfy(val => val >= 18, 'Age must be at least 18');
* expect(age).toEqual(expect.toSatisfy(val => val >= 18, 'Age must be at least 18'));
*/
toSatisfy: (matcher: (value: any) => boolean, message?: string) => any
}

export interface AsymmetricMatchersContaining extends CustomMatcher {
/**
* Matches if the received string contains the expected substring.
*
Expand Down Expand Up @@ -153,7 +167,7 @@ export interface AsymmetricMatchersContaining {
closeTo: (expected: number, precision?: number) => any
}

export interface JestAssertion<T = any> extends jest.Matchers<void, T> {
export interface JestAssertion<T = any> extends jest.Matchers<void, T>, CustomMatcher {
/**
* Used when you want to check that two objects have the same value.
* This matcher recursively checks the equality of all fields, rather than checking for object identity.
Expand Down Expand Up @@ -645,17 +659,6 @@ export interface Assertion<T = any>
*/
toHaveBeenCalledExactlyOnceWith: <E extends any[]>(...args: E) => void

/**
* Checks that a value satisfies a custom matcher function.
*
* @param matcher - A function returning a boolean based on the custom condition
* @param message - Optional custom error message on failure
*
* @example
* expect(age).toSatisfy(val => val >= 18, 'Age must be at least 18');
*/
toSatisfy: <E>(matcher: (value: E) => boolean, message?: string) => void

/**
* This assertion checks if a `Mock` was called before another `Mock`.
* @param mock - A mock function created by `vi.spyOn` or `vi.fn`
Expand Down
3 changes: 3 additions & 0 deletions packages/vitest/src/integrations/chai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { TaskPopulated, Test } from '@vitest/runner'
import {
addCustomEqualityTesters,
ASYMMETRIC_MATCHERS_OBJECT,
customMatchers,
getState,
GLOBAL_EXPECT,
setState,
Expand Down Expand Up @@ -109,6 +110,8 @@ export function createExpect(test?: TaskPopulated) {
chai.util.addMethod(expect, 'assertions', assertions)
chai.util.addMethod(expect, 'hasAssertions', hasAssertions)

expect.extend(customMatchers)

return expect
}

Expand Down
68 changes: 68 additions & 0 deletions test/core/test/__snapshots__/jest-expect.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -454,3 +454,71 @@ exports[`toMatch/toContain diff 3`] = `
"message": "expected 'hellohellohellohellohellohellohellohe…' to match /world/",
}
`;
exports[`toSatisfy() > error message 1`] = `
{
"actual": "undefined",
"diff": undefined,
"expected": "undefined",
"message": "expect(received).toSatisfy()
Expected value to satisfy:
[Function isOdd]
Received:
2",
}
`;
exports[`toSatisfy() > error message 2`] = `
{
"actual": "undefined",
"diff": undefined,
"expected": "undefined",
"message": "expect(received).toSatisfy()
Expected value to satisfy:
ODD
Received:
2",
}
`;
exports[`toSatisfy() > error message 3`] = `
{
"actual": "Object {
"value": 2,
}",
"diff": "- Expected
+ Received
Object {
- "value": toSatisfy<(value) => value % 2 !== 0>,
+ "value": 2,
}",
"expected": "Object {
"value": toSatisfy<(value) => value % 2 !== 0>,
}",
"message": "expected { value: 2 } to deeply equal { value: toSatisfy{…} }",
}
`;
exports[`toSatisfy() > error message 4`] = `
{
"actual": "Object {
"value": 2,
}",
"diff": "- Expected
+ Received
Object {
- "value": toSatisfy<(value) => value % 2 !== 0, ODD>,
+ "value": 2,
}",
"expected": "Object {
"value": toSatisfy<(value) => value % 2 !== 0, ODD>,
}",
"message": "expected { value: 2 } to deeply equal { value: toSatisfy{…} }",
}
`;
42 changes: 41 additions & 1 deletion test/core/test/jest-expect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { AssertionError } from 'node:assert'
import { stripVTControlCharacters } from 'node:util'
import { generateToBeMessage } from '@vitest/expect'
import { processError } from '@vitest/utils/error'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { assert, beforeAll, describe, expect, it, vi } from 'vitest'

class TestError extends Error {}

Expand Down Expand Up @@ -606,6 +606,46 @@ describe('toSatisfy()', () => {
expect(1).toSatisfy(isOddMock)
expect(isOddMock).toBeCalled()
})

it('asymmetric matcher', () => {
expect({ value: 1 }).toEqual({ value: expect.toSatisfy(isOdd) })
expect(() => {
expect({ value: 2 }).toEqual({ value: expect.toSatisfy(isOdd, 'odd') })
}).toThrowErrorMatchingInlineSnapshot(
`[AssertionError: expected { value: 2 } to deeply equal { value: toSatisfy{…} }]`,
)

expect(() => {
throw new Error('1')
}).toThrow(
expect.toSatisfy((e) => {
assert(e instanceof Error)
expect(e).toMatchObject({ message: expect.toSatisfy(isOdd) })
return true
}),
)

expect(() => {
expect(() => {
throw new Error('2')
}).toThrow(
expect.toSatisfy((e) => {
assert(e instanceof Error)
expect(e).toMatchObject({ message: expect.toSatisfy(isOdd) })
return true
}),
)
}).toThrowErrorMatchingInlineSnapshot(
`[AssertionError: expected Error: 2 to match object { message: toSatisfy{…} }]`,
)
})

it('error message', () => {
snapshotError(() => expect(2).toSatisfy(isOdd))
snapshotError(() => expect(2).toSatisfy(isOdd, 'ODD'))
snapshotError(() => expect({ value: 2 }).toEqual({ value: expect.toSatisfy(isOdd) }))
snapshotError(() => expect({ value: 2 }).toEqual({ value: expect.toSatisfy(isOdd, 'ODD') }))
})
})

describe('toHaveBeenCalled', () => {
Expand Down

0 comments on commit f691ad7

Please sign in to comment.