diff --git a/CHANGELOG.md b/CHANGELOG.md index faf822635d12..1ffaf2f86810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- `[expect]` Add `ArrayOf` asymmetric matcher for validating array elements. ([#15567](https://github.com/jestjs/jest/pull/15567)) - `[babel-jest]` Add option `excludeJestPreset` to allow opting out of `babel-preset-jest` ([#15164](https://github.com/jestjs/jest/pull/15164)) - `[jest-circus, jest-cli, jest-config]` Add `waitNextEventLoopTurnForUnhandledRejectionEvents` flag to minimise performance impact of correct detection of unhandled promise rejections introduced in [#14315](https://github.com/jestjs/jest/pull/14315) ([#14681](https://github.com/jestjs/jest/pull/14681)) - `[jest-circus]` Add a `waitBeforeRetry` option to `jest.retryTimes` ([#14738](https://github.com/jestjs/jest/pull/14738)) diff --git a/docs/ExpectAPI.md b/docs/ExpectAPI.md index 336564856346..9f941bce9850 100644 --- a/docs/ExpectAPI.md +++ b/docs/ExpectAPI.md @@ -958,6 +958,52 @@ describe('not.arrayContaining', () => { }); ``` +### `expect.arrayOf(value)` + +`expect.arrayOf(value)` matches a received array whose elements match the provided value. This is useful for asserting that every item in an array satisfies a particular condition or type. + +**Example:** + +```js +test('all elements in array are strings', () => { + expect(['apple', 'banana', 'cherry']).toEqual( + expect.arrayOf(expect.any(String)), + ); +}); +``` + +This matcher is particularly useful for validating arrays containing complex structures: + +```js +test('array of objects with specific properties', () => { + expect([ + {id: 1, name: 'Alice'}, + {id: 2, name: 'Bob'}, + ]).toEqual( + expect.arrayOf( + expect.objectContaining({ + id: expect.any(Number), + name: expect.any(String), + }), + ), + ); +}); +``` + +### `expect.not.arrayOf(value)` + +`expect.not.arrayOf(value)` matches a received array where not all elements match the provided matcher. + +**Example:** + +```js +test('not all elements in array are strings', () => { + expect(['apple', 123, 'cherry']).toEqual( + expect.not.arrayOf(expect.any(String)), + ); +}); +``` + ### `expect.closeTo(number, numDigits?)` `expect.closeTo(number, numDigits?)` is useful when comparing floating point numbers in object properties or array item. If you need to compare a number, please use `.toBeCloseTo` instead. diff --git a/packages/expect/src/__tests__/asymmetricMatchers.test.ts b/packages/expect/src/__tests__/asymmetricMatchers.test.ts index 2a1748e27741..cd108cbd1e31 100644 --- a/packages/expect/src/__tests__/asymmetricMatchers.test.ts +++ b/packages/expect/src/__tests__/asymmetricMatchers.test.ts @@ -13,7 +13,9 @@ import { anything, arrayContaining, arrayNotContaining, + arrayOf, closeTo, + notArrayOf, notCloseTo, objectContaining, objectNotContaining, @@ -514,3 +516,62 @@ describe('closeTo', () => { jestExpect(notCloseTo(1).asymmetricMatch('a')).toBe(false); }); }); + +test('ArrayOf matches', () => { + for (const test of [ + arrayOf(1).asymmetricMatch([1]), + arrayOf(1).asymmetricMatch([1, 1, 1]), + arrayOf({a: 1}).asymmetricMatch([{a: 1}, {a: 1}]), + arrayOf(undefined).asymmetricMatch([undefined]), + arrayOf(null).asymmetricMatch([null]), + arrayOf([]).asymmetricMatch([[], []]), + arrayOf(any(String)).asymmetricMatch(['a', 'b', 'c']), + ]) { + jestExpect(test).toEqual(true); + } +}); + +test('ArrayOf does not match', () => { + for (const test of [ + arrayOf(1).asymmetricMatch([2]), + arrayOf(1).asymmetricMatch([1, 2]), + arrayOf({a: 1}).asymmetricMatch([{a: 2}]), + arrayOf(undefined).asymmetricMatch([null]), + arrayOf(null).asymmetricMatch([undefined]), + arrayOf([]).asymmetricMatch([{}]), + arrayOf(1).asymmetricMatch(1), + arrayOf(1).asymmetricMatch('not an array'), + arrayOf(1).asymmetricMatch({}), + arrayOf(any(String)).asymmetricMatch([1, 2]), + ]) { + jestExpect(test).toEqual(false); + } +}); + +test('NotArrayOf matches', () => { + for (const test of [ + notArrayOf(1).asymmetricMatch([2]), + notArrayOf(1).asymmetricMatch([1, 2]), + notArrayOf({a: 1}).asymmetricMatch([{a: 2}]), + notArrayOf(1).asymmetricMatch(1), + notArrayOf(1).asymmetricMatch('not an array'), + notArrayOf(1).asymmetricMatch({}), + notArrayOf(any(Number)).asymmetricMatch(['a', 'b']), + ]) { + jestExpect(test).toEqual(true); + } +}); + +test('NotArrayOf does not match', () => { + for (const test of [ + notArrayOf(1).asymmetricMatch([1]), + notArrayOf(1).asymmetricMatch([1, 1, 1]), + notArrayOf({a: 1}).asymmetricMatch([{a: 1}, {a: 1}]), + notArrayOf(undefined).asymmetricMatch([undefined]), + notArrayOf(null).asymmetricMatch([null]), + notArrayOf([]).asymmetricMatch([[], []]), + notArrayOf(any(String)).asymmetricMatch(['a', 'b', 'c']), + ]) { + jestExpect(test).toEqual(false); + } +}); diff --git a/packages/expect/src/asymmetricMatchers.ts b/packages/expect/src/asymmetricMatchers.ts index 3784273cbfac..7ececdf437be 100644 --- a/packages/expect/src/asymmetricMatchers.ts +++ b/packages/expect/src/asymmetricMatchers.ts @@ -225,6 +225,27 @@ class ArrayContaining extends AsymmetricMatcher> { } } +class ArrayOf extends AsymmetricMatcher { + asymmetricMatch(other: unknown) { + const matcherContext = this.getMatcherContext(); + const result = + Array.isArray(other) && + other.every(item => + equals(this.sample, item, matcherContext.customTesters), + ); + + return this.inverse ? !result : result; + } + + toString() { + return `${this.inverse ? 'Not' : ''}ArrayOf`; + } + + override getExpectedType() { + return 'array'; + } +} + class ObjectContaining extends AsymmetricMatcher< Record > { @@ -383,6 +404,9 @@ export const arrayContaining = (sample: Array): ArrayContaining => new ArrayContaining(sample); export const arrayNotContaining = (sample: Array): ArrayContaining => new ArrayContaining(sample, true); +export const arrayOf = (sample: unknown): ArrayOf => new ArrayOf(sample); +export const notArrayOf = (sample: unknown): ArrayOf => + new ArrayOf(sample, true); export const objectContaining = ( sample: Record, ): ObjectContaining => new ObjectContaining(sample); diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts index 0979429e59c9..6c773452deee 100644 --- a/packages/expect/src/index.ts +++ b/packages/expect/src/index.ts @@ -16,7 +16,9 @@ import { anything, arrayContaining, arrayNotContaining, + arrayOf, closeTo, + notArrayOf, notCloseTo, objectContaining, objectNotContaining, @@ -397,6 +399,7 @@ expect.any = any; expect.not = { arrayContaining: arrayNotContaining, + arrayOf: notArrayOf, closeTo: notCloseTo, objectContaining: objectNotContaining, stringContaining: stringNotContaining, @@ -404,6 +407,7 @@ expect.not = { }; expect.arrayContaining = arrayContaining; +expect.arrayOf = arrayOf; expect.closeTo = closeTo; expect.objectContaining = objectContaining; expect.stringContaining = stringContaining; diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index ec3fe6494953..4646dc07a9a2 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -117,6 +117,7 @@ export interface AsymmetricMatchers { any(sample: unknown): AsymmetricMatcher; anything(): AsymmetricMatcher; arrayContaining(sample: Array): AsymmetricMatcher; + arrayOf(sample: unknown): AsymmetricMatcher; closeTo(sample: number, precision?: number): AsymmetricMatcher; objectContaining(sample: Record): AsymmetricMatcher; stringContaining(sample: string): AsymmetricMatcher; diff --git a/packages/pretty-format/src/__tests__/AsymmetricMatcher.test.ts b/packages/pretty-format/src/__tests__/AsymmetricMatcher.test.ts index cab0cc29cba8..13c42c07a9a8 100644 --- a/packages/pretty-format/src/__tests__/AsymmetricMatcher.test.ts +++ b/packages/pretty-format/src/__tests__/AsymmetricMatcher.test.ts @@ -78,6 +78,16 @@ test('arrayNotContaining()', () => { ]`); }); +test('arrayOf()', () => { + const result = prettyFormat(expect.arrayOf(expect.any(String)), options); + expect(result).toBe('ArrayOf Any'); +}); + +test('notArrayOf()', () => { + const result = prettyFormat(expect.not.arrayOf(expect.any(String)), options); + expect(result).toBe('NotArrayOf Any'); +}); + test('objectContaining()', () => { const result = prettyFormat(expect.objectContaining({a: 'test'}), options); expect(result).toBe(`ObjectContaining { @@ -183,6 +193,11 @@ test('supports multiple nested asymmetric matchers', () => { d: expect.stringContaining('jest'), e: expect.stringMatching('jest'), f: expect.objectContaining({test: 'case'}), + g: expect.arrayOf( + expect.objectContaining({ + nested: expect.any(Number), + }), + ), }), }, }, @@ -201,6 +216,9 @@ test('supports multiple nested asymmetric matchers', () => { "f": ObjectContaining { "test": "case", }, + "g": ArrayOf ObjectContaining { + "nested": Any, + }, }, }, }`); diff --git a/packages/pretty-format/src/plugins/AsymmetricMatcher.ts b/packages/pretty-format/src/plugins/AsymmetricMatcher.ts index cf71b69150e2..575dc66fa0ba 100644 --- a/packages/pretty-format/src/plugins/AsymmetricMatcher.ts +++ b/packages/pretty-format/src/plugins/AsymmetricMatcher.ts @@ -80,6 +80,19 @@ export const serialize: NewPlugin['serialize'] = ( ); } + if (stringedValue === 'ArrayOf' || stringedValue === 'NotArrayOf') { + if (++depth > config.maxDepth) { + return `[${stringedValue}]`; + } + return `${stringedValue + SPACE}${printer( + val.sample, + config, + indentation, + depth, + refs, + )}`; + } + if (typeof val.toAsymmetricMatcher !== 'function') { throw new TypeError( `Asymmetric matcher ${val.constructor.name} does not implement toAsymmetricMatcher()`,