From ba117615983bd64580a8c2162d19d88f78036479 Mon Sep 17 00:00:00 2001 From: zirkelc Date: Wed, 27 Nov 2024 12:42:42 +0100 Subject: [PATCH 1/9] feat(expect): add expect.oneOf asymmetric matcher --- .../expect/src/jest-asymmetric-matchers.ts | 41 +++++++++++++++++++ packages/expect/src/types.ts | 10 +++++ test/core/test/jest-expect.test.ts | 13 ++++++ 3 files changed, 64 insertions(+) diff --git a/packages/expect/src/jest-asymmetric-matchers.ts b/packages/expect/src/jest-asymmetric-matchers.ts index 7fd7fee5bf56..453ffd89eed0 100644 --- a/packages/expect/src/jest-asymmetric-matchers.ts +++ b/packages/expect/src/jest-asymmetric-matchers.ts @@ -377,11 +377,50 @@ class CloseTo extends AsymmetricMatcher { } } + +class OneOf extends AsymmetricMatcher> { + constructor(sample: Array, inverse = false) { + super(sample, inverse) + } + + asymmetricMatch(other: unknown) { + if (!Array.isArray(this.sample)) { + throw new TypeError( + `You must provide an array to ${this.toString()}, not '${typeof this + .sample}'.`, + ) + } + + const matcherContext = this.getMatcherContext() + const result + = this.sample.length === 0 + || this.sample.some(item => + equals(item, other, matcherContext.customTesters), + ) + + return this.inverse ? !result : result + } + + toString() { + return `${this.inverse ? 'Not' : ''}OneOf` + } + + getExpectedType() { + return this.sample.map(item => stringify(item)).join(' | ') + } + + toAsymmetricMatcher() { + return `${this.toString()}<${this.getExpectedType()}>` + } +} + export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { utils.addMethod(chai.expect, 'anything', () => new Anything()) utils.addMethod(chai.expect, 'any', (expected: unknown) => new Any(expected)) + utils.addMethod(chai.expect, 'oneOf', (expected: Array) => new OneOf(expected)) + utils.addMethod( chai.expect, 'stringContaining', @@ -423,5 +462,7 @@ export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { new StringMatching(expected, true), closeTo: (expected: any, precision?: number) => new CloseTo(expected, precision, true), + oneOf: (expected: Array) => + new OneOf(expected, true), } } diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 5e1a76a897bd..8fdba3e93051 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -151,6 +151,16 @@ 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 is one of the values in the expected array. + * + * @example + * expect(1).toEqual(expect.oneOf([1, 2, 3])) + * expect('foo').toEqual(expect.oneOf([expect.any(String), undefined])) + * expect({ a: 1 }).toEqual(expect.oneOf([expect.objectContaining({ a: '1' }), null])) + */ + oneOf: (sample: Array) => any } export interface JestAssertion extends jest.Matchers { diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index 74ed197ddf07..f9d0c4b0928f 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -194,6 +194,14 @@ describe('jest-expect', () => { sum: expect.closeTo(0.4), }) }).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected { sum: 0.30000000000000004 } to deeply equal { sum: NumberCloseTo 0.4 (2 digits) }]`) + + expect(0).toEqual(expect.oneOf([0, 1, 2])) + expect(0).toEqual(expect.oneOf([expect.any(Number), undefined])) + expect('string').toEqual(expect.oneOf([expect.any(String), undefined])) + expect({ a: 0 }).toEqual(expect.oneOf([expect.objectContaining({ a: 0 }), null])) + expect(null).toEqual(expect.oneOf([expect.any(Object)])) + expect(null).toEqual(expect.oneOf([null])) + expect(undefined).toEqual(expect.oneOf([undefined])) }) it('asymmetric matchers negate', () => { @@ -201,6 +209,11 @@ describe('jest-expect', () => { expect('bar').toEqual(expect.not.stringMatching(/zoo/)) expect({ bar: 'zoo' }).toEqual(expect.not.objectContaining({ zoo: 'bar' })) expect(['Bob', 'Eve']).toEqual(expect.not.arrayContaining(['Steve'])) + expect(0).toEqual(expect.not.oneOf([1, 2, 3])) + expect('foo').toEqual(expect.not.oneOf([expect.any(Number), undefined])) + expect({ a: 0 }).toEqual(expect.not.oneOf([expect.objectContaining({ b: 0 }), null])) + expect(null).toEqual(expect.not.oneOf([expect.any(String)])) + expect(undefined).toEqual(expect.not.oneOf([expect.any(Object)])) }) it('expect.extend', async () => { From 371e9c81e90dbf1a0e4ca3171332d2358f20522e Mon Sep 17 00:00:00 2001 From: zirkelc Date: Wed, 27 Nov 2024 13:23:58 +0100 Subject: [PATCH 2/9] docs(expect): add docs for expect.oneOf --- docs/api/expect.md | 48 +++++++++++++++++++++++++++++- test/core/test/jest-expect.test.ts | 7 +++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/docs/api/expect.md b/docs/api/expect.md index c4bbbd2e016c..39004a5f7db1 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -1075,7 +1075,7 @@ test('spy function returns bananas on a last call', () => { - **Type**: `(time: number, returnValue: any) => Awaitable` -You can call this assertion to check if a function has successfully returned a value with certain parameters on a certain call. Requires a spy function to be passed to `expect`. +You can call this assertion to check if a function has successfully returned a value with certain parameters on a specific invokation. Requires a spy function to be passed to `expect`. ```ts import { expect, test, vi } from 'vitest' @@ -1427,6 +1427,52 @@ test('"id" is a number', () => { }) ``` +## expect.oneOf + +- **Type:** `(sample: Array) => any` + +When used with an equality check, this asymmetric matcher will return `true` if the value matches any of the values in the provided array. + +```ts +import { expect, test } from 'vitest' + +test('fruit is one of the allowed types', () => { + const fruit = { + name: 'apple', + count: 1 + } + + expect(fruit).toEqual({ + name: expect.oneOf(['apple', 'banana', 'orange']), + count: 1 + }) +}) +``` + +This 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 = { + id: 1, + firstName: 'John', + middleName: undefined, + lastName: 'Doe' + } + + expect(user).toEqual({ + id: expect.any(Number), + firstName: expect.any(String), + middleName: expect.oneOf([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. +::: + ## expect.closeTo {#expect-closeto} - **Type:** `(expected: any, precision?: number) => any` diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index f9d0c4b0928f..dd34c14f1d04 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -199,6 +199,13 @@ describe('jest-expect', () => { expect(0).toEqual(expect.oneOf([expect.any(Number), undefined])) expect('string').toEqual(expect.oneOf([expect.any(String), undefined])) expect({ a: 0 }).toEqual(expect.oneOf([expect.objectContaining({ a: 0 }), null])) + expect({ + name: 'apple', + count: 1 + }).toEqual({ + name: expect.oneOf(['apple', 'banana', 'orange']), + count: 1, + }) expect(null).toEqual(expect.oneOf([expect.any(Object)])) expect(null).toEqual(expect.oneOf([null])) expect(undefined).toEqual(expect.oneOf([undefined])) From 539608c582d350cf9025da8f3754a274ac1efe83 Mon Sep 17 00:00:00 2001 From: zirkelc Date: Thu, 28 Nov 2024 08:13:53 +0100 Subject: [PATCH 3/9] test(expect): snapshot test for expect.oneOf --- .../__snapshots__/jest-expect.test.ts.snap | 65 +++++++++++++++++-- test/core/test/jest-expect.test.ts | 4 ++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/test/core/test/__snapshots__/jest-expect.test.ts.snap b/test/core/test/__snapshots__/jest-expect.test.ts.snap index ced7d75d7410..01d51c08e11e 100644 --- a/test/core/test/__snapshots__/jest-expect.test.ts.snap +++ b/test/core/test/__snapshots__/jest-expect.test.ts.snap @@ -220,6 +220,57 @@ exports[`asymmetric matcher error 15`] = ` `; exports[`asymmetric matcher error 16`] = ` +{ + "actual": "foo", + "diff": "- Expected: +OneOf<"bar" | "baz"> + ++ Received: +"foo"", + "expected": "OneOf<"bar" | "baz">", + "message": "expected 'foo' to deeply equal OneOf<"bar" | "baz">", +} +`; + +exports[`asymmetric matcher error 17`] = ` +{ + "actual": "0", + "diff": "- Expected: +OneOf | null | undefined> + ++ Received: +0", + "expected": "OneOf | null | undefined>", + "message": "expected +0 to deeply equal OneOf | null | undefined>", +} +`; + +exports[`asymmetric matcher error 18`] = ` +{ + "actual": "Object { + "k": "v", + "k2": "v2", +}", + "diff": "- Expected: +OneOf + ++ Received: +Object { + "k": "v", + "k2": "v2", +}", + "expected": "OneOf", + "message": "expected { k: 'v', k2: 'v2' } to deeply equal OneOf{…}", +} +`; + +exports[`asymmetric matcher error 19`] = ` { "actual": "hello", "diff": undefined, @@ -228,7 +279,7 @@ exports[`asymmetric matcher error 16`] = ` } `; -exports[`asymmetric matcher error 17`] = ` +exports[`asymmetric matcher error 20`] = ` { "actual": "hello", "diff": undefined, @@ -237,7 +288,7 @@ exports[`asymmetric matcher error 17`] = ` } `; -exports[`asymmetric matcher error 18`] = ` +exports[`asymmetric matcher error 21`] = ` { "actual": "hello", "diff": "- Expected: @@ -250,7 +301,7 @@ stringContainingCustom } `; -exports[`asymmetric matcher error 19`] = ` +exports[`asymmetric matcher error 22`] = ` { "actual": "hello", "diff": undefined, @@ -259,7 +310,7 @@ exports[`asymmetric matcher error 19`] = ` } `; -exports[`asymmetric matcher error 20`] = ` +exports[`asymmetric matcher error 23`] = ` { "actual": "hello", "diff": "- Expected: @@ -272,7 +323,7 @@ stringContainingCustom } `; -exports[`asymmetric matcher error 21`] = ` +exports[`asymmetric matcher error 24`] = ` { "actual": "[Error: hello]", "diff": "- Expected: @@ -285,7 +336,7 @@ StringContaining "ll" } `; -exports[`asymmetric matcher error 22`] = ` +exports[`asymmetric matcher error 25`] = ` { "actual": "[Error: hello]", "diff": "- Expected: @@ -298,7 +349,7 @@ stringContainingCustom } `; -exports[`asymmetric matcher error 23`] = ` +exports[`asymmetric matcher error 26`] = ` { "actual": "[Error: hello]", "diff": "- Expected: diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index dd34c14f1d04..de60a553d80c 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -4,6 +4,7 @@ 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 { snapshot } from 'node:test' class TestError extends Error {} @@ -1558,6 +1559,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.oneOf(['bar', 'baz']))) + snapshotError(() => expect(0).toEqual(expect.oneOf([expect.any(String), null, undefined]))) + snapshotError(() => expect({ k: 'v', k2: 'v2' }).toEqual(expect.oneOf([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)))) From fd20607d9d0dc8307b151a68c8d6fd4b7aa306dc Mon Sep 17 00:00:00 2001 From: zirkelc Date: Thu, 28 Nov 2024 08:15:16 +0100 Subject: [PATCH 4/9] style: lint --- packages/expect/src/jest-asymmetric-matchers.ts | 1 - test/core/test/jest-expect.test.ts | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/expect/src/jest-asymmetric-matchers.ts b/packages/expect/src/jest-asymmetric-matchers.ts index 453ffd89eed0..3875078c0be9 100644 --- a/packages/expect/src/jest-asymmetric-matchers.ts +++ b/packages/expect/src/jest-asymmetric-matchers.ts @@ -377,7 +377,6 @@ class CloseTo extends AsymmetricMatcher { } } - class OneOf extends AsymmetricMatcher> { constructor(sample: Array, inverse = false) { super(sample, inverse) diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index de60a553d80c..8a127acd784f 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -4,7 +4,6 @@ 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 { snapshot } from 'node:test' class TestError extends Error {} @@ -202,7 +201,7 @@ describe('jest-expect', () => { expect({ a: 0 }).toEqual(expect.oneOf([expect.objectContaining({ a: 0 }), null])) expect({ name: 'apple', - count: 1 + count: 1, }).toEqual({ name: expect.oneOf(['apple', 'banana', 'orange']), count: 1, From 2a4b5e6363b2739a684ffab399cc575d2d7c3fdc Mon Sep 17 00:00:00 2001 From: zirkelc Date: Tue, 10 Dec 2024 18:16:44 +0100 Subject: [PATCH 5/9] feat(expect): add `toBeOneOf` custom matcher --- packages/expect/src/custom-matchers.ts | 37 +++++++ .../expect/src/jest-asymmetric-matchers.ts | 40 -------- packages/expect/src/types.ts | 20 ++-- .../__snapshots__/jest-expect.test.ts.snap | 98 ++++++++++++++++--- test/core/test/jest-expect.test.ts | 75 +++++++++----- 5 files changed, 181 insertions(+), 89 deletions(-) diff --git a/packages/expect/src/custom-matchers.ts b/packages/expect/src/custom-matchers.ts index 06f7e5b5af2b..86f073024868 100644 --- a/packages/expect/src/custom-matchers.ts +++ b/packages/expect/src/custom-matchers.ts @@ -26,4 +26,41 @@ 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-asymmetric-matchers.ts b/packages/expect/src/jest-asymmetric-matchers.ts index 3875078c0be9..7fd7fee5bf56 100644 --- a/packages/expect/src/jest-asymmetric-matchers.ts +++ b/packages/expect/src/jest-asymmetric-matchers.ts @@ -377,49 +377,11 @@ class CloseTo extends AsymmetricMatcher { } } -class OneOf extends AsymmetricMatcher> { - constructor(sample: Array, inverse = false) { - super(sample, inverse) - } - - asymmetricMatch(other: unknown) { - if (!Array.isArray(this.sample)) { - throw new TypeError( - `You must provide an array to ${this.toString()}, not '${typeof this - .sample}'.`, - ) - } - - const matcherContext = this.getMatcherContext() - const result - = this.sample.length === 0 - || this.sample.some(item => - equals(item, other, matcherContext.customTesters), - ) - - return this.inverse ? !result : result - } - - toString() { - return `${this.inverse ? 'Not' : ''}OneOf` - } - - getExpectedType() { - return this.sample.map(item => stringify(item)).join(' | ') - } - - toAsymmetricMatcher() { - return `${this.toString()}<${this.getExpectedType()}>` - } -} - export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { utils.addMethod(chai.expect, 'anything', () => new Anything()) utils.addMethod(chai.expect, 'any', (expected: unknown) => new Any(expected)) - utils.addMethod(chai.expect, 'oneOf', (expected: Array) => new OneOf(expected)) - utils.addMethod( chai.expect, 'stringContaining', @@ -461,7 +423,5 @@ export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { new StringMatching(expected, true), closeTo: (expected: any, precision?: number) => new CloseTo(expected, precision, true), - oneOf: (expected: Array) => - new OneOf(expected, true), } } diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 3d3a06fbd7f8..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 { @@ -165,16 +175,6 @@ export interface AsymmetricMatchersContaining extends CustomMatcher { * expect(5.11).toEqual(expect.closeTo(5.12)); // with default precision */ closeTo: (expected: number, precision?: number) => any - - /** - * Matches if the received value is one of the values in the expected array. - * - * @example - * expect(1).toEqual(expect.oneOf([1, 2, 3])) - * expect('foo').toEqual(expect.oneOf([expect.any(String), undefined])) - * expect({ a: 1 }).toEqual(expect.oneOf([expect.objectContaining({ a: '1' }), null])) - */ - oneOf: (sample: Array) => any } export interface JestAssertion extends jest.Matchers, CustomMatcher { diff --git a/test/core/test/__snapshots__/jest-expect.test.ts.snap b/test/core/test/__snapshots__/jest-expect.test.ts.snap index 8ad2224a5fcd..80e2f2e4f5d0 100644 --- a/test/core/test/__snapshots__/jest-expect.test.ts.snap +++ b/test/core/test/__snapshots__/jest-expect.test.ts.snap @@ -223,12 +223,12 @@ exports[`asymmetric matcher error 16`] = ` { "actual": "foo", "diff": "- Expected: -OneOf<"bar" | "baz"> +toBeOneOf + Received: "foo"", - "expected": "OneOf<"bar" | "baz">", - "message": "expected 'foo' to deeply equal OneOf<"bar" | "baz">", + "expected": "toBeOneOf", + "message": "expected 'foo' to deeply equal toBeOneOf", } `; @@ -236,12 +236,12 @@ exports[`asymmetric matcher error 17`] = ` { "actual": "0", "diff": "- Expected: -OneOf | null | undefined> +toBeOneOf + Received: 0", - "expected": "OneOf | null | undefined>", - "message": "expected +0 to deeply equal OneOf | null | undefined>", + "expected": "toBeOneOf", + "message": "expected +0 to deeply equal toBeOneOf", } `; @@ -252,21 +252,15 @@ exports[`asymmetric matcher error 18`] = ` "k2": "v2", }", "diff": "- Expected: -OneOf +toBeOneOf + Received: -Object { +{ "k": "v", "k2": "v2", }", - "expected": "OneOf", - "message": "expected { k: 'v', k2: 'v2' } to deeply equal OneOf{…}", + "expected": "toBeOneOf", + "message": "expected { k: 'v', k2: 'v2' } to deeply equal toBeOneOf", } `; @@ -677,6 +671,78 @@ 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 { Object (name) }", +} +`; + exports[`toHaveBeenNthCalledWith error 1`] = ` { "actual": "Array [ diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index 52dffa14bac5..e1590d548136 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -4,6 +4,7 @@ import { stripVTControlCharacters } from 'node:util' import { generateToBeMessage } from '@vitest/expect' import { processError } from '@vitest/utils/error' import { assert, beforeAll, describe, expect, it, vi } from 'vitest' +import exp from 'node:constants' class TestError extends Error {} @@ -194,21 +195,6 @@ describe('jest-expect', () => { sum: expect.closeTo(0.4), }) }).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected { sum: 0.30000000000000004 } to deeply equal { sum: NumberCloseTo 0.4 (2 digits) }]`) - - expect(0).toEqual(expect.oneOf([0, 1, 2])) - expect(0).toEqual(expect.oneOf([expect.any(Number), undefined])) - expect('string').toEqual(expect.oneOf([expect.any(String), undefined])) - expect({ a: 0 }).toEqual(expect.oneOf([expect.objectContaining({ a: 0 }), null])) - expect({ - name: 'apple', - count: 1, - }).toEqual({ - name: expect.oneOf(['apple', 'banana', 'orange']), - count: 1, - }) - expect(null).toEqual(expect.oneOf([expect.any(Object)])) - expect(null).toEqual(expect.oneOf([null])) - expect(undefined).toEqual(expect.oneOf([undefined])) }) it('asymmetric matchers negate', () => { @@ -216,11 +202,6 @@ describe('jest-expect', () => { expect('bar').toEqual(expect.not.stringMatching(/zoo/)) expect({ bar: 'zoo' }).toEqual(expect.not.objectContaining({ zoo: 'bar' })) expect(['Bob', 'Eve']).toEqual(expect.not.arrayContaining(['Steve'])) - expect(0).toEqual(expect.not.oneOf([1, 2, 3])) - expect('foo').toEqual(expect.not.oneOf([expect.any(Number), undefined])) - expect({ a: 0 }).toEqual(expect.not.oneOf([expect.objectContaining({ b: 0 }), null])) - expect(null).toEqual(expect.not.oneOf([expect.any(String)])) - expect(undefined).toEqual(expect.not.oneOf([expect.any(Object)])) }) it('expect.extend', async () => { @@ -605,6 +586,54 @@ 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]))) + snapshotError(() => expect({ name: 'mango' }).toEqual({ name: expect.toBeOneOf(['apple', 'banana', 'orange']) })) + }) +}) + + describe('toSatisfy()', () => { const isOdd = (value: number) => value % 2 !== 0 @@ -1598,9 +1627,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.oneOf(['bar', 'baz']))) - snapshotError(() => expect(0).toEqual(expect.oneOf([expect.any(String), null, undefined]))) - snapshotError(() => expect({ k: 'v', k2: 'v2' }).toEqual(expect.oneOf([expect.objectContaining({ k: 'v', k3: 'v3' }), null, undefined]))) + 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)))) From 94ddff8257b5986f88adfdf2a9e52ac0dd9b7a08 Mon Sep 17 00:00:00 2001 From: zirkelc Date: Tue, 10 Dec 2024 18:25:41 +0100 Subject: [PATCH 6/9] docs: toBeOneOf --- docs/api/expect.md | 82 ++++++++++++++++++++-------------------------- 1 file changed, 36 insertions(+), 46 deletions(-) diff --git a/docs/api/expect.md b/docs/api/expect.md index 41ab095dde8d..d72d9395c53d 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` @@ -1445,52 +1481,6 @@ test('"id" is a number', () => { }) ``` -## expect.oneOf - -- **Type:** `(sample: Array) => any` - -When used with an equality check, this asymmetric matcher will return `true` if the value matches any of the values in the provided array. - -```ts -import { expect, test } from 'vitest' - -test('fruit is one of the allowed types', () => { - const fruit = { - name: 'apple', - count: 1 - } - - expect(fruit).toEqual({ - name: expect.oneOf(['apple', 'banana', 'orange']), - count: 1 - }) -}) -``` - -This 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 = { - id: 1, - firstName: 'John', - middleName: undefined, - lastName: 'Doe' - } - - expect(user).toEqual({ - id: expect.any(Number), - firstName: expect.any(String), - middleName: expect.oneOf([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. -::: - ## expect.closeTo {#expect-closeto} - **Type:** `(expected: any, precision?: number) => any` From 8251c73db6400c88b7c93210ebc3e5736dae981c Mon Sep 17 00:00:00 2001 From: zirkelc Date: Tue, 10 Dec 2024 18:31:24 +0100 Subject: [PATCH 7/9] docs: revert --- docs/api/expect.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/expect.md b/docs/api/expect.md index d72d9395c53d..cb87254bf87f 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -1129,7 +1129,7 @@ test('spy function returns bananas on a last call', () => { - **Type**: `(time: number, returnValue: any) => Awaitable` -You can call this assertion to check if a function has successfully returned a value with certain parameters on a specific invokation. Requires a spy function to be passed to `expect`. +You can call this assertion to check if a function has successfully returned a value with certain parameters on a certain call. Requires a spy function to be passed to `expect`. ```ts import { expect, test, vi } from 'vitest' From d9637683c44d897290809e33afe2bc00d79bf305 Mon Sep 17 00:00:00 2001 From: zirkelc Date: Wed, 11 Dec 2024 08:29:50 +0100 Subject: [PATCH 8/9] fix: use stringify instead of String --- packages/expect/src/jest-extend.ts | 2 +- .../__snapshots__/jest-expect.test.ts.snap | 137 ++++++++++++------ test/core/test/jest-expect.test.ts | 4 +- 3 files changed, 99 insertions(+), 44 deletions(-) diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index cf75a4c45a01..371afc4466e6 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/test/core/test/__snapshots__/jest-expect.test.ts.snap b/test/core/test/__snapshots__/jest-expect.test.ts.snap index 80e2f2e4f5d0..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{…}", } `; @@ -223,12 +227,21 @@ exports[`asymmetric matcher error 16`] = ` { "actual": "foo", "diff": "- Expected: -toBeOneOf +toBeOneOf + Received: "foo"", - "expected": "toBeOneOf", - "message": "expected 'foo' to deeply equal toBeOneOf", + "expected": "toBeOneOf", + "message": "expected 'foo' to deeply equal toBeOneOf", } `; @@ -236,12 +249,20 @@ exports[`asymmetric matcher error 17`] = ` { "actual": "0", "diff": "- Expected: -toBeOneOf +toBeOneOf, + null, + undefined, +]> + Received: 0", - "expected": "toBeOneOf", - "message": "expected +0 to deeply equal toBeOneOf", + "expected": "toBeOneOf, + null, + undefined, +]>", + "message": "expected +0 to deeply equal toBeOneOf{…}", } `; @@ -252,15 +273,29 @@ exports[`asymmetric matcher error 18`] = ` "k2": "v2", }", "diff": "- Expected: -toBeOneOf +toBeOneOf + Received: { "k": "v", "k2": "v2", }", - "expected": "toBeOneOf", - "message": "expected { k: 'v', k2: 'v2' } to deeply equal toBeOneOf", + "expected": "toBeOneOf", + "message": "expected { k: 'v', k2: 'v2' } to deeply equal toBeOneOf{…}", } `; @@ -286,11 +321,11 @@ 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", } `; @@ -308,11 +343,11 @@ 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", } `; @@ -336,13 +371,13 @@ 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", } `; @@ -713,14 +748,26 @@ exports[`toBeOneOf() > error message 3`] = ` "a": 0, }", "diff": "- Expected: -toBeOneOf +toBeOneOf + Received: { "a": 0, }", - "expected": "toBeOneOf", - "message": "expected { a: +0 } to deeply equal toBeOneOf", + "expected": "toBeOneOf", + "message": "expected { a: +0 } to deeply equal toBeOneOf{…}", } `; @@ -733,13 +780,21 @@ exports[`toBeOneOf() > error message 4`] = ` + Received { -- "name": toBeOneOf, +- "name": toBeOneOf, + "name": "mango", }", "expected": "Object { - "name": toBeOneOf, + "name": toBeOneOf, }", - "message": "expected { name: 'mango' } to deeply equal { Object (name) }", + "message": "expected { name: 'mango' } to deeply equal { name: toBeOneOf{…} }", } `; @@ -845,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]> }", } `; @@ -864,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 e1590d548136..7883fc6ed1f8 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -628,7 +628,7 @@ describe('toBeOneOf()', () => { 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]))) + snapshotError(() => expect({ a: 0 }).toEqual(expect.toBeOneOf([expect.objectContaining({ b: 0 }), null, undefined]))) snapshotError(() => expect({ name: 'mango' }).toEqual({ name: expect.toBeOneOf(['apple', 'banana', 'orange']) })) }) }) @@ -685,7 +685,7 @@ describe('toSatisfy()', () => { }), ) }).toThrowErrorMatchingInlineSnapshot( - `[AssertionError: expected Error: 2 to match object { message: toSatisfy{…} }]`, + `[AssertionError: expected Error: 2 to match object { Object (message) }]`, ) }) From ef967e6d25368a0110782449d28641e7f10dbb98 Mon Sep 17 00:00:00 2001 From: zirkelc Date: Wed, 11 Dec 2024 08:36:58 +0100 Subject: [PATCH 9/9] style: lint --- packages/expect/src/custom-matchers.ts | 4 ++-- packages/expect/src/jest-extend.ts | 2 +- test/core/test/jest-expect.test.ts | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/expect/src/custom-matchers.ts b/packages/expect/src/custom-matchers.ts index 86f073024868..18ede7c46a78 100644 --- a/packages/expect/src/custom-matchers.ts +++ b/packages/expect/src/custom-matchers.ts @@ -29,7 +29,7 @@ ${printReceived(actual)}`, toBeOneOf(actual: unknown, expected: Array) { const { equals, customTesters } = this - const { printReceived, printExpected, matcherHint, } = this.utils + const { printReceived, printExpected, matcherHint } = this.utils if (!Array.isArray(expected)) { throw new TypeError( @@ -62,5 +62,5 @@ ${printExpected(expected)} Received: ${printReceived(actual)}`, } - } + }, } diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index 371afc4466e6..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((item) => stringify(item)).join(', ')}>` + return `${this.toString()}<${this.sample.map(item => stringify(item)).join(', ')}>` } } diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index 7883fc6ed1f8..d1f2a635d3a2 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -4,7 +4,6 @@ import { stripVTControlCharacters } from 'node:util' import { generateToBeMessage } from '@vitest/expect' import { processError } from '@vitest/utils/error' import { assert, beforeAll, describe, expect, it, vi } from 'vitest' -import exp from 'node:constants' class TestError extends Error {} @@ -633,7 +632,6 @@ describe('toBeOneOf()', () => { }) }) - describe('toSatisfy()', () => { const isOdd = (value: number) => value % 2 !== 0