mizdra's blog

ぽよぐらみんぐ

Node.js でフィクスチャーファイルを簡単に作成するライブラリを作った

よく Node.js で CLI ツールを作っているのだけど、そのテストコードを書くのが大変だなあという課題感があった。例えば、ESLint のエラーをルールごとにグルーピングして、ルール単位で修正できるツール (eslint-interactive) を作ったときは、以下のようなテストコードを書いていた。

// fixtures/semi.js
const val = 0
// fixtures/prefer-const.js
let val = 0;
// fixtures/.eslintrc.js
module.exports = {
  root: true,
  parserOptions: { ecmaVersion: 2022 },
  rules: {
    'semi': 'error',
    'prefer-const': 'error',
  },
};
// src/core.test.ts
import { Core, takeRuleStatistics } from './core.js';
import { describe, it } from 'vitest';
import dedent from 'dedent';
import { join } from 'node:path';

const __dirname = new URL('.', import.meta.url).pathname;
const fixtureDir = join(__dirname, '../fixtures');

describe('Core', () => {
  const core = new Core({
    patterns: [fixtureDir],
  });
  const results = await core.lint();

  it('lint 結果の概要を出力できる', async () => {
    const formatted = await core.formatResultSummary(results);
    expect(formatted).toBe(dedent`
    ╔══════════════╤═══════╤═════════╤════════════╤═════════════════╗
    ║ Rule         │ Error │ Warning │ is fixable │ has suggestions ║
    ╟──────────────┼───────┼─────────┼────────────┼─────────────────╢
    ║ semi         │ 1     │ 0       │ 1          │ 0               ║
    ╟──────────────┼───────┼─────────┼────────────┼─────────────────╢
    ║ prefer-const │ 1     │ 0       │ 1          │ 0               ║
    ╚══════════════╧═══════╧═════════╧════════════╧═════════════════╝
  `);
  });
  test('rule ごとの lint 結果を返せる', async () => {
    const statistics = takeRuleStatistics(results);
    expect(statistics).toStrictEqual([
      expect.objectContaining({ ruleId: 'semi', errorCount: 1 }),
      expect.objectContaining({ ruleId: 'prefer-const', errorCount: 1 }),
    ]);
  });
});

eslint-interactive は *.js を ESLint で lint するということを内部的にやっているので、テストするには *.js (いわゆるフィクスチャーファイル) を事前に用意しておく必要がある。上記のテストコードでは、事前に fixtures ディレクトリに *.js を置いておく戦略を取っている。

fixtures ディレクトリの問題点

シンプルなアプローチなのだけど、個人的には以下のような問題点があると感じている。

  • テストコードを読むだけだとテストの挙動が分かりにくい
    • fixtures ディレクトリにはどんなファイルがあって、その内容はどうなっているかは、テストコードからは分からない
      • fixtures ディレクトリに見に行かないといけない
    • ファイルを何度も行き来してようやく挙動を理解できる...みたいな
  • テストケースごとに利用するフィクスチャーファイルを変更しづらい
    • こっちのテストケースでは fixtures/semi.js は要らないので除外したい...が手軽にできない
    • fixtures/test-case-1 のようにテストケースごとにディレクトリを切れば可能だが...
      • 逆にテストケース間でフィクスチャーファイルの共有が難しくなる

という訳で、これらの問題点を解決するために、@mizdra/inline-fixture-files というライブラリを作った。

@mizdra/inline-fixture-files とは

@mizdra/inline-fixture-files はフィクスチャーファイルをテストコードの中に埋め込むことができるライブラリである。

github.com

先程のテストコードを @mizdra/inline-fixture-files を使って書き換えると以下のようになる。

// src/core.test.ts
import { Core, takeRuleStatistics } from './core.js';
import { describe, it } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import dedent from 'dedent';
import { createIFF } from '@mizdra/inline-fixture-files';

const fixtureDir = join(tmpdir(), 'inline-fs-fixtures', process.env['VITEST_POOL_ID']!);

