diff --git a/packages/expect/src/custom-matchers.ts b/packages/expect/src/custom-matchers.ts new file mode 100644 index 000000000000..06f7e5b5af2b --- /dev/null +++ b/packages/expect/src/custom-matchers.ts @@ -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)}`, + } + }, +} diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts index 448a348eaef5..76b87ad04091 100644 --- a/packages/expect/src/index.ts +++ b/packages/expect/src/index.ts @@ -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' diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index c15f277edd9e..223b151312d5 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -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) { diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 5e1a76a897bd..a26a448b242e 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -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. * @@ -153,7 +167,7 @@ export interface AsymmetricMatchersContaining { closeTo: (expected: number, precision?: number) => any } -export interface JestAssertion extends jest.Matchers { +export interface JestAssertion extends jest.Matchers, 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. @@ -645,17 +659,6 @@ export interface Assertion */ toHaveBeenCalledExactlyOnceWith: (...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: (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` diff --git a/packages/vitest/src/integrations/chai/index.ts b/packages/vitest/src/integrations/chai/index.ts index 1aea8249218f..829963eb7de4 100644 --- a/packages/vitest/src/integrations/chai/index.ts +++ b/packages/vitest/src/integrations/chai/index.ts @@ -5,6 +5,7 @@ import type { TaskPopulated, Test } from '@vitest/runner' import { addCustomEqualityTesters, ASYMMETRIC_MATCHERS_OBJECT, + customMatchers, getState, GLOBAL_EXPECT, setState, @@ -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 } diff --git a/test/core/test/__snapshots__/jest-expect.test.ts.snap b/test/core/test/__snapshots__/jest-expect.test.ts.snap index 0471481b1dfe..6ef28d04bf3b 100644 --- a/test/core/test/__snapshots__/jest-expect.test.ts.snap +++ b/test/core/test/__snapshots__/jest-expect.test.ts.snap @@ -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{…} }", +} +`; diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index 9f74ed75f90b..31c050fe1e8b 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -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 {} @@ -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', () => {