From 3d742b2b525107a1714986220fcb5f63e4cf93d8 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 20 Dec 2024 08:55:34 +0100 Subject: [PATCH] feat(expect): add `toBeOneOf` matcher (#6974) Co-authored-by: Hiroshi Ogawa --- docs/api/expect.md | 36 +++ packages/expect/src/custom-matchers.ts | 37 +++ packages/expect/src/jest-extend.ts | 2 +- packages/expect/src/types.ts | 10 + .../__snapshots__/jest-expect.test.ts.snap | 238 +++++++++++++++--- test/core/test/jest-expect.test.ts | 52 +++- 6 files changed, 340 insertions(+), 35 deletions(-) diff --git a/docs/api/expect.md b/docs/api/expect.md index 94ee348ecace..cb87254bf87f 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -309,6 +309,42 @@ test('getApplesCount has some unusual side effects...', () => { }) ``` +## toBeOneOf + +- **Type:** `(sample: Array) => any` + +`toBeOneOf` asserts if a value matches any of the values in the provided array. + +```ts +import { expect, test } from 'vitest' + +test('fruit is one of the allowed values', () => { + expect(fruit).toBeOneOf(['apple', 'banana', 'orange']) +}) +``` + +The asymmetric matcher is particularly useful when testing optional properties that could be either `null` or `undefined`: + +```ts +test('optional properties can be null or undefined', () => { + const user = { + firstName: 'John', + middleName: undefined, + lastName: 'Doe' + } + + expect(user).toEqual({ + firstName: expect.any(String), + middleName: expect.toBeOneOf([expect.any(String), undefined]), + lastName: expect.any(String), + }) +}) +``` + +:::tip +You can use `expect.not` with this matcher to ensure a value does NOT match any of the provided options. +::: + ## toBeTypeOf - **Type:** `(c: 'bigint' | 'boolean' | 'function' | 'number' | 'object' | 'string' | 'symbol' | 'undefined') => Awaitable` diff --git a/packages/expect/src/custom-matchers.ts b/packages/expect/src/custom-matchers.ts index 06f7e5b5af2b..18ede7c46a78 100644 --- a/packages/expect/src/custom-matchers.ts +++ b/packages/expect/src/custom-matchers.ts @@ -22,6 +22,43 @@ ${matcherHint('.toSatisfy', 'received', '')} Expected value to satisfy: ${message || printExpected(expected)} +Received: +${printReceived(actual)}`, + } + }, + + toBeOneOf(actual: unknown, expected: Array) { + const { equals, customTesters } = this + const { printReceived, printExpected, matcherHint } = this.utils + + if (!Array.isArray(expected)) { + throw new TypeError( + `You must provide an array to ${matcherHint('.toBeOneOf')}, not '${typeof expected}'.`, + ) + } + + const pass = expected.length === 0 + || expected.some(item => + equals(item, actual, customTesters), + ) + + return { + pass, + message: () => + pass + ? `\ +${matcherHint('.not.toBeOneOf', 'received', '')} + +Expected value to not be one of: +${printExpected(expected)} +Received: +${printReceived(actual)}` + : `\ +${matcherHint('.toBeOneOf', 'received', '')} + +Expected value to be one of: +${printExpected(expected)} + Received: ${printReceived(actual)}`, } diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index cf75a4c45a01..3b7df473d600 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -132,7 +132,7 @@ function JestExtendPlugin( } toAsymmetricMatcher() { - return `${this.toString()}<${this.sample.map(String).join(', ')}>` + return `${this.toString()}<${this.sample.map(item => stringify(item)).join(', ')}>` } } diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index a26a448b242e..963273382a2a 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -118,6 +118,16 @@ interface CustomMatcher { * expect(age).toEqual(expect.toSatisfy(val => val >= 18, 'Age must be at least 18')); */ toSatisfy: (matcher: (value: any) => boolean, message?: string) => any + + /** + * Matches if the received value is one of the values in the expected array. + * + * @example + * expect(1).toBeOneOf([1, 2, 3]) + * expect('foo').toBeOneOf([expect.any(String)]) + * expect({ a: 1 }).toEqual({ a: expect.toBeOneOf(['1', '2', '3']) }) + */ + toBeOneOf: (sample: Array) => any } export interface AsymmetricMatchersContaining extends CustomMatcher { diff --git a/test/core/test/__snapshots__/jest-expect.test.ts.snap b/test/core/test/__snapshots__/jest-expect.test.ts.snap index 2921503d6ccd..e3a8785eb466 100644 --- a/test/core/test/__snapshots__/jest-expect.test.ts.snap +++ b/test/core/test/__snapshots__/jest-expect.test.ts.snap @@ -60,12 +60,12 @@ exports[`asymmetric matcher error 5`] = ` { "actual": "hello", "diff": "- Expected: -stringContainingCustom +stringContainingCustom<"xx"> + Received: "hello"", - "expected": "stringContainingCustom", - "message": "expected 'hello' to deeply equal stringContainingCustom", + "expected": "stringContainingCustom<"xx">", + "message": "expected 'hello' to deeply equal stringContainingCustom<"xx">", } `; @@ -73,12 +73,12 @@ exports[`asymmetric matcher error 6`] = ` { "actual": "hello", "diff": "- Expected: -not.stringContainingCustom +not.stringContainingCustom<"ll"> + Received: "hello"", - "expected": "not.stringContainingCustom", - "message": "expected 'hello' to deeply equal not.stringContainingCustom", + "expected": "not.stringContainingCustom<"ll">", + "message": "expected 'hello' to deeply equal not.stringContainingCustom<"ll">", } `; @@ -91,13 +91,13 @@ exports[`asymmetric matcher error 7`] = ` + Received { -- "foo": stringContainingCustom, +- "foo": stringContainingCustom<"xx">, + "foo": "hello", }", "expected": "Object { - "foo": stringContainingCustom, + "foo": stringContainingCustom<"xx">, }", - "message": "expected { foo: 'hello' } to deeply equal { foo: stringContainingCustom }", + "message": "expected { foo: 'hello' } to deeply equal { foo: stringContainingCustom<"xx"> }", } `; @@ -110,13 +110,13 @@ exports[`asymmetric matcher error 8`] = ` + Received { -- "foo": not.stringContainingCustom, +- "foo": not.stringContainingCustom<"ll">, + "foo": "hello", }", "expected": "Object { - "foo": not.stringContainingCustom, + "foo": not.stringContainingCustom<"ll">, }", - "message": "expected { foo: 'hello' } to deeply equal { foo: not.stringContainingCustom }", + "message": "expected { foo: 'hello' } to deeply equal { foo: not.stringContainingCustom{…} }", } `; @@ -142,12 +142,16 @@ exports[`asymmetric matcher error 11`] = ` { "actual": "hello", "diff": "- Expected: -testComplexMatcher<[object Object]> +testComplexMatcher + Received: "hello"", - "expected": "testComplexMatcher<[object Object]>", - "message": "expected 'hello' to deeply equal testComplexMatcher<[object Object]>", + "expected": "testComplexMatcher", + "message": "expected 'hello' to deeply equal testComplexMatcher{…}", } `; @@ -220,6 +224,82 @@ exports[`asymmetric matcher error 15`] = ` `; exports[`asymmetric matcher error 16`] = ` +{ + "actual": "foo", + "diff": "- Expected: +toBeOneOf + ++ Received: +"foo"", + "expected": "toBeOneOf", + "message": "expected 'foo' to deeply equal toBeOneOf", +} +`; + +exports[`asymmetric matcher error 17`] = ` +{ + "actual": "0", + "diff": "- Expected: +toBeOneOf, + null, + undefined, +]> + ++ Received: +0", + "expected": "toBeOneOf, + null, + undefined, +]>", + "message": "expected +0 to deeply equal toBeOneOf{…}", +} +`; + +exports[`asymmetric matcher error 18`] = ` +{ + "actual": "Object { + "k": "v", + "k2": "v2", +}", + "diff": "- Expected: +toBeOneOf + ++ Received: +{ + "k": "v", + "k2": "v2", +}", + "expected": "toBeOneOf", + "message": "expected { k: 'v', k2: 'v2' } to deeply equal toBeOneOf{…}", +} +`; + +exports[`asymmetric matcher error 19`] = ` { "actual": "hello", "diff": undefined, @@ -228,7 +308,7 @@ exports[`asymmetric matcher error 16`] = ` } `; -exports[`asymmetric matcher error 17`] = ` +exports[`asymmetric matcher error 20`] = ` { "actual": "hello", "diff": undefined, @@ -237,20 +317,20 @@ exports[`asymmetric matcher error 17`] = ` } `; -exports[`asymmetric matcher error 18`] = ` +exports[`asymmetric matcher error 21`] = ` { "actual": "hello", "diff": "- Expected: -stringContainingCustom +stringContainingCustom<"xx"> + Received: "hello"", - "expected": "stringContainingCustom", + "expected": "stringContainingCustom<"xx">", "message": "expected error to match asymmetric matcher", } `; -exports[`asymmetric matcher error 19`] = ` +exports[`asymmetric matcher error 22`] = ` { "actual": "hello", "diff": undefined, @@ -259,20 +339,20 @@ exports[`asymmetric matcher error 19`] = ` } `; -exports[`asymmetric matcher error 20`] = ` +exports[`asymmetric matcher error 23`] = ` { "actual": "hello", "diff": "- Expected: -stringContainingCustom +stringContainingCustom<"ll"> + Received: "hello"", - "expected": "stringContainingCustom", + "expected": "stringContainingCustom<"ll">", "message": "expected error not to match asymmetric matcher", } `; -exports[`asymmetric matcher error 21`] = ` +exports[`asymmetric matcher error 24`] = ` { "actual": "[Error: hello]", "diff": "- Expected: @@ -287,22 +367,22 @@ Error { } `; -exports[`asymmetric matcher error 22`] = ` +exports[`asymmetric matcher error 25`] = ` { "actual": "[Error: hello]", "diff": "- Expected: -stringContainingCustom +stringContainingCustom<"ll"> + Received: Error { "message": "hello", }", - "expected": "stringContainingCustom", + "expected": "stringContainingCustom<"ll">", "message": "expected error to match asymmetric matcher", } `; -exports[`asymmetric matcher error 23`] = ` +exports[`asymmetric matcher error 26`] = ` { "actual": "[Error: hello]", "diff": "- Expected: @@ -626,6 +706,98 @@ exports[`error equality 13`] = ` } `; +exports[`toBeOneOf() > error message 1`] = ` +{ + "actual": "undefined", + "diff": undefined, + "expected": "undefined", + "message": "expect(received).toBeOneOf() + +Expected value to be one of: +Array [ + 0, + 1, + 2, +] + +Received: +3", +} +`; + +exports[`toBeOneOf() > error message 2`] = ` +{ + "actual": "undefined", + "diff": undefined, + "expected": "undefined", + "message": "expect(received).toBeOneOf() + +Expected value to be one of: +Array [ + Any, +] + +Received: +3", +} +`; + +exports[`toBeOneOf() > error message 3`] = ` +{ + "actual": "Object { + "a": 0, +}", + "diff": "- Expected: +toBeOneOf + ++ Received: +{ + "a": 0, +}", + "expected": "toBeOneOf", + "message": "expected { a: +0 } to deeply equal toBeOneOf{…}", +} +`; + +exports[`toBeOneOf() > error message 4`] = ` +{ + "actual": "Object { + "name": "mango", +}", + "diff": "- Expected ++ Received + + { +- "name": toBeOneOf, ++ "name": "mango", + }", + "expected": "Object { + "name": toBeOneOf, +}", + "message": "expected { name: 'mango' } to deeply equal { name: toBeOneOf{…} }", +} +`; + exports[`toHaveBeenNthCalledWith error 1`] = ` { "actual": "Array [ @@ -728,13 +900,13 @@ exports[`toSatisfy() > error message 3`] = ` + Received { -- "value": toSatisfy<(value) => value % 2 !== 0>, +- "value": toSatisfy<[Function isOdd]>, + "value": 2, }", "expected": "Object { - "value": toSatisfy<(value) => value % 2 !== 0>, + "value": toSatisfy<[Function isOdd]>, }", - "message": "expected { value: 2 } to deeply equal { value: toSatisfy{…} }", + "message": "expected { value: 2 } to deeply equal { value: toSatisfy<[Function isOdd]> }", } `; @@ -747,11 +919,11 @@ exports[`toSatisfy() > error message 4`] = ` + Received { -- "value": toSatisfy<(value) => value % 2 !== 0, ODD>, +- "value": toSatisfy<[Function isOdd], "ODD">, + "value": 2, }", "expected": "Object { - "value": toSatisfy<(value) => value % 2 !== 0, ODD>, + "value": toSatisfy<[Function isOdd], "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 fbdeeef43a17..d1f2a635d3a2 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -585,6 +585,53 @@ describe('toBeTypeOf()', () => { }) }) +describe('toBeOneOf()', () => { + it('pass with assertion', () => { + expect(0).toBeOneOf([0, 1, 2]) + expect(0).toBeOneOf([expect.any(Number)]) + expect('apple').toBeOneOf(['apple', 'banana', 'orange']) + expect('apple').toBeOneOf([expect.any(String)]) + expect(true).toBeOneOf([true, false]) + expect(true).toBeOneOf([expect.any(Boolean)]) + expect(null).toBeOneOf([expect.any(Object)]) + expect(undefined).toBeOneOf([undefined]) + }) + + it('pass with negotiation', () => { + expect(3).not.toBeOneOf([0, 1, 2]) + expect(3).not.toBeOneOf([expect.any(String)]) + expect('mango').not.toBeOneOf(['apple', 'banana', 'orange']) + expect('mango').not.toBeOneOf([expect.any(Number)]) + expect(null).not.toBeOneOf([undefined]) + }) + + it.fails('fail with missing negotiation', () => { + expect(3).toBeOneOf([0, 1, 2]) + expect(3).toBeOneOf([expect.any(String)]) + expect('mango').toBeOneOf(['apple', 'banana', 'orange']) + expect('mango').toBeOneOf([expect.any(Number)]) + expect(null).toBeOneOf([undefined]) + }) + + it('asymmetric matcher', () => { + expect({ a: 0 }).toEqual(expect.toBeOneOf([expect.objectContaining({ a: 0 }), null])) + expect({ + name: 'apple', + count: 1, + }).toEqual({ + name: expect.toBeOneOf(['apple', 'banana', 'orange']), + count: expect.toBeOneOf([expect.any(Number)]), + }) + }) + + it('error message', () => { + snapshotError(() => expect(3).toBeOneOf([0, 1, 2])) + snapshotError(() => expect(3).toBeOneOf([expect.any(String)])) + snapshotError(() => expect({ a: 0 }).toEqual(expect.toBeOneOf([expect.objectContaining({ b: 0 }), null, undefined]))) + snapshotError(() => expect({ name: 'mango' }).toEqual({ name: expect.toBeOneOf(['apple', 'banana', 'orange']) })) + }) +}) + describe('toSatisfy()', () => { const isOdd = (value: number) => value % 2 !== 0 @@ -636,7 +683,7 @@ describe('toSatisfy()', () => { }), ) }).toThrowErrorMatchingInlineSnapshot( - `[AssertionError: expected Error: 2 to match object { message: toSatisfy{…} }]`, + `[AssertionError: expected Error: 2 to match object { Object (message) }]`, ) }) @@ -1578,6 +1625,9 @@ it('asymmetric matcher error', () => { snapshotError(() => expect(['a', 'b']).toEqual(expect.arrayContaining(['a', 'c']))) snapshotError(() => expect('hello').toEqual(expect.stringMatching(/xx/))) snapshotError(() => expect(2.5).toEqual(expect.closeTo(2, 1))) + snapshotError(() => expect('foo').toEqual(expect.toBeOneOf(['bar', 'baz']))) + snapshotError(() => expect(0).toEqual(expect.toBeOneOf([expect.any(String), null, undefined]))) + snapshotError(() => expect({ k: 'v', k2: 'v2' }).toEqual(expect.toBeOneOf([expect.objectContaining({ k: 'v', k3: 'v3' }), null, undefined]))) // simple truncation if pretty-format is too long snapshotError(() => expect('hello').toEqual(expect.stringContaining('a'.repeat(40))))