Skip to content

Commit

Permalink
feat(jest-each): add support for interpolation with object properties (
Browse files Browse the repository at this point in the history
  • Loading branch information
frozenbonito authored and mprinc committed May 9, 2021
1 parent 67fdb75 commit 7e610ac
Show file tree
Hide file tree
Showing 7 changed files with 268 additions and 77 deletions.
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);
}

0 comments on commit 7e610ac

Please sign in to comment.