Repository: jestjs/jest (TypeScript)

Issue:
`test.each` should perform string interpolation with object properties
<!-- Love Jest? Please consider supporting our collective: 👉  https://opencollective.com/jest/donate -->

## 🚀 Feature Proposal

When giving an array of objects to the `test.each` overload that accepts an array, and not a template strings table, jest should interpolate the object's properties into the test name.

## Motivation

The current interpolation of template strings works great, but it doesn't provide any hook for type-safety. This is problematic when scaling up to large and complex test cases, and in TypeScript, it allows `any` to sneak into your test functions. It also is more intuitive, it left me wondering for a while why a transformation to a type-safe version of a test wasn't causing interpolation of the test case.

## Example

We can do something like this right now:

```typescript
test.each`
  a    | b    | expectedResult
  ${1} | ${1} | ${2}
  ${2} | ${2} | ${4}
`('add($a, $b) === $expectedResult', ({ a, b, expectedResult }) => {
  expect(a + b).toEqual(expectedResult);
});
```

Which will output the test cases:
√ add(1, 1) === 2
√ add(2, 2) === 4

In order to achieve type-safety on the same test, I would make this transformation:

```typescript
type AddExample = {
  a: number;
  b: number;
  expectedResult: number;
};

test.each<AddExample>([
  { a: 1, b: 1, expectedResult: 2 },
  { a: 2, b: 2, expectedResult: 4 },
])('add($a, $b) === $expectedResult', ({ a, b, expectedResult }) => {
  expect(a + b).toEqual(expectedResult);
});
```

Which I would expect should output the same test cases as above, but instead:
√ add($a, $b) === $expectedResult
√ add($a, $b) === $expectedResult

## Pitch

It is isomorphic to functionality in the core library, but I believe it cannot be implemented as an extension.


Hints:
@mattphillips thoughts? Seems reasonable to me
This has been something I've wanted to add for a while but not got around to it and I think we should add parity with the tagged template table with `$` syntax for sure 👍 


Hey @mattphillips, I was looking at the code to see how I could contribute this change. Was wondering what your thoughts were on ensuring it's not breaking. Perhaps if the test name contains any of the printf special characters, use the printf formatting logic, else use variable interpolation?
Found this in my inbox and noticed it hasn't been replied to, so I'll just go ahead and answer from my perspective :sweat_smile: 
@nth-commit I would say as long as we land it in a major version (27.0.0 is likely coming up soon), it'd be fine to have the small breaking change. It's particularly small since only existing `$variable`s will  be interpolated, `$b` if there is no `b` variable in the template string is already being ignored, so most test case names even if they have `$` in their name will not be affected.

Patch:
diff --git a/packages/jest-each/src/table/array.ts b/packages/jest-each/src/table/array.ts
--- a/packages/jest-each/src/table/array.ts
+++ b/packages/jest-each/src/table/array.ts
@@ -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';
@@ -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);
diff --git a/packages/jest-each/src/table/interpolation.ts b/packages/jest-each/src/table/interpolation.ts
new file mode 100644
--- /dev/null
+++ b/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);
+}
diff --git a/packages/jest-each/src/table/template.ts b/packages/jest-each/src/table/template.ts
--- a/packages/jest-each/src/table/template.ts
+++ b/packages/jest-each/src/table/template.ts
@@ -7,13 +7,9 @@
  */
 
 import type {Global} from '@jest/types';
-import {isPrimitive} from 'jest-get-type';
-import {format as pretty} from 'pretty-format';
 import type {EachTests} from '../bind';
-
-type Template = Record<string, unknown>;
-type Templates = Array<Template>;
-type Headings = Array<string>;
+import type {Headings, Template, Templates} from './interpolation';
+import {interpolateVariables} from './interpolation';
 
 export default (
   title: string,
@@ -24,7 +20,7 @@ export default (
   const templates = convertTableToTemplates(table, headings);
   return templates.map((template, index) => ({
     arguments: [template],
-    title: interpolate(title, template, index),
+    title: interpolateVariables(title, template, index),
   }));
 };
 
@@ -46,71 +42,3 @@ const convertTableToTemplates = (
       {},
     ),
   );
-
-const interpolate = (title: string, template: Template, index: number) =>
-  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);
-}


Test patch:
diff --git a/packages/jest-each/src/__tests__/array.test.ts b/packages/jest-each/src/__tests__/array.test.ts
--- a/packages/jest-each/src/__tests__/array.test.ts
+++ b/packages/jest-each/src/__tests__/array.test.ts
@@ -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,
+        );
+      });
     });
   });