Skip to content

feat(expect): add ArrayOf asymmetric matcher #15567

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
46 changes: 46 additions & 0 deletions docs/ExpectAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
61 changes: 61 additions & 0 deletions packages/expect/src/__tests__/asymmetricMatchers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
anything,
arrayContaining,
arrayNotContaining,
arrayOf,
closeTo,
notArrayOf,
notCloseTo,
objectContaining,
objectNotContaining,
Expand Down Expand Up @@ -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);
}
});
24 changes: 24 additions & 0 deletions packages/expect/src/asymmetricMatchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,27 @@
}
}

class ArrayOf extends AsymmetricMatcher<unknown> {
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() {

Check warning on line 240 in packages/expect/src/asymmetricMatchers.ts

View check run for this annotation

Codecov / codecov/patch

packages/expect/src/asymmetricMatchers.ts#L240

Added line #L240 was not covered by tests
return `${this.inverse ? 'Not' : ''}ArrayOf`;
}

override getExpectedType() {
return 'array';

Check warning on line 245 in packages/expect/src/asymmetricMatchers.ts

View check run for this annotation

Codecov / codecov/patch

packages/expect/src/asymmetricMatchers.ts#L244-L245

Added lines #L244 - L245 were not covered by tests
}
}

class ObjectContaining extends AsymmetricMatcher<
Record<string | symbol, unknown>
> {
Expand Down Expand Up @@ -383,6 +404,9 @@
new ArrayContaining(sample);
export const arrayNotContaining = (sample: Array<unknown>): 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<string, unknown>,
): ObjectContaining => new ObjectContaining(sample);
Expand Down
4 changes: 4 additions & 0 deletions packages/expect/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import {
anything,
arrayContaining,
arrayNotContaining,
arrayOf,
closeTo,
notArrayOf,
notCloseTo,
objectContaining,
objectNotContaining,
Expand Down Expand Up @@ -397,13 +399,15 @@ expect.any = any;

expect.not = {
arrayContaining: arrayNotContaining,
arrayOf: notArrayOf,
closeTo: notCloseTo,
objectContaining: objectNotContaining,
stringContaining: stringNotContaining,
stringMatching: stringNotMatching,
};

expect.arrayContaining = arrayContaining;
expect.arrayOf = arrayOf;
expect.closeTo = closeTo;
expect.objectContaining = objectContaining;
expect.stringContaining = stringContaining;
Expand Down
1 change: 1 addition & 0 deletions packages/expect/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export interface AsymmetricMatchers {
any(sample: unknown): AsymmetricMatcher;
anything(): AsymmetricMatcher;
arrayContaining(sample: Array<unknown>): AsymmetricMatcher;
arrayOf(sample: unknown): AsymmetricMatcher;
closeTo(sample: number, precision?: number): AsymmetricMatcher;
objectContaining(sample: Record<string, unknown>): AsymmetricMatcher;
stringContaining(sample: string): AsymmetricMatcher;
Expand Down
18 changes: 18 additions & 0 deletions packages/pretty-format/src/__tests__/AsymmetricMatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ test('arrayNotContaining()', () => {
]`);
});

test('arrayOf()', () => {
const result = prettyFormat(expect.arrayOf(expect.any(String)), options);
expect(result).toBe('ArrayOf Any<String>');
});

test('notArrayOf()', () => {
const result = prettyFormat(expect.not.arrayOf(expect.any(String)), options);
expect(result).toBe('NotArrayOf Any<String>');
});

test('objectContaining()', () => {
const result = prettyFormat(expect.objectContaining({a: 'test'}), options);
expect(result).toBe(`ObjectContaining {
Expand Down Expand Up @@ -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),
}),
),
}),
},
},
Expand All @@ -201,6 +216,9 @@ test('supports multiple nested asymmetric matchers', () => {
"f": ObjectContaining {
"test": "case",
},
"g": ArrayOf ObjectContaining {
"nested": Any<Number>,
},
},
},
}`);
Expand Down
13 changes: 13 additions & 0 deletions packages/pretty-format/src/plugins/AsymmetricMatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@
);
}

if (stringedValue === 'ArrayOf' || stringedValue === 'NotArrayOf') {
if (++depth > config.maxDepth) {
return `[${stringedValue}]`;

Check warning on line 85 in packages/pretty-format/src/plugins/AsymmetricMatcher.ts

View check run for this annotation

Codecov / codecov/patch

packages/pretty-format/src/plugins/AsymmetricMatcher.ts#L85

Added line #L85 was not covered by tests
}
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()`,
Expand Down
Loading