Skip to content

Commit 3d742b2

Browse files
zirkelchi-ogawa
andauthoredDec 20, 2024··
feat(expect): add toBeOneOf matcher (#6974)
Co-authored-by: Hiroshi Ogawa <hi.ogawa.zz@gmail.com>
1 parent 78b62ff commit 3d742b2

File tree

6 files changed

+340
-35
lines changed

6 files changed

+340
-35
lines changed
 

‎docs/api/expect.md

+36
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,42 @@ test('getApplesCount has some unusual side effects...', () => {
309309
})
310310
```
311311

312+
## toBeOneOf
313+
314+
- **Type:** `(sample: Array<any>) => any`
315+
316+
`toBeOneOf` asserts if a value matches any of the values in the provided array.
317+
318+
```ts
319+
import { expect, test } from 'vitest'
320+
321+
test('fruit is one of the allowed values', () => {
322+
expect(fruit).toBeOneOf(['apple', 'banana', 'orange'])
323+
})
324+
```
325+
326+
The asymmetric matcher is particularly useful when testing optional properties that could be either `null` or `undefined`:
327+
328+
```ts
329+
test('optional properties can be null or undefined', () => {
330+
const user = {
331+
firstName: 'John',
332+
middleName: undefined,
333+
lastName: 'Doe'
334+
}
335+
336+
expect(user).toEqual({
337+
firstName: expect.any(String),
338+
middleName: expect.toBeOneOf([expect.any(String), undefined]),
339+
lastName: expect.any(String),
340+
})
341+
})
342+
```
343+
344+
:::tip
345+
You can use `expect.not` with this matcher to ensure a value does NOT match any of the provided options.
346+
:::
347+
312348
## toBeTypeOf
313349

314350
- **Type:** `(c: 'bigint' | 'boolean' | 'function' | 'number' | 'object' | 'string' | 'symbol' | 'undefined') => Awaitable<void>`

‎packages/expect/src/custom-matchers.ts

+37
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,43 @@ ${matcherHint('.toSatisfy', 'received', '')}
2222
Expected value to satisfy:
2323
${message || printExpected(expected)}
2424
25+
Received:
26+
${printReceived(actual)}`,
27+
}
28+
},
29+
30+
toBeOneOf(actual: unknown, expected: Array<unknown>) {
31+
const { equals, customTesters } = this
32+
const { printReceived, printExpected, matcherHint } = this.utils
33+
34+
if (!Array.isArray(expected)) {
35+
throw new TypeError(
36+
`You must provide an array to ${matcherHint('.toBeOneOf')}, not '${typeof expected}'.`,
37+
)
38+
}
39+
40+
const pass = expected.length === 0
41+
|| expected.some(item =>
42+
equals(item, actual, customTesters),
43+
)
44+
45+
return {
46+
pass,
47+
message: () =>
48+
pass
49+
? `\
50+
${matcherHint('.not.toBeOneOf', 'received', '')}
51+
52+
Expected value to not be one of:
53+
${printExpected(expected)}
54+
Received:
55+
${printReceived(actual)}`
56+
: `\
57+
${matcherHint('.toBeOneOf', 'received', '')}
58+
59+
Expected value to be one of:
60+
${printExpected(expected)}
61+
2562
Received:
2663
${printReceived(actual)}`,
2764
}

‎packages/expect/src/jest-extend.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ function JestExtendPlugin(
132132
}
133133

134134
toAsymmetricMatcher() {
135-
return `${this.toString()}<${this.sample.map(String).join(', ')}>`
135+
return `${this.toString()}<${this.sample.map(item => stringify(item)).join(', ')}>`
136136
}
137137
}
138138

‎packages/expect/src/types.ts

+10
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,16 @@ interface CustomMatcher {
118118
* expect(age).toEqual(expect.toSatisfy(val => val >= 18, 'Age must be at least 18'));
119119
*/
120120
toSatisfy: (matcher: (value: any) => boolean, message?: string) => any
121+
122+
/**
123+
* Matches if the received value is one of the values in the expected array.
124+
*
125+
* @example
126+
* expect(1).toBeOneOf([1, 2, 3])
127+
* expect('foo').toBeOneOf([expect.any(String)])
128+
* expect({ a: 1 }).toEqual({ a: expect.toBeOneOf(['1', '2', '3']) })
129+
*/
130+
toBeOneOf: <T>(sample: Array<T>) => any
121131
}
122132

123133
export interface AsymmetricMatchersContaining extends CustomMatcher {

‎test/core/test/__snapshots__/jest-expect.test.ts.snap

+205-33
Original file line numberDiff line numberDiff line change
@@ -60,25 +60,25 @@ exports[`asymmetric matcher error 5`] = `
6060
{
6161
"actual": "hello",
6262
"diff": "- Expected:
63-
stringContainingCustom<xx>
63+
stringContainingCustom<"xx">
6464
6565
+ Received:
6666
"hello"",
67-
"expected": "stringContainingCustom<xx>",
68-
"message": "expected 'hello' to deeply equal stringContainingCustom<xx>",
67+
"expected": "stringContainingCustom<"xx">",
68+
"message": "expected 'hello' to deeply equal stringContainingCustom<"xx">",
6969
}
7070
`;
7171

7272
exports[`asymmetric matcher error 6`] = `
7373
{
7474
"actual": "hello",
7575
"diff": "- Expected:
76-
not.stringContainingCustom<ll>
76+
not.stringContainingCustom<"ll">
7777
7878
+ Received:
7979
"hello"",
80-
"expected": "not.stringContainingCustom<ll>",
81-
"message": "expected 'hello' to deeply equal not.stringContainingCustom<ll>",
80+
"expected": "not.stringContainingCustom<"ll">",
81+
"message": "expected 'hello' to deeply equal not.stringContainingCustom<"ll">",
8282
}
8383
`;
8484

@@ -91,13 +91,13 @@ exports[`asymmetric matcher error 7`] = `
9191
+ Received
9292
9393
{
94-
- "foo": stringContainingCustom<xx>,
94+
- "foo": stringContainingCustom<"xx">,
9595
+ "foo": "hello",
9696
}",
9797
"expected": "Object {
98-
"foo": stringContainingCustom<xx>,
98+
"foo": stringContainingCustom<"xx">,
9999
}",
100-
"message": "expected { foo: 'hello' } to deeply equal { foo: stringContainingCustom<xx> }",
100+
"message": "expected { foo: 'hello' } to deeply equal { foo: stringContainingCustom<"xx"> }",
101101
}
102102
`;
103103

@@ -110,13 +110,13 @@ exports[`asymmetric matcher error 8`] = `
110110
+ Received
111111
112112
{
113-
- "foo": not.stringContainingCustom<ll>,
113+
- "foo": not.stringContainingCustom<"ll">,
114114
+ "foo": "hello",
115115
}",
116116
"expected": "Object {
117-
"foo": not.stringContainingCustom<ll>,
117+
"foo": not.stringContainingCustom<"ll">,
118118
}",
119-
"message": "expected { foo: 'hello' } to deeply equal { foo: not.stringContainingCustom<ll> }",
119+
"message": "expected { foo: 'hello' } to deeply equal { foo: not.stringContainingCustom{…} }",
120120
}
121121
`;
122122

@@ -142,12 +142,16 @@ exports[`asymmetric matcher error 11`] = `
142142
{
143143
"actual": "hello",
144144
"diff": "- Expected:
145-
testComplexMatcher<[object Object]>
145+
testComplexMatcher<Object {
146+
"x": "y",
147+
}>
146148
147149
+ Received:
148150
"hello"",
149-
"expected": "testComplexMatcher<[object Object]>",
150-
"message": "expected 'hello' to deeply equal testComplexMatcher<[object Object]>",
151+
"expected": "testComplexMatcher<Object {
152+
"x": "y",
153+
}>",
154+
"message": "expected 'hello' to deeply equal testComplexMatcher{}",
151155
}
152156
`;
153157

@@ -220,6 +224,82 @@ exports[`asymmetric matcher error 15`] = `
220224
`;
221225

222226
exports[`asymmetric matcher error 16`] = `
227+
{
228+
"actual": "foo",
229+
"diff": "- Expected:
230+
toBeOneOf<Array [
231+
"bar",
232+
"baz",
233+
]>
234+
235+
+ Received:
236+
"foo"",
237+
"expected": "toBeOneOf<Array [
238+
"bar",
239+
"baz",
240+
]>",
241+
"message": "expected 'foo' to deeply equal toBeOneOf<Array [
242+
"bar",
243+
"baz",
244+
]>",
245+
}
246+
`;
247+
248+
exports[`asymmetric matcher error 17`] = `
249+
{
250+
"actual": "0",
251+
"diff": "- Expected:
252+
toBeOneOf<Array [
253+
Any<String>,
254+
null,
255+
undefined,
256+
]>
257+
258+
+ Received:
259+
0",
260+
"expected": "toBeOneOf<Array [
261+
Any<String>,
262+
null,
263+
undefined,
264+
]>",
265+
"message": "expected +0 to deeply equal toBeOneOf{…}",
266+
}
267+
`;
268+
269+
exports[`asymmetric matcher error 18`] = `
270+
{
271+
"actual": "Object {
272+
"k": "v",
273+
"k2": "v2",
274+
}",
275+
"diff": "- Expected:
276+
toBeOneOf<Array [
277+
ObjectContaining {
278+
"k": "v",
279+
"k3": "v3",
280+
},
281+
null,
282+
undefined,
283+
]>
284+
285+
+ Received:
286+
{
287+
"k": "v",
288+
"k2": "v2",
289+
}",
290+
"expected": "toBeOneOf<Array [
291+
ObjectContaining {
292+
"k": "v",
293+
"k3": "v3",
294+
},
295+
null,
296+
undefined,
297+
]>",
298+
"message": "expected { k: 'v', k2: 'v2' } to deeply equal toBeOneOf{…}",
299+
}
300+
`;
301+
302+
exports[`asymmetric matcher error 19`] = `
223303
{
224304
"actual": "hello",
225305
"diff": undefined,
@@ -228,7 +308,7 @@ exports[`asymmetric matcher error 16`] = `
228308
}
229309
`;
230310
231-
exports[`asymmetric matcher error 17`] = `
311+
exports[`asymmetric matcher error 20`] = `
232312
{
233313
"actual": "hello",
234314
"diff": undefined,
@@ -237,20 +317,20 @@ exports[`asymmetric matcher error 17`] = `
237317
}
238318
`;
239319
240-
exports[`asymmetric matcher error 18`] = `
320+
exports[`asymmetric matcher error 21`] = `
241321
{
242322
"actual": "hello",
243323
"diff": "- Expected:
244-
stringContainingCustom<xx>
324+
stringContainingCustom<"xx">
245325
246326
+ Received:
247327
"hello"",
248-
"expected": "stringContainingCustom<xx>",
328+
"expected": "stringContainingCustom<"xx">",
249329
"message": "expected error to match asymmetric matcher",
250330
}
251331
`;
252332
253-
exports[`asymmetric matcher error 19`] = `
333+
exports[`asymmetric matcher error 22`] = `
254334
{
255335
"actual": "hello",
256336
"diff": undefined,
@@ -259,20 +339,20 @@ exports[`asymmetric matcher error 19`] = `
259339
}
260340
`;
261341
262-
exports[`asymmetric matcher error 20`] = `
342+
exports[`asymmetric matcher error 23`] = `
263343
{
264344
"actual": "hello",
265345
"diff": "- Expected:
266-
stringContainingCustom<ll>
346+
stringContainingCustom<"ll">
267347
268348
+ Received:
269349
"hello"",
270-
"expected": "stringContainingCustom<ll>",
350+
"expected": "stringContainingCustom<"ll">",
271351
"message": "expected error not to match asymmetric matcher",
272352
}
273353
`;
274354
275-
exports[`asymmetric matcher error 21`] = `
355+
exports[`asymmetric matcher error 24`] = `
276356
{
277357
"actual": "[Error: hello]",
278358
"diff": "- Expected:
@@ -287,22 +367,22 @@ Error {
287367
}
288368
`;
289369
290-
exports[`asymmetric matcher error 22`] = `
370+
exports[`asymmetric matcher error 25`] = `
291371
{
292372
"actual": "[Error: hello]",
293373
"diff": "- Expected:
294-
stringContainingCustom<ll>
374+
stringContainingCustom<"ll">
295375
296376
+ Received:
297377
Error {
298378
"message": "hello",
299379
}",
300-
"expected": "stringContainingCustom<ll>",
380+
"expected": "stringContainingCustom<"ll">",
301381
"message": "expected error to match asymmetric matcher",
302382
}
303383
`;
304384
305-
exports[`asymmetric matcher error 23`] = `
385+
exports[`asymmetric matcher error 26`] = `
306386
{
307387
"actual": "[Error: hello]",
308388
"diff": "- Expected:
@@ -626,6 +706,98 @@ exports[`error equality 13`] = `
626706
}
627707
`;
628708
709+
exports[`toBeOneOf() > error message 1`] = `
710+
{
711+
"actual": "undefined",
712+
"diff": undefined,
713+
"expected": "undefined",
714+
"message": "expect(received).toBeOneOf()
715+
716+
Expected value to be one of:
717+
Array [
718+
0,
719+
1,
720+
2,
721+
]
722+
723+
Received:
724+
3",
725+
}
726+
`;
727+
728+
exports[`toBeOneOf() > error message 2`] = `
729+
{
730+
"actual": "undefined",
731+
"diff": undefined,
732+
"expected": "undefined",
733+
"message": "expect(received).toBeOneOf()
734+
735+
Expected value to be one of:
736+
Array [
737+
Any<String>,
738+
]
739+
740+
Received:
741+
3",
742+
}
743+
`;
744+
745+
exports[`toBeOneOf() > error message 3`] = `
746+
{
747+
"actual": "Object {
748+
"a": 0,
749+
}",
750+
"diff": "- Expected:
751+
toBeOneOf<Array [
752+
ObjectContaining {
753+
"b": 0,
754+
},
755+
null,
756+
undefined,
757+
]>
758+
759+
+ Received:
760+
{
761+
"a": 0,
762+
}",
763+
"expected": "toBeOneOf<Array [
764+
ObjectContaining {
765+
"b": 0,
766+
},
767+
null,
768+
undefined,
769+
]>",
770+
"message": "expected { a: +0 } to deeply equal toBeOneOf{…}",
771+
}
772+
`;
773+
774+
exports[`toBeOneOf() > error message 4`] = `
775+
{
776+
"actual": "Object {
777+
"name": "mango",
778+
}",
779+
"diff": "- Expected
780+
+ Received
781+
782+
{
783+
- "name": toBeOneOf<Array [
784+
- "apple",
785+
- "banana",
786+
- "orange",
787+
- ]>,
788+
+ "name": "mango",
789+
}",
790+
"expected": "Object {
791+
"name": toBeOneOf<Array [
792+
"apple",
793+
"banana",
794+
"orange",
795+
]>,
796+
}",
797+
"message": "expected { name: 'mango' } to deeply equal { name: toBeOneOf{…} }",
798+
}
799+
`;
800+
629801
exports[`toHaveBeenNthCalledWith error 1`] = `
630802
{
631803
"actual": "Array [
@@ -728,13 +900,13 @@ exports[`toSatisfy() > error message 3`] = `
728900
+ Received
729901
730902
{
731-
- "value": toSatisfy<(value) => value % 2 !== 0>,
903+
- "value": toSatisfy<[Function isOdd]>,
732904
+ "value": 2,
733905
}",
734906
"expected": "Object {
735-
"value": toSatisfy<(value) => value % 2 !== 0>,
907+
"value": toSatisfy<[Function isOdd]>,
736908
}",
737-
"message": "expected { value: 2 } to deeply equal { value: toSatisfy{…} }",
909+
"message": "expected { value: 2 } to deeply equal { value: toSatisfy<[Function isOdd]> }",
738910
}
739911
`;
740912
@@ -747,11 +919,11 @@ exports[`toSatisfy() > error message 4`] = `
747919
+ Received
748920
749921
{
750-
- "value": toSatisfy<(value) => value % 2 !== 0, ODD>,
922+
- "value": toSatisfy<[Function isOdd], "ODD">,
751923
+ "value": 2,
752924
}",
753925
"expected": "Object {
754-
"value": toSatisfy<(value) => value % 2 !== 0, ODD>,
926+
"value": toSatisfy<[Function isOdd], "ODD">,
755927
}",
756928
"message": "expected { value: 2 } to deeply equal { value: toSatisfy{…} }",
757929
}

‎test/core/test/jest-expect.test.ts

+51-1
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,53 @@ describe('toBeTypeOf()', () => {
585585
})
586586
})
587587

588+
describe('toBeOneOf()', () => {
589+
it('pass with assertion', () => {
590+
expect(0).toBeOneOf([0, 1, 2])
591+
expect(0).toBeOneOf([expect.any(Number)])
592+
expect('apple').toBeOneOf(['apple', 'banana', 'orange'])
593+
expect('apple').toBeOneOf([expect.any(String)])
594+
expect(true).toBeOneOf([true, false])
595+
expect(true).toBeOneOf([expect.any(Boolean)])
596+
expect(null).toBeOneOf([expect.any(Object)])
597+
expect(undefined).toBeOneOf([undefined])
598+
})
599+
600+
it('pass with negotiation', () => {
601+
expect(3).not.toBeOneOf([0, 1, 2])
602+
expect(3).not.toBeOneOf([expect.any(String)])
603+
expect('mango').not.toBeOneOf(['apple', 'banana', 'orange'])
604+
expect('mango').not.toBeOneOf([expect.any(Number)])
605+
expect(null).not.toBeOneOf([undefined])
606+
})
607+
608+
it.fails('fail with missing negotiation', () => {
609+
expect(3).toBeOneOf([0, 1, 2])
610+
expect(3).toBeOneOf([expect.any(String)])
611+
expect('mango').toBeOneOf(['apple', 'banana', 'orange'])
612+
expect('mango').toBeOneOf([expect.any(Number)])
613+
expect(null).toBeOneOf([undefined])
614+
})
615+
616+
it('asymmetric matcher', () => {
617+
expect({ a: 0 }).toEqual(expect.toBeOneOf([expect.objectContaining({ a: 0 }), null]))
618+
expect({
619+
name: 'apple',
620+
count: 1,
621+
}).toEqual({
622+
name: expect.toBeOneOf(['apple', 'banana', 'orange']),
623+
count: expect.toBeOneOf([expect.any(Number)]),
624+
})
625+
})
626+
627+
it('error message', () => {
628+
snapshotError(() => expect(3).toBeOneOf([0, 1, 2]))
629+
snapshotError(() => expect(3).toBeOneOf([expect.any(String)]))
630+
snapshotError(() => expect({ a: 0 }).toEqual(expect.toBeOneOf([expect.objectContaining({ b: 0 }), null, undefined])))
631+
snapshotError(() => expect({ name: 'mango' }).toEqual({ name: expect.toBeOneOf(['apple', 'banana', 'orange']) }))
632+
})
633+
})
634+
588635
describe('toSatisfy()', () => {
589636
const isOdd = (value: number) => value % 2 !== 0
590637

@@ -636,7 +683,7 @@ describe('toSatisfy()', () => {
636683
}),
637684
)
638685
}).toThrowErrorMatchingInlineSnapshot(
639-
`[AssertionError: expected Error: 2 to match object { message: toSatisfy{…} }]`,
686+
`[AssertionError: expected Error: 2 to match object { Object (message) }]`,
640687
)
641688
})
642689

@@ -1578,6 +1625,9 @@ it('asymmetric matcher error', () => {
15781625
snapshotError(() => expect(['a', 'b']).toEqual(expect.arrayContaining(['a', 'c'])))
15791626
snapshotError(() => expect('hello').toEqual(expect.stringMatching(/xx/)))
15801627
snapshotError(() => expect(2.5).toEqual(expect.closeTo(2, 1)))
1628+
snapshotError(() => expect('foo').toEqual(expect.toBeOneOf(['bar', 'baz'])))
1629+
snapshotError(() => expect(0).toEqual(expect.toBeOneOf([expect.any(String), null, undefined])))
1630+
snapshotError(() => expect({ k: 'v', k2: 'v2' }).toEqual(expect.toBeOneOf([expect.objectContaining({ k: 'v', k3: 'v3' }), null, undefined])))
15811631

15821632
// simple truncation if pretty-format is too long
15831633
snapshotError(() => expect('hello').toEqual(expect.stringContaining('a'.repeat(40))))

0 commit comments

Comments
 (0)
Please sign in to comment.