8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Jestのテーブル駆動テストの構文の仕組みを探る

Posted at

TL; DR

こんな書き方ができるのはなぜ?
test.each`
  a    | b    | expected
  ${1} | ${1} | ${2}
  ${1} | ${2} | ${3}
  ${2} | ${1} | ${3}
`('returns $expected when $a is added $b', ({a, b, expected}) => {
  expect(a + b).toBe(expected);
});

(コードは https://jestjs.io/ja/docs/api#%EF%BC%92-testeachtablename-fn-timeout より引用)

  • テンプレートリテラルでテストケースを記述できるのは、JavaScriptの タグ付きテンプレートの構文を使用しているため
  • 関数内では、正規表現でテンプレートリテラルをパースし変数とデータを紐づけている (実装)

はじめに

Jestでは、冒頭のようにテーブル駆動テスト(似たような実装のテストケースをfor文等でまとめる書き方)を楽に書くメソッドが用意されています。
Goでテーブル駆動に馴染みがあったので手になじんだ反面、「なんだこの文法は??」と面喰いました。

本記事では、Jestの test.each の仕組みを調べてみたまとめを紹介します。

JSの「タグ付きテンプレート」構文

結論から言うと、上記は「タグ付きテンプレート」の構文を利用したものです。

関数名`テンプレートリテラル` と書くことで、テンプレートリテラルを関数の引数に渡すことができます(渡されるときは配列になるので注意)。

function printTemplate(arg) {
	console.log(arg);
}

printTemplate(`abc`);
printTemplate`de`;
結果
abc
[ 'de' ]

テンプレートリテラルを配列として受け取っているのは、プレイスホルダー(${})の前後で各要素が区切られるためです。

function debugTemplate(strs) {
	strs.forEach((str, i) => {
        console.log(`strs[${i}] == ${str}`);
    });
}

const name = "Taro";
debugTemplate`Hello, ${name}!`;
strs[0] == Hello, 
strs[1] == !

ここまでだと特にありがたみはありませんが、タグ付きテンプレートでは テンプレートリテラル内の式も引数として渡すことができます

function printMsg(strs, name, language) {
	if (language === "JP") {
		console.log(`こんにちは、${name}!`);
	} else {
		console.log(`Hello, ${name}!`);
	}
}

// 1つ目のプレースホルダーは `name` へ、2つめのプレースホルダーは `language` へ代入される
printMsg`${"John"} ${"EN"}`;
printMsg`${"Taro"} ${"JP"}`;
Hello, John!
こんにちは、Taro!

Jest以外でも、DSLを作る時に重宝しそうです。

test.each の実装

続いて、 test.each の実装を順を追って見ていきましょう1

test.eachの定義

importが深くて追うのが難しかったですが、おそらくここです。

packages/jest-each/src/index.ts
const each = (
  table: Global.EachTable,
  ...data: Global.TemplateData
): ReturnType<typeof install> =>
  install(globalThis as unknown as Global, table, ...data);

引数の型の定義は以下の通りで、それぞれ以下の要素が渡されます。

packages/jest-types/src/Global.ts
export type TemplateTable = TemplateStringsArray;
export type EachTable = ArrayTable | TemplateTable;
export type TemplateData = ReadonlyArray<unknown>;
  • table: テンプレートリテラルを表す文字列配列
  • data: プレースホルダーに入った値の配列

(ちなみに、TemplateStringsArray はタグ付きテンプレートの第一引数(テンプレートリテラルを表す文字列配列)を表す組み込み型です)

型こそついていますが、「JSの「タグ付きテンプレート」構文」で例に挙げた関数と同じ引数になっていることが確認できます。
冒頭の例で言うと、 table, data には以下の値が入ります。

テスト例(再掲)
test.each`
  a    | b    | expected
  ${1} | ${1} | ${2}
  ${1} | ${2} | ${3}
  ${2} | ${1} | ${3}
`('returns $expected when $a is added $b', ({a, b, expected}) => {
  expect(a + b).toBe(expected);
});
table === ["\n  a    | b    | expected\n ", " | ", " | ", " | \n ", " | ", ..., "\n"]
data === [1, 1, 2, 1, 2, 3, 2, 1, 3]

テストの実行

テスト関数は、test.each が呼び出す install 内で初期化されます。

packages/jest-each/src/index.ts
const install = (
  g: Global,
  table: Global.EachTable,
  ...data: Global.TemplateData
) => {
  // ...

  const test = (
    title: string,
    test: Global.EachTestFn<Global.TestFn>,
    timeout?: number,
  ) => bind(g.test)(table, ...data)(title, test, timeout);

  // ...
  return {describe, fdescribe, fit, it, test, xdescribe, xit, xtest};
};

そして、bind でテンプレートリテラルをパースし、各テストケースをループで実行しています。

packages/jest-each/src/bind.ts
export default <EachCallback extends Global.TestCallback>(
    cb: GlobalCallback,
    supportsDone: boolean = true,
  ) =>
  (table: Global.EachTable, ...taggedTemplateData: Global.TemplateData) =>
    function eachBind(
      title: string,
      test: Global.EachTestFn<EachCallback>,
      timeout?: number,
    ): void {
      try {
        // テンプレートリテラルをパースし、テストケースの配列に整形
        // (引数は配列形式でも指定可能なため isArrayTable で分岐している)
        const tests = isArrayTable(taggedTemplateData)
          ? buildArrayTests(title, table)
          : buildTemplateTests(title, table, taggedTemplateData);

        // 各テストケースを実行
        return tests.forEach(row =>
          cb(
            row.title,
            applyArguments(supportsDone, row.arguments, test), // テスト実行の本体
            timeout,
          ),
        );
      } catch (e) {
        const error = new ErrorWithStack(e.message, eachBind);
        return cb(title, () => {
          throw error;
        });
      }
    };

テンプレートリテラルのパース

テンプレートリテラルのパースは buildTemplateTests で行われます。

packages/jest-each/src/bind.ts
const buildTemplateTests = (
  title: string,
  table: Global.EachTable,
  taggedTemplateData: Global.TemplateData,
): EachTests => {
  // ヘッダー(最初の行の変数名一覧)をパース
  // テンプレートリテラル配列の0番目要素(=はじめのプレースホルダーの直前までの文字列)を渡す
  const headings = getHeadingKeys(table[0] as string);
  // テストケースの形式(プレースホルダーの個数)が正しいかチェック
  validateTemplateTableArguments(headings, taggedTemplateData);
  // テンプレートリテラルとデータをテストケースの配列に整形
  return convertTemplateTable(title, headings, taggedTemplateData);
};
冒頭の例の場合に渡される引数
title === 'returns $expected when $a is added $b' // テストケースのタイトル
table === ["\n  a    | b    | expected\n ", " | ", " | ", " | \n ", " | ", ..., "\n"] // テンプレートリテラル
data === [1, 1, 2, 1, 2, 3, 2, 1, 3] // プレースホルダーの中身

ヘッダーをパース

テンプレートリテラル最初の行の変数名一覧を取り出します。

テスト(再掲)
// 取得したいのは `a    | b    | expected` の部分 (変数名 ["a", "b", "expected"] を取り出す)
test.each`
  a    | b    | expected
  ${1} | ${1} | ${2}
  ${1} | ${2} | ${3}
  ${2} | ${1} | ${3}
`('returns $expected when $a is added $b', ({a, b, expected}) => {
  expect(a + b).toBe(expected);
});

シンプルに、正規表現で最初の行(正確には先頭に改行があるので2行目)の、| で区切られたパターンを抽出します。

packages/jest-each/src/validation.ts
// 引数 headingsはテンプレートリテラルの先頭部分(最初のプレースホルダーの直前まで)の文字列
export const extractValidTemplateHeadings = (headings: string): string => {
  // HEADINGS_FORMAT === /^\n\s*[^\s]+\s*(\|\s*[^\s]+\s*)*\n/g
  const matches = headings.match(HEADINGS_FORMAT);
  if (matches === null) {
    throw new Error(/*...*/);
  }

  return matches[0];
};

先頭行を取り出したら、 | でsplitして変数名文字列の配列(例: ["a", "b", "expected"])を得ます。

packages/jest-each/src/bind.ts
const getHeadingKeys = (headings: string): Array<string> =>
  // 空白が挟まっているかもしれないのでreplaceで除去
  extractValidTemplateHeadings(headings).replace(/\s/g, '').split('|');

テストケースの形式が正しいかチェック

続いて、プレースホルダーの個数に過不足が無いかチェックします。

export const validateTemplateTableArguments = (
  headings: Array<string>, // 変数名の配列
  data: TemplateData, // テストデータ(プレースホルダーの中身)の配列
): void => {
  // 各テストケースで変数を使うので、データ長 == 変数の個数 * テストケース数となる
  // テストケース数がテストケース数で割り切れないなら、プレースホルダーに過不足がある
  const missingData = data.length % headings.length;

  if (missingData > 0) {
    throw new Error(/*...*/)},
    );
  }
};
冒頭の例の場合に渡される引数
headings === ["a", "b", "expected"]
data === [1, 1, 2, 1, 2, 3, 2, 1, 3]

テンプレートリテラルとデータをテストケースの配列に整形

そして、テンプレートリテラルから得られた変数名とデータ(プレースホルダーの中身)を、テストケースの配列の形に整形します。

packages/jest-each/src/table/template.ts
// convertTemplateTable の実装
export default function template(
  title: string,
  headings: Headings,
  row: Global.Row,
): EachTests {
  // テストデータ一覧を行ごとに区切る
  const table = convertRowToTable(row, headings);
  // テストデータと変数名を突合し、テストケース(template)の配列を作成
  const templates = convertTableToTemplates(table, headings);
  return templates.map((template, index) => ({
    arguments: [template], // テストケース({変数名: 値}の形式のオブジェクト)
    title: interpolateVariables(title, template, index), // タイトル文字列生成(正規表現で頑張る)
  }));
}
冒頭の例の場合に渡される引数
title === 'returns $expected when $a is added $b' // テストケースのタイトル
headings === ["a", "b", "expected"] // 変数名
row === [1, 1, 2, 1, 2, 3, 2, 1, 3] // プレースホルダーの中身

余談ですが、 row は名前こそ「行」ですが中身はプレースホルダーの配列です。global.Rowglobal.TemplateData と同じ型です(型を分ける意図は不明)。

まずは、テストデータ一覧を行ごとに区切ります。

packages/jest-each/src/table/template.ts
// [1, 1, 2, 1, 2, 3, 2, 1, 3] -> [[1, 1, 2], [1, 2, 3], [2, 1, 3]]
const convertRowToTable = (row: Global.Row, headings: Headings): Global.Table =>
  // 行数分の長さのダミー配列を作ってから、その区間の部分配列を取得
  Array.from({length: row.length / headings.length}).map((_, index) =>
    row.slice(
      index * headings.length,
      index * headings.length + headings.length,
    ),
  );

Array.from は長さ row.length / headings.length の配列を生成するために使われています。{length} だけ指定してマップ関数を指定しないと、undefined の配列を返します(調べて初めて知った)。

// arrayに変換
Array.from("foo");  // ["f", "o", "o"]

// マップ関数を指定することも可能
Array.from("foo", x => x.toUpperCase()); // ["F", "O", "O"]

// lengthを使うと、インデックスをマップ可能
Array.from({length: 5}, (_, i) => i)); // [0, 1, 2, 3, 4]

// さらにマップ関数をなくすと?
console.log(Array.from({length: 5})); // [ undefined, undefined, undefined, undefined, undefined ]

閑話休題。

続いて、 convertTableToTemplates で変数名とテストデータを突合します。
Object.assign を使って、変数名をキー、対応するテストデータを値として、オブジェクトに代入しています。

packages/jest-each/src/table/template.ts
const convertTableToTemplates = (
  table: Global.Table,
  headings: Headings,
): Templates =>
  table.map(row =>
    row.reduce<Template>(
      (acc, value, index) => Object.assign(acc, {[headings[index]]: value}),
      {},
    ),
  );
冒頭の例の場合
table: [[1, 1, 2], [1, 2, 3], [2, 1, 3]]
headings: ["a", "b", "expected"]
return: [{a: 1, b: 1, expected: 2}, {a: 1, b: 2, expected: 3}, {a: 2, b: 1, expected: 3}]

パースされたテストケースを実行

いよいよ大詰め、整形されたテストデータをテスト関数へ渡します。 といっても、先ほどのテストデータオブジェクトをテスト関数に丸ごと渡しているだけです。

packages/jest-each/src/bind.ts
// 各テストケースを実行
return tests.forEach(row =>
   cb(
       row.title,
       applyArguments(supportsDone, row.arguments, test), // テスト実行の本体
       timeout,
    ),
);
packages/jest-each/src/bind.ts
const applyArguments = <EachCallback extends Global.TestCallback>(
  supportsDone: boolean,
  params: ReadonlyArray<unknown>,
  test: Global.EachTestFn<EachCallback>,
): Global.EachTestFn<any> =>
  supportsDone && params.length < test.length
    // row.arguments は1要素の配列なので実質 test(template, done)
    ? (done: Global.DoneFn) => test(...params, done)
    : () => test(...params);

ただし、引数をオブジェクトにまとめて渡しているのがポイントで、このためテスト関数では引数を分割代入する必要があります。

test.each`
  a    | b    | expected
  ${1} | ${1} | ${2}
  ${1} | ${2} | ${3}
  ${2} | ${1} | ${3}
`('returns $expected when $a is added $b', ({a, b, expected}) => { // (a, b, expected) ではダメ!
  expect(a + b).toBe(expected);
});

とはいえ、引数を一般的な形式 (a, b, expected) ではなく分割代入 ({a, b, expected}) 代入にすることによって、順番を入れ替えたり使わないものを省略したりすることが可能です。おそらく利便性のために意図的にこのような設計にしたのだと思います。

おわりに

以上、Jestのテーブル駆動テストの仕組みについての記事でした。
構文を知ってしまえば「なるほど!」となるのですが、用語を知るまではググるのにも難儀しました。リファレンスを通読すれば用語は網羅できるのでしょうが...
JS/TS力を高めて、いつか自分でもタグ付きテンプレートを活用してみたいと思います。

  1. 調べたのは執筆時点の最新版(v29.0.1)です。

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?