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(jest-each): add support for interpolation with object properties #11388

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -19,6 +19,7 @@
- `[jest-core]` Add support for `globalSetup` and `globalTeardown` written in ESM ([#11267](https://github.com/facebook/jest/pull/11267))
- `[jest-core]` Add support for `watchPlugins` written in ESM ([#11315](https://github.com/facebook/jest/pull/11315))
- `[jest-core]` Add support for `runner` written in ESM ([#11232](https://github.com/facebook/jest/pull/11232))
- `[jest-each]` Add support for interpolation with object properties ([#11388](https://github.com/facebook/jest/pull/11388))
- `[jest-environment-node]` Add AbortController to globals ([#11182](https://github.com/facebook/jest/pull/11182))
- `[@jest/fake-timers]` Update to `@sinonjs/fake-timers` to v7 ([#11198](https://github.com/facebook/jest/pull/11198))
- `[jest-haste-map]` Handle injected scm clocks ([#10966](https://github.com/facebook/jest/pull/10966))
Expand Down
38 changes: 38 additions & 0 deletions docs/GlobalAPI.md
Expand Up @@ -245,6 +245,10 @@ Use `describe.each` if you keep duplicating the same test suites with different
- `%o` - Object.
- `%#` - Index of the test case.
- `%%` - single percent sign ('%'). This does not consume an argument.
- Or generate unique test titles by injecting properties of test case object with `$variable`
- To inject nested object values use you can supply a keyPath i.e. `$variable.path.to.value`
- You can use `$#` to inject the index of the test case
- You cannot use `$variable` with the `printf` formatting except for `%%`
- `fn`: `Function` the suite of tests to be ran, this is the function that will receive the parameters in each row as function arguments.
- Optionally, you can provide a `timeout` (in milliseconds) for specifying how long to wait for each row before aborting. _Note: The default timeout is 5 seconds._

Expand All @@ -270,6 +274,26 @@ describe.each([
});
```

```js
describe.each([
{a: 1, b: 1, expected: 2},
{a: 1, b: 2, expected: 3},
{a: 2, b: 1, expected: 3},
])('.add($a, $b)', ({a, b, expected}) => {
test(`returns ${expected}`, () => {
expect(a + b).toBe(expected);
});

test(`returned value not be greater than ${expected}`, () => {
expect(a + b).not.toBeGreaterThan(expected);
});

test(`returned value not be less than ${expected}`, () => {
expect(a + b).not.toBeLessThan(expected);
});
});
```

#### 2. `` describe.each`table`(name, fn, timeout) ``

- `table`: `Tagged Template Literal`
Expand Down Expand Up @@ -655,6 +679,10 @@ Use `test.each` if you keep duplicating the same test with different data. `test
- `%o` - Object.
- `%#` - Index of the test case.
- `%%` - single percent sign ('%'). This does not consume an argument.
- Or generate unique test titles by injecting properties of test case object with `$variable`
- To inject nested object values use you can supply a keyPath i.e. `$variable.path.to.value`
- You can use `$#` to inject the index of the test case
- You cannot use `$variable` with the `printf` formatting except for `%%`
- `fn`: `Function` the test to be ran, this is the function that will receive the parameters in each row as function arguments.
- Optionally, you can provide a `timeout` (in milliseconds) for specifying how long to wait for each row before aborting. _Note: The default timeout is 5 seconds._

Expand All @@ -670,6 +698,16 @@ test.each([
});
```

```js
test.each([
{a: 1, b: 1, expected: 2},
{a: 1, b: 2, expected: 3},
{a: 2, b: 1, expected: 3},
])('.add($a, $b)', ({a, b, expected}) => {
expect(a + b).toBe(expected);
});
```

#### 2. `` test.each`table`(name, fn, timeout) ``

- `table`: `Tagged Template Literal`
Expand Down
41 changes: 41 additions & 0 deletions packages/jest-each/README.md
Expand Up @@ -41,6 +41,7 @@ jest-each allows you to provide multiple arguments to your `test`/`describe` whi
- `%o` - Object.
- `%#` - Index of the test case.
- `%%` - single percent sign ('%'). This does not consume an argument.
- Unique test titles by injecting properties of test case object
- 🖖 Spock like data tables with [Tagged Template Literals](#tagged-template-literal-of-rows)

---
Expand Down Expand Up @@ -118,6 +119,10 @@ const each = require('jest-each').default;
- `%o` - Object.
- `%#` - Index of the test case.
- `%%` - single percent sign ('%'). This does not consume an argument.
- Or generate unique test titles by injecting properties of test case object with `$variable`
- To inject nested object values use you can supply a keyPath i.e. `$variable.path.to.value`
- You can use `$#` to inject the index of the test case
- You cannot use `$variable` with the `printf` formatting except for `%%`
- testFn: `Function` the test logic, this is the function that will receive the parameters of each row as function arguments

#### `each([parameters]).describe(name, suiteFn)`
Expand All @@ -140,6 +145,10 @@ const each = require('jest-each').default;
- `%o` - Object.
- `%#` - Index of the test case.
- `%%` - single percent sign ('%'). This does not consume an argument.
- Or generate unique test titles by injecting properties of test case object with `$variable`
- To inject nested object values use you can supply a keyPath i.e. `$variable.path.to.value`
- You can use `$#` to inject the index of the test case
- You cannot use `$variable` with the `printf` formatting except for `%%`
- suiteFn: `Function` the suite of `test`/`it`s to be ran, this is the function that will receive the parameters in each row as function arguments

### Usage
Expand All @@ -158,6 +167,16 @@ each([
});
```

```js
each([
{a: 1, b: 1, expected: 2},
{a: 1, b: 2, expected: 3},
{a: 2, b: 1, expected: 3},
]).test('returns the result of adding $a to $b', ({a, b, expected}) => {
expect(a + b).toBe(expected);
});
```

#### `.test.only(name, fn)`

Aliases: `.it.only(name, fn)` or `.fit(name, fn)`
Expand Down Expand Up @@ -278,6 +297,28 @@ each([
});
```

```js
each([
{a: 1, b: 1, expected: 2},
{a: 1, b: 2, expected: 3},
{a: 2, b: 1, expected: 3},
]).describe('.add($a, $b)', ({a, b, expected}) => {
test(`returns ${expected}`, () => {
expect(a + b).toBe(expected);
});

test('does not mutate first arg', () => {
a + b;
expect(a).toBe(a);
});

test('does not mutate second arg', () => {
a + b;
expect(b).toBe(b);
});
});
```

#### `.describe.only(name, fn)`

Aliases: `.fdescribe(name, fn)`
Expand Down
77 changes: 77 additions & 0 deletions packages/jest-each/src/__tests__/array.test.ts
Expand Up @@ -291,6 +291,83 @@ describe('jest-each', () => {
10000,
);
});

test('calls global with title containing object property when using $variable', () => {
const globalTestMocks = getGlobalTestMocks();
const eachObject = each.withGlobal(globalTestMocks)([
{
a: 'hello',
b: 1,
c: null,
d: undefined,
e: 1.2,
f: {key: 'foo'},
g: () => {},
h: [],
i: Infinity,
j: NaN,
},
{
a: 'world',
b: 1,
c: null,
d: undefined,
e: 1.2,
f: {key: 'bar'},
g: () => {},
h: [],
i: Infinity,
j: NaN,
},
]);
const testFunction = get(eachObject, keyPath);
testFunction(
'expected string: %% %%s $a $b $c $d $e $f $f.key $g $h $i $j $#',
noop,
);

const globalMock = get(globalTestMocks, keyPath);
expect(globalMock).toHaveBeenCalledTimes(2);
expect(globalMock).toHaveBeenCalledWith(
'expected string: % %s hello 1 null undefined 1.2 {"key": "foo"} foo [Function g] [] Infinity NaN 0',
expectFunction,
undefined,
);
expect(globalMock).toHaveBeenCalledWith(
'expected string: % %s world 1 null undefined 1.2 {"key": "bar"} bar [Function g] [] Infinity NaN 1',
expectFunction,
undefined,
);
});

test('calls global with title containing param values when using both % placeholder and $variable', () => {
const globalTestMocks = getGlobalTestMocks();
const eachObject = each.withGlobal(globalTestMocks)([
{
a: 'hello',
b: 1,
},
{
a: 'world',
b: 1,
},
]);
const testFunction = get(eachObject, keyPath);
testFunction('expected string: %p %# $a $b $#', noop);

const globalMock = get(globalTestMocks, keyPath);
expect(globalMock).toHaveBeenCalledTimes(2);
expect(globalMock).toHaveBeenCalledWith(
'expected string: {"a": "hello", "b": 1} 0 $a $b $#',
expectFunction,
undefined,
);
expect(globalMock).toHaveBeenCalledWith(
'expected string: {"a": "world", "b": 1} 1 $a $b $#',
expectFunction,
undefined,
);
});
});
});

Expand Down
24 changes: 22 additions & 2 deletions packages/jest-each/src/table/array.ts
Expand Up @@ -10,6 +10,8 @@ import * as util from 'util';
import type {Global} from '@jest/types';
import {format as pretty} from 'pretty-format';
import type {EachTests} from '../bind';
import type {Templates} from './interpolation';
import {interpolateVariables} from './interpolation';

const SUPPORTED_PLACEHOLDERS = /%[sdifjoOp]/g;
const PRETTY_PLACEHOLDER = '%p';
Expand All @@ -18,11 +20,29 @@ const PLACEHOLDER_PREFIX = '%';
const ESCAPED_PLACEHOLDER_PREFIX = /%%/g;
const JEST_EACH_PLACEHOLDER_ESCAPE = '@@__JEST_EACH_PLACEHOLDER_ESCAPE__@@';

export default (title: string, arrayTable: Global.ArrayTable): EachTests =>
normaliseTable(arrayTable).map((row, index) => ({
export default (title: string, arrayTable: Global.ArrayTable): EachTests => {
if (isTemplates(title, arrayTable)) {
return arrayTable.map((template, index) => ({
arguments: [template],
title: interpolateVariables(title, template, index).replace(
ESCAPED_PLACEHOLDER_PREFIX,
PLACEHOLDER_PREFIX,
),
}));
}
return normaliseTable(arrayTable).map((row, index) => ({
arguments: row,
title: formatTitle(title, row, index),
}));
};

const isTemplates = (
title: string,
arrayTable: Global.ArrayTable,
): arrayTable is Templates =>
!SUPPORTED_PLACEHOLDERS.test(interpolateEscapedPlaceholders(title)) &&
!isTable(arrayTable) &&
arrayTable.every(col => col != null && typeof col === 'object');

const normaliseTable = (table: Global.ArrayTable): Global.Table =>
isTable(table) ? table : table.map(colToRow);
Expand Down
86 changes: 86 additions & 0 deletions packages/jest-each/src/table/interpolation.ts
@@ -0,0 +1,86 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import {isPrimitive} from 'jest-get-type';
import {format as pretty} from 'pretty-format';

export type Template = Record<string, unknown>;
export type Templates = Array<Template>;
export type Headings = Array<string>;

export const interpolateVariables = (
title: string,
template: Template,
index: number,
): string =>
Object.keys(template)
.reduce(getMatchingKeyPaths(title), []) // aka flatMap
.reduce(replaceKeyPathWithValue(template), title)
.replace('$#', '' + index);

const getMatchingKeyPaths = (title: string) => (
matches: Headings,
key: string,
) => matches.concat(title.match(new RegExp(`\\$${key}[\\.\\w]*`, 'g')) || []);

const replaceKeyPathWithValue = (template: Template) => (
title: string,
match: string,
) => {
const keyPath = match.replace('$', '').split('.');
const value = getPath(template, keyPath);

if (isPrimitive(value)) {
return title.replace(match, String(value));
}
return title.replace(match, pretty(value, {maxDepth: 1, min: true}));
};

/* eslint import/export: 0*/
export function getPath<
Obj extends Template,
A extends keyof Obj,
B extends keyof Obj[A],
C extends keyof Obj[A][B],
D extends keyof Obj[A][B][C],
E extends keyof Obj[A][B][C][D]
>(obj: Obj, path: [A, B, C, D, E]): Obj[A][B][C][D][E];
export function getPath<
Obj extends Template,
A extends keyof Obj,
B extends keyof Obj[A],
C extends keyof Obj[A][B],
D extends keyof Obj[A][B][C]
>(obj: Obj, path: [A, B, C, D]): Obj[A][B][C][D];
export function getPath<
Obj extends Template,
A extends keyof Obj,
B extends keyof Obj[A],
C extends keyof Obj[A][B]
>(obj: Obj, path: [A, B, C]): Obj[A][B][C];
export function getPath<
Obj extends Template,
A extends keyof Obj,
B extends keyof Obj[A]
>(obj: Obj, path: [A, B]): Obj[A][B];
export function getPath<Obj extends Template, A extends keyof Obj>(
obj: Obj,
path: [A],
): Obj[A];
export function getPath<Obj extends Template>(
obj: Obj,
path: Array<string>,
): unknown;
export function getPath(
template: Template,
[head, ...tail]: Array<string>,
): unknown {
if (!head || !template.hasOwnProperty || !template.hasOwnProperty(head))
return template;
return getPath(template[head] as Template, tail);
}