await createIFF(
  {
    'semi.js': dedent`
      const val = 0
    `,
    'prefer-const.js': dedent`
      let val = 0;
    `,
    '.eslintrc.js': dedent`
      module.exports = {
        root: true,
        parserOptions: { ecmaVersion: 2022 },
        rules: {
          'semi': 'error',
          'prefer-const': 'error',
        },
      };
    `,
  },
  { rootDir: fixtureDir },
);

describe('Core', () => {
  const core = new Core({
    patterns: [createIFF.rootDir],
  });
  const results = await core.lint();

  it('lint 結果の概要を出力できる', async () => {
    const formatted = await core.formatResultSummary(results);
    expect(formatted).toBe(dedent`
    ╔══════════════╤═══════╤═════════╤════════════╤═════════════════╗
    ║ Rule         │ Error │ Warning │ is fixable │ has suggestions ║
    ╟──────────────┼───────┼─────────┼────────────┼─────────────────╢
    ║ semi         │ 1     │ 0       │ 1          │ 0               ║
    ╟──────────────┼───────┼─────────┼────────────┼─────────────────╢
    ║ prefer-const │ 1     │ 0       │ 1          │ 0               ║
    ╚══════════════╧═══════╧═════════╧════════════╧═════════════════╝
  `);
  });
  test('rule ごとの lint 結果を返せる', async () => {
    const statistics = takeRuleStatistics(results);
    expect(statistics).toStrictEqual([
      expect.objectContaining({ ruleId: 'semi', errorCount: 1 }),
      expect.objectContaining({ ruleId: 'prefer-const', errorCount: 1 }),
    ]);
  });
});

フィクスチャーファイルがインラインで書かれているので、ファイルを行ったり来たりせずとも、テストコードを読むだけでテストの挙動が分かるようになった。

フィクスチャーファイルのパスに型安全にアクセスする

createIFF の戻り値に paths というプロパティがある。これはフィクスチャーファイルの生のパスを保持するオブジェクトである。厳密に型付けされていて、フィクスチャーファイルの生のパスに型安全にアクセスできる。

const iff = await createIFF(
  {
    'semi.js': dedent`
      const val = 0
    `,
    'prefer-const.js': dedent`
      let val = 0;
    `,
  },
  { rootDir: fixtureDir },
);
iff.paths['semi.js']; // `join(fixtureDir, 'semi.js')` と同じ
iff.paths['prefer-const.js']; // `join(fixtureDir, 'prefer-const.js')` と同じ
iff.paths['not-exist.js']; // コンパイルエラー

rootDir をランダムに切り替える

@mizdra/inline-fixture-files をそのまま使っても便利だが、rootDir をランダムに切り替えるユーティリティを作ると、より便利になる。rootDir がランダムに切り替わるので、各テストケースを独立に保てる (あるテストケースでのフィクスチャーの変更が、他のテストケースに影響しない)。

// example/util/create-iff-by-random-root-dir.ts
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createIFF, Directory } from '@mizdra/inline-fixture-files';

/**
 * NOTE: `fixtureDir` の肥大化を防ぐため、テストの実行毎に `fixtureDir` を削除する
 * コードを仕込んでおくと良い。
 * ```typescript
 * // vitest.setup.ts
 * import { rm } from 'node:fs/promises';
 * await rm(fixtureDir, { recursive: true, force: true });
 * ```
 */
const fixtureDir = join(tmpdir(), 'inline-fs-fixtures', process.env['VITEST_POOL_ID']!);

/** `rootDir` をランダムに切り替えつつ `createIFF` を呼び出すユーティリティ */
export async function createIFFByRandomRootDir<const T extends Directory>(directory: T) {
  const getRandomRootDir = () => join(fixtureDir, randomUUID());
  const iff = await createIFF(directory, { rootDir: getRandomRootDir() });
  return {
    ...iff,
    fork: async function forkImpl<const U extends Directory>(additionalDirectory: U) {
      const forkedIff = await iff.fork(additionalDirectory, { rootDir: getRandomRootDir() });
      return { ...forkedIff, fork: forkImpl };
    },
  };
// example/02-random-root-dir.test.ts
import dedent from 'dedent';
import { ESLint } from 'eslint';
import { expect, test } from 'vitest';
import { createIFFByRandomRootDir } from './util/create-iff-by-random-root-dir.js';

test('eslint reports lint errors', async () => {
  const iff = await createIFFByRandomRootDir({
    '.eslintrc.cjs': `module.exports = { root: true, rules: { semi: 'error' } };`,
    'src': {
      'semi.js': dedent`
        var withSemicolon = 1;
        var withoutSemicolon = 2
      `,
    },
  });

  const eslint = new ESLint({ cwd: iff.rootDir, useEslintrc: true });
  const results = await eslint.lintFiles([iff.paths['src/semi.js']]);
  const formatter = await eslint.loadFormatter('unix');
  const resultText = formatter.format(results);
  expect(resultText).toStrictEqual(dedent`
    ${iff.paths['src/semi.js']}:2:25: Missing semicolon. [Error/semi]

    1 problem
  `);
});

テストケース間でフィクスチャーファイルを共有する

iff.fork という、以前に作成されたフィクスチャファイルを引き継ぎながらルートディレクトリを変更する API がある。これにより、フィクスチャファイルをテストケース間で共有できる。

// example/03-share-fixtures-between-test-cases.test.ts
import { readFile } from 'node:fs/promises';
import dedent from 'dedent';
import { ESLint } from 'eslint';
import { describe, expect, it } from 'vitest';
import { createIFFByRandomRootDir } from './util/create-iff-by-random-root-dir.js';

describe('eslint', async () => {
  // `.eslintrc.cjs` をテストケース間で共有する
  const baseIFF = await createIFFByRandomRootDir({
    '.eslintrc.cjs': `module.exports = { root: true, rules: { semi: 'error' } };`,
  });

  it('reports lint errors', async () => {
    // fork` を使うと、`baseIFF` からフィクスチャを継承しながら、
    // フィクスチャの `rootDir` を変更できる。
    const iff = await baseIFF.fork({
      src: {
        'semi.js': dedent`
          var withSemicolon = 1;
          var withoutSemicolon = 2
        `,
      },
    });
    const eslint = new ESLint({ cwd: iff.rootDir, useEslintrc: true });
    const results = await eslint.lintFiles([iff.paths['src/semi.js']]);
    const formatter = await eslint.loadFormatter('unix');
    const resultText = formatter.format(results);
    expect(resultText).toStrictEqual(dedent`
      ${iff.paths['src/semi.js']}:2:25: Missing semicolon. [Error/semi]
  
      1 problem
    `);
  });
  it('fix lint errors', async () => {
    const iff = await baseIFF.fork({
      src: {
        'semi.js': dedent`
          var withoutSemicolon = 2
        `,
      },
    });
    const eslint = new ESLint({ cwd: iff.rootDir, useEslintrc: true, fix: true });
    const results = await eslint.lintFiles([iff.paths['src/semi.js']]);

    expect(await readFile(iff.paths['src/semi.js'], 'utf8')).toMatchInlineSnapshot('"var withoutSemicolon = 2"');
    await ESLint.outputFixes(results);
    expect(await readFile(iff.paths['src/semi.js'], 'utf8')).toMatchInlineSnapshot('"var withoutSemicolon = 2;"');
  });
});

内部的には「以前に作成されたフィクスチャファイルを引き継ぐ」ために、baseIFF からファイルをコピーしてきている。そのため fork する度にコピーのコストが発生する。ただ、baseIFF からのコピーは (システムが Copy-on-Write をサポートしていれば) Copy-on-Write で行われるので、多くの場合は無視できるレベルのコストだと思う。

まとめ

  • fixtures ディレクトリパターンはテストコードの可読性とメンテナンス性に課題がある
  • @mizdra/inline-fixture-files を使うと、フィクスチャーファイルをテストコードの中に埋め込める
  • 型安全にフィクスチャーファイルのパスにアクセスできたり、fork でフィクスチャーファイルをテストケース間で共有できたりと、便利な機能がある

どうぞお使いください。

github.com

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.