Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(expect): add toBeOneOf matcher #6974

Merged
merged 11 commits into from
Dec 20, 2024
36 changes: 36 additions & 0 deletions docs/api/expect.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,42 @@ test('getApplesCount has some unusual side effects...', () => {
})
```

## toBeOneOf

- **Type:** `(sample: Array<any>) => 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<void>`
Expand Down
37 changes: 37 additions & 0 deletions packages/expect/src/custom-matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,41 @@
${printReceived(actual)}`,
}
},

toBeOneOf(actual: unknown, expected: Array<unknown>) {
const { equals, customTesters } = this
const { printReceived, printExpected, matcherHint, } = this.utils

Check failure on line 32 in packages/expect/src/custom-matchers.ts

View workflow job for this annotation

GitHub Actions / Lint: node-latest, ubuntu-latest

Unexpected trailing comma

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)}`,
}
}

Check failure on line 65 in packages/expect/src/custom-matchers.ts

View workflow job for this annotation

GitHub Actions / Lint: node-latest, ubuntu-latest

Missing trailing comma
}
10 changes: 10 additions & 0 deletions packages/expect/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <T>(sample: Array<T>) => any
}

export interface AsymmetricMatchersContaining extends CustomMatcher {
Expand Down
131 changes: 124 additions & 7 deletions test/core/test/__snapshots__/jest-expect.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,51 @@ exports[`asymmetric matcher error 15`] = `
`;

exports[`asymmetric matcher error 16`] = `
{
"actual": "foo",
"diff": "- Expected:
toBeOneOf<bar,baz>

+ Received:
"foo"",
"expected": "toBeOneOf<bar,baz>",
"message": "expected 'foo' to deeply equal toBeOneOf<bar,baz>",
}
`;

exports[`asymmetric matcher error 17`] = `
{
"actual": "0",
"diff": "- Expected:
toBeOneOf<Any,,>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diff doesn't handle null and undefined correctly. It should be:

Suggested change
toBeOneOf<Any,,>
toBeOneOf<Any,null,undefined>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this is unfortunate. Maybe can we try stringify instead of String? 🤔

toAsymmetricMatcher() {
return `${this.toString()}<${this.sample.map(String).join(', ')}>`
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is better. But it also affects toSatisfy()


+ Received:
0",
"expected": "toBeOneOf<Any,,>",
"message": "expected +0 to deeply equal toBeOneOf<Any,,>",
}
`;

exports[`asymmetric matcher error 18`] = `
{
"actual": "Object {
"k": "v",
"k2": "v2",
}",
"diff": "- Expected:
toBeOneOf<ObjectContaining,,>

+ Received:
{
"k": "v",
"k2": "v2",
}",
"expected": "toBeOneOf<ObjectContaining,,>",
"message": "expected { k: 'v', k2: 'v2' } to deeply equal toBeOneOf<ObjectContaining,,>",
}
`;

exports[`asymmetric matcher error 19`] = `
{
"actual": "hello",
"diff": undefined,
Expand All @@ -228,7 +273,7 @@ exports[`asymmetric matcher error 16`] = `
}
`;

exports[`asymmetric matcher error 17`] = `
exports[`asymmetric matcher error 20`] = `
{
"actual": "hello",
"diff": undefined,
Expand All @@ -237,7 +282,7 @@ exports[`asymmetric matcher error 17`] = `
}
`;

exports[`asymmetric matcher error 18`] = `
exports[`asymmetric matcher error 21`] = `
{
"actual": "hello",
"diff": "- Expected:
Expand All @@ -250,7 +295,7 @@ stringContainingCustom<xx>
}
`;

exports[`asymmetric matcher error 19`] = `
exports[`asymmetric matcher error 22`] = `
{
"actual": "hello",
"diff": undefined,
Expand All @@ -259,7 +304,7 @@ exports[`asymmetric matcher error 19`] = `
}
`;

exports[`asymmetric matcher error 20`] = `
exports[`asymmetric matcher error 23`] = `
{
"actual": "hello",
"diff": "- Expected:
Expand All @@ -272,7 +317,7 @@ stringContainingCustom<ll>
}
`;

exports[`asymmetric matcher error 21`] = `
exports[`asymmetric matcher error 24`] = `
{
"actual": "[Error: hello]",
"diff": "- Expected:
Expand All @@ -287,7 +332,7 @@ Error {
}
`;

exports[`asymmetric matcher error 22`] = `
exports[`asymmetric matcher error 25`] = `
{
"actual": "[Error: hello]",
"diff": "- Expected:
Expand All @@ -302,7 +347,7 @@ Error {
}
`;

exports[`asymmetric matcher error 23`] = `
exports[`asymmetric matcher error 26`] = `
{
"actual": "[Error: hello]",
"diff": "- Expected:
Expand Down Expand Up @@ -626,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<String>,
]

Received:
3",
}
`;

exports[`toBeOneOf() > error message 3`] = `
{
"actual": "Object {
"a": 0,
}",
"diff": "- Expected:
toBeOneOf<ObjectContaining,>

+ Received:
{
"a": 0,
}",
"expected": "toBeOneOf<ObjectContaining,>",
"message": "expected { a: +0 } to deeply equal toBeOneOf<ObjectContaining,>",
}
`;

exports[`toBeOneOf() > error message 4`] = `
{
"actual": "Object {
"name": "mango",
}",
"diff": "- Expected
+ Received

{
- "name": toBeOneOf<apple,banana,orange>,
+ "name": "mango",
}",
"expected": "Object {
"name": toBeOneOf<apple,banana,orange>,
}",
"message": "expected { name: 'mango' } to deeply equal { Object (name) }",
}
`;

exports[`toHaveBeenNthCalledWith error 1`] = `
{
"actual": "Array [
Expand Down
52 changes: 52 additions & 0 deletions test/core/test/jest-expect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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'

Check failure on line 7 in test/core/test/jest-expect.test.ts

View workflow job for this annotation

GitHub Actions / Lint: node-latest, ubuntu-latest

'constants' module was deprecated since v6.3.0. Use 'constants' property of each module instead

Check failure on line 7 in test/core/test/jest-expect.test.ts

View workflow job for this annotation

GitHub Actions / Lint: node-latest, ubuntu-latest

Expected "node:constants" (builtin) to come before "vitest" (external)

Check failure on line 7 in test/core/test/jest-expect.test.ts

View workflow job for this annotation

GitHub Actions / Lint: node-latest, ubuntu-latest

'exp' is defined but never used

class TestError extends Error {}

Expand Down Expand Up @@ -585,6 +586,54 @@
})
})

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']) }))
})
})


Check failure on line 636 in test/core/test/jest-expect.test.ts

View workflow job for this annotation

GitHub Actions / Lint: node-latest, ubuntu-latest

More than 1 blank line not allowed
describe('toSatisfy()', () => {
const isOdd = (value: number) => value % 2 !== 0

Expand Down Expand Up @@ -1578,6 +1627,9 @@
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))))
Expand Down
Loading