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
48 changes: 47 additions & 1 deletion docs/api/expect.md
Original file line number Diff line number Diff line change
Expand Up @@ -1075,7 +1075,7 @@ test('spy function returns bananas on a last call', () => {

- **Type**: `(time: number, returnValue: any) => Awaitable<void>`

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'
Expand Down Expand Up @@ -1427,6 +1427,52 @@ test('"id" is a number', () => {
})
```

## expect.oneOf

- **Type:** `(sample: Array<any>) => 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`
Expand Down
41 changes: 41 additions & 0 deletions packages/expect/src/jest-asymmetric-matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,11 +377,50 @@
}
}


Check failure on line 380 in packages/expect/src/jest-asymmetric-matchers.ts

View workflow job for this annotation

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

More than 1 blank line not allowed
class OneOf<T = unknown> extends AsymmetricMatcher<Array<T>> {
constructor(sample: Array<T>, 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<unknown>) => new OneOf(expected))

utils.addMethod(
chai.expect,
'stringContaining',
Expand Down Expand Up @@ -423,5 +462,7 @@
new StringMatching(expected, true),
closeTo: (expected: any, precision?: number) =>
new CloseTo(expected, precision, true),
oneOf: <T = unknown>(expected: Array<T>) =>
new OneOf<T>(expected, true),
}
}
10 changes: 10 additions & 0 deletions packages/expect/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <T>(sample: Array<T>) => any
}

export interface JestAssertion<T = any> extends jest.Matchers<void, T> {
Expand Down
20 changes: 20 additions & 0 deletions test/core/test/jest-expect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,13 +194,33 @@
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

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

View workflow job for this annotation

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

Missing trailing comma
}).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', () => {
expect('bar').toEqual(expect.not.stringContaining('zoo'))
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 () => {
Expand Down
Loading