よく 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
はフィクスチャーファイルをテストコードの中に埋め込むことができるライブラリである。
先程のテストコードを @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
でフィクスチャーファイルをテストケース間で共有できたりと、便利な機能がある
どうぞお使いください。