スナップショットテストといえばJestsnap-shot-itなどが有名です。 しかし、どちらもそれ自体がAssertionを含むため、比較したいだけには大掛かりな仕組みです。

自分がスナップショットテストを書くときはいつもその場でパターン化したテストコードを書いています。

スナップショットテスト

スナップショットテストは、何かの入力を受け取り、その出力とファイルに保存しておいた前回の出力結果を比較してテストする手法だと思います。 主な目的はコードの変更して既存の機能を壊してしまうようなリグレッションを防ぐことです。

Babelプラグインのようなコード変換、パーサ、ジェネレータなど何かの入力を受け取り出力できるものに利用できます。

入力と出力の組み合わせをどんどん作っていけばテストも増えるので、テストを簡単に増やせるのが特徴的です。

スナップショットテストを書く

次のようなテストコードを書きます。(これはMochaを使っていますが大体どんなテストフレームワークでも同じことが出来ます。)

次のスナップショットでは、transformというJSONを入力に受け取り、JSONを出力する関数をテストしています。

snapshot-test.js:

const fs = require("fs");
const path = require("path");
const assert = require("assert");
const fixturesDir = path.join(__dirname, "snapshots");
// transform function
const transform = require("../transform");

describe("Snapshot testing", () => {
  fs.readdirSync(fixturesDir)
    .map(caseName => {
      const normalizedTestName = caseName.replace(/-/g, " ");
      it(`Test ${normalizedTestName}`, function() {
        const fixtureDir = path.join(fixturesDir, caseName);
        const actualFilePath = path.join(fixtureDir, "input.json");
        const actualContent = JSON.parse(fs.readFileSync(actualFilePath, "utf-8"));
        const actual = transform(actualContent);
        const expectedFilePath = path.join(fixtureDir, "output.json");
        // Usage: update snapshots
        // UPDATE_SNAPSHOT=1 npm test
        if (!fs.existsSync(expectedFilePath) || process.env.UPDATE_SNAPSHOT) {
          fs.writeFileSync(expectedFilePath, JSON.stringify(actual, null, 4));
          this.skip(); // skip when updating snapshots
          return;
        }
        // compare input and output
        const expected = JSON.parse(fs.readFileSync(expectedFilePath, "utf-8"));
        assert.deepEqual(
          actual,
          expected,
          `
${fixtureDir}
${JSON.stringify(actual)}
`
        );
      });
    });
});

スナップショットファイルの作成

先ほどのテストに書いた fixturesDir に対して、テストケース毎のディレクトリを作っていくだけです。 それぞれのディレクトリには、入力となるinput.json、出力となるoutput.jsonを作って配置していくだけです。

├── snapshot-test.js
└── snapshots
    ├── テストケース名1
    │   ├── input.json
    │   └── output.json
    └── テストケース名2
        ├── input.json
        └── output.json

スナップショットのテスト作成

スナップショットテストの良いところは、前回の結果とずれてないかを目視で確認するのが主な目的であるところです。つまり、テストケースの期待する結果をわざわざ自分で書く必要はありません。

先ほどのテストもUPDATE_SNAPSHOT=1という環境変数を付けて実行すると、input.jsonからoutput.jsonのファイルを自動的に作成してくれます。

UPDATE_SNAPSHOT=1 npm test

この自動的に作られたoutput.jsonが期待している形ならコミットして終わりです。 次からは、環境変数をつけないで実行すれば単に比較されます。

npm test 

また新しいスナップショットテストを追加したい場合は同じようにinput.jsonを作って、output.jsonが期待するものかを確認して追加を繰り返すだけです。

既存の挙動を変えたときもUPDATE_SNAPSHOT=1 npm testで既存のスナップショットがすべて更新できるので、テストの変更に必要な労力は殆どありません(その結果が期待するものかは確認が必要です)

このようにテストケースがコピペのような形で増やせて、かつ今まで追加したスナップショットテストがリグレッションを防いでくれます。

おわりに

すべてのパターンに使えるテスト方法ではありませんが、変換ツールやCLIといったものをテストする際には費用対効果が良いのでおすすめです。 Jestなどを使わずにスナップショットテストを書くメリットとしてはAssertionを自由にカスタマイズできる点です。assert.deepEqual(actual, expected);はテスト内容によって異なるAssertionを使えます。

スナップショットテストを使ってる例:

TypeScriptの例

import * as fs from "fs";
import * as path from "path";
import * as assert from "assert";
// transform function
import { transform } from "../src/transform";

const fixturesDir = path.join(__dirname, "snapshots");
describe("Snapshot testing", () => {
    fs.readdirSync(fixturesDir)
        .map(caseName => {
            const normalizedTestName = caseName.replace(/-/g, " ");
            it(`Test ${normalizedTestName}`, async function () {
                const fixtureDir = path.join(fixturesDir, caseName);
                const actualFilePath = path.join(fixtureDir, "input.js");
                const actualContent = fs.readFileSync(actualFilePath, "utf-8");
                const actualOptionFilePath = path.join(fixtureDir, "options.json");
                const actualOptions = fs.existsSync(actualOptionFilePath)
                    ? JSON.parse(fs.readFileSync(actualOptionFilePath, "utf-8"))
                    : {};
                const actual = transform(actualContent, actualOptions);
                const expectedFilePath = path.join(fixtureDir, "output.txt");
                // Usage: update snapshots
                // UPDATE_SNAPSHOT=1 npm test
                if (!fs.existsSync(expectedFilePath) || process.env.UPDATE_SNAPSHOT) {
                    fs.writeFileSync(expectedFilePath, actual);
                    this.skip(); // skip when updating snapshots
                    return;
                }
                // compare input and output
                const expectedContent = fs.readFileSync(expectedFilePath, "utf-8");
                assert.deepStrictEqual(
                    actual,
                    expectedContent
                );
            });
        });
});

JSONのシリアライズするときにfilePathを<root> として相対的な値に変換するパターン

import * as fs from "fs";
import * as path from "path";
import * as assert from "assert";
import { TextlintKernelOptions } from "../src/textlint-kernel-interface";
import { TextlintKernel } from "../src";

const SNAPSHOTS_DIRECTORY = path.join(__dirname, "snapshots");
const pathReplacer = (dirPath: string) => {
    return function replacer(key: string, value: any) {
        if (key === "filePath") {
            return value.replace(dirPath, "<root>");
        }
        return value;
    };
};
const normalizeJSON = (o: object, rootDir: string) => {
    return JSON.parse(JSON.stringify(o, pathReplacer(rootDir)));
};

describe("textlint-kernel-snapshots", () => {
    fs.readdirSync(SNAPSHOTS_DIRECTORY)
        .forEach(caseName => {
            const normalizedTestName = caseName.replace(/-/g, " ");
            it(`Test ${normalizedTestName}`, async function() {
                const fixtureDir = path.join(SNAPSHOTS_DIRECTORY, caseName);
                const actualFilePath = path.join(fixtureDir, "input.md");
                const actualContent = fs.readFileSync(actualFilePath, "utf-8");
                const actualOptionFilePath = path.join(fixtureDir, "options.ts");
                const actualOptions: TextlintKernelOptions = await import(actualOptionFilePath).then(m => m.options);
                const kernel = new TextlintKernel();
                const actualResults = normalizeJSON(await kernel.lintText(actualContent, actualOptions), SNAPSHOTS_DIRECTORY);
                const expectedFilePath = path.join(fixtureDir, "output.json");
                // Usage: update snapshots
                // UPDATE_SNAPSHOT=1 npm test
                if (!fs.existsSync(expectedFilePath) || process.env.UPDATE_SNAPSHOT) {
                    fs.writeFileSync(expectedFilePath, JSON.stringify(actualResults, null, 4));
                    this.skip(); // skip when updating snapshots
                    return;
                }
                // compare input and output
                const expectedContent = JSON.parse(fs.readFileSync(expectedFilePath, "utf-8"));
                assert.deepStrictEqual(
                    actualResults,
                    expectedContent
                );
            });
        });
});