From 50b2cc635e110fa2b7bc8ba6739b85a71c69ef6f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 5 Dec 2024 10:34:53 +0900 Subject: [PATCH 1/5] feat(expect): add `toSatisfy` asymmetric matcher --- .../expect/src/jest-asymmetric-matchers.ts | 31 +++++++++++++++++++ packages/expect/src/types.ts | 11 +++++++ 2 files changed, 42 insertions(+) diff --git a/packages/expect/src/jest-asymmetric-matchers.ts b/packages/expect/src/jest-asymmetric-matchers.ts index 7fd7fee5bf56..41a395926385 100644 --- a/packages/expect/src/jest-asymmetric-matchers.ts +++ b/packages/expect/src/jest-asymmetric-matchers.ts @@ -377,6 +377,29 @@ class CloseTo extends AsymmetricMatcher { } } +class ToSatisfy extends AsymmetricMatcher<(value: any) => boolean> { + constructor(sample: (value: any) => boolean, private message?: string, inverse?: boolean) { + super(sample, inverse) + } + + asymmetricMatch(other: unknown) { + const pass = this.sample(other) + return this.inverse ? !pass : pass + } + + toString() { + return `${this.inverse ? 'not.' : ''}ToSatisfy` + } + + getExpectedType() { + return 'Function' + } + + toAsymmetricMatcher() { + return `${this.toString()}${this.message ? `<${this.message}>` : ''}` + } +} + export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { utils.addMethod(chai.expect, 'anything', () => new Anything()) @@ -410,6 +433,12 @@ export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { chai.expect, 'closeTo', (expected: any, precision?: number) => new CloseTo(expected, precision), + ) + + utils.addMethod( + chai.expect, + 'toSatisfy', + (expected: any, message?: string) => new ToSatisfy(expected, message), ); // defineProperty does not work @@ -423,5 +452,7 @@ export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { new StringMatching(expected, true), closeTo: (expected: any, precision?: number) => new CloseTo(expected, precision, true), + toSatisfy: (expected: any, message?: string) => + new ToSatisfy(expected, message, true), } } diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 5e1a76a897bd..5747b68b6ba1 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -151,6 +151,17 @@ export interface AsymmetricMatchersContaining { * expect(5.11).toEqual(expect.closeTo(5.12)); // with default precision */ closeTo: (expected: number, precision?: number) => any + + /** + * Matches if the received value satisfies 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).toEqual(expect.toSatisfy(val => val >= 18, 'Age must be at least 18')); + */ + toSatisfy: (matcher: (value: any) => boolean, message?: string) => any } export interface JestAssertion extends jest.Matchers { From 03f807025c0e510ff80fde84275558e2808f126c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 5 Dec 2024 11:21:33 +0900 Subject: [PATCH 2/5] test: add test --- test/core/test/jest-expect.test.ts | 35 +++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index 74ed197ddf07..5fb904a1fd6d 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,39 @@ 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 }]`, + ) + }) }) describe('toHaveBeenCalled', () => { From 287914a0a5c4a927eb176013d62ca281be8981ee Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 6 Dec 2024 16:09:19 +0900 Subject: [PATCH 3/5] refactor: use expect.extend --- packages/expect/src/index.ts | 1 + .../expect/src/jest-asymmetric-matchers.ts | 31 --------- packages/expect/src/jest-expect.ts | 3 - packages/expect/src/jest-extended.ts | 29 ++++++++ .../vitest/src/integrations/chai/index.ts | 3 + .../__snapshots__/jest-expect.test.ts.snap | 68 +++++++++++++++++++ test/core/test/jest-expect.test.ts | 11 ++- 7 files changed, 110 insertions(+), 36 deletions(-) create mode 100644 packages/expect/src/jest-extended.ts diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts index 448a348eaef5..93550fbea447 100644 --- a/packages/expect/src/index.ts +++ b/packages/expect/src/index.ts @@ -2,6 +2,7 @@ export * from './constants' export * from './jest-asymmetric-matchers' export { JestChaiExpect } from './jest-expect' export { JestExtend } from './jest-extend' +export { jestExtendedMatchers } from './jest-extended' export { addCustomEqualityTesters } from './jest-matcher-utils' export * from './jest-utils' export { getState, setState } from './state' diff --git a/packages/expect/src/jest-asymmetric-matchers.ts b/packages/expect/src/jest-asymmetric-matchers.ts index 41a395926385..7fd7fee5bf56 100644 --- a/packages/expect/src/jest-asymmetric-matchers.ts +++ b/packages/expect/src/jest-asymmetric-matchers.ts @@ -377,29 +377,6 @@ class CloseTo extends AsymmetricMatcher { } } -class ToSatisfy extends AsymmetricMatcher<(value: any) => boolean> { - constructor(sample: (value: any) => boolean, private message?: string, inverse?: boolean) { - super(sample, inverse) - } - - asymmetricMatch(other: unknown) { - const pass = this.sample(other) - return this.inverse ? !pass : pass - } - - toString() { - return `${this.inverse ? 'not.' : ''}ToSatisfy` - } - - getExpectedType() { - return 'Function' - } - - toAsymmetricMatcher() { - return `${this.toString()}${this.message ? `<${this.message}>` : ''}` - } -} - export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { utils.addMethod(chai.expect, 'anything', () => new Anything()) @@ -433,12 +410,6 @@ export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { chai.expect, 'closeTo', (expected: any, precision?: number) => new CloseTo(expected, precision), - ) - - utils.addMethod( - chai.expect, - 'toSatisfy', - (expected: any, message?: string) => new ToSatisfy(expected, message), ); // defineProperty does not work @@ -452,7 +423,5 @@ export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { new StringMatching(expected, true), closeTo: (expected: any, precision?: number) => new CloseTo(expected, precision, true), - toSatisfy: (expected: any, message?: string) => - new ToSatisfy(expected, message, true), } } 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/jest-extended.ts b/packages/expect/src/jest-extended.ts new file mode 100644 index 000000000000..43650379a870 --- /dev/null +++ b/packages/expect/src/jest-extended.ts @@ -0,0 +1,29 @@ +import type { MatchersObject } from './types' + +// selectively ported from https://github.com/jest-community/jest-extended +export const jestExtendedMatchers: 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/vitest/src/integrations/chai/index.ts b/packages/vitest/src/integrations/chai/index.ts index 1aea8249218f..68cbbf03d06e 100644 --- a/packages/vitest/src/integrations/chai/index.ts +++ b/packages/vitest/src/integrations/chai/index.ts @@ -7,6 +7,7 @@ import { ASYMMETRIC_MATCHERS_OBJECT, getState, GLOBAL_EXPECT, + jestExtendedMatchers, setState, } from '@vitest/expect' import { getCurrentTest } from '@vitest/runner' @@ -109,6 +110,8 @@ export function createExpect(test?: TaskPopulated) { chai.util.addMethod(expect, 'assertions', assertions) chai.util.addMethod(expect, 'hasAssertions', hasAssertions) + expect.extend(jestExtendedMatchers) + 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 ced7d75d7410..109f961f8db5 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 5fb904a1fd6d..82c007ef9e37 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -612,7 +612,7 @@ describe('toSatisfy()', () => { expect(() => { expect({ value: 2 }).toEqual({ value: expect.toSatisfy(isOdd, 'odd') }) }).toThrowErrorMatchingInlineSnapshot( - `[AssertionError: expected { value: 2 } to deeply equal { value: ToSatisfy }]`, + `[AssertionError: expected { value: 2 } to deeply equal { value: toSatisfy{…} }]`, ) expect(() => { @@ -636,9 +636,16 @@ describe('toSatisfy()', () => { }), ) }).toThrowErrorMatchingInlineSnapshot( - `[AssertionError: expected Error: 2 to match object { message: ToSatisfy }]`, + `[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', () => { From 7d01a65c71036ca30d83d63602ab5565454418e3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 6 Dec 2024 16:16:25 +0900 Subject: [PATCH 4/5] chore: add jsdoc --- packages/expect/src/types.ts | 40 +++++++++++++++--------------------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 5747b68b6ba1..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. * @@ -151,20 +165,9 @@ export interface AsymmetricMatchersContaining { * expect(5.11).toEqual(expect.closeTo(5.12)); // with default precision */ closeTo: (expected: number, precision?: number) => any - - /** - * Matches if the received value satisfies 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).toEqual(expect.toSatisfy(val => val >= 18, 'Age must be at least 18')); - */ - toSatisfy: (matcher: (value: any) => boolean, message?: string) => 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. @@ -656,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` From 705aff6b19da567f2956ed9ba39218a97045f66d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 6 Dec 2024 16:18:01 +0900 Subject: [PATCH 5/5] chore: rename --- packages/expect/src/{jest-extended.ts => custom-matchers.ts} | 2 +- packages/expect/src/index.ts | 2 +- packages/vitest/src/integrations/chai/index.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename packages/expect/src/{jest-extended.ts => custom-matchers.ts} (93%) diff --git a/packages/expect/src/jest-extended.ts b/packages/expect/src/custom-matchers.ts similarity index 93% rename from packages/expect/src/jest-extended.ts rename to packages/expect/src/custom-matchers.ts index 43650379a870..06f7e5b5af2b 100644 --- a/packages/expect/src/jest-extended.ts +++ b/packages/expect/src/custom-matchers.ts @@ -1,7 +1,7 @@ import type { MatchersObject } from './types' // selectively ported from https://github.com/jest-community/jest-extended -export const jestExtendedMatchers: MatchersObject = { +export const customMatchers: MatchersObject = { toSatisfy(actual: unknown, expected: (actual: unknown) => boolean, message?: string) { const { printReceived, printExpected, matcherHint } = this.utils const pass = expected(actual) diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts index 93550fbea447..76b87ad04091 100644 --- a/packages/expect/src/index.ts +++ b/packages/expect/src/index.ts @@ -1,8 +1,8 @@ export * from './constants' +export { customMatchers } from './custom-matchers' export * from './jest-asymmetric-matchers' export { JestChaiExpect } from './jest-expect' export { JestExtend } from './jest-extend' -export { jestExtendedMatchers } from './jest-extended' export { addCustomEqualityTesters } from './jest-matcher-utils' export * from './jest-utils' export { getState, setState } from './state' diff --git a/packages/vitest/src/integrations/chai/index.ts b/packages/vitest/src/integrations/chai/index.ts index 68cbbf03d06e..829963eb7de4 100644 --- a/packages/vitest/src/integrations/chai/index.ts +++ b/packages/vitest/src/integrations/chai/index.ts @@ -5,9 +5,9 @@ import type { TaskPopulated, Test } from '@vitest/runner' import { addCustomEqualityTesters, ASYMMETRIC_MATCHERS_OBJECT, + customMatchers, getState, GLOBAL_EXPECT, - jestExtendedMatchers, setState, } from '@vitest/expect' import { getCurrentTest } from '@vitest/runner' @@ -110,7 +110,7 @@ export function createExpect(test?: TaskPopulated) { chai.util.addMethod(expect, 'assertions', assertions) chai.util.addMethod(expect, 'hasAssertions', hasAssertions) - expect.extend(jestExtendedMatchers) + expect.extend(customMatchers) return expect }