zudo-test-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

remark/rehypeプラグインのテスト

ASTプラグインを2つの相補的なレイヤーでテストする -- ユニット制御のための手組み mdast ツリーファクトリと、UPDATE_FIXTURES による再生成と言語をまたぐパリティオラクルを備えた、パイプライン全体の一致を保証するゴールデンフィクスチャ群。

remarkやrehypeのプラグインは、構文木(markdownならmdast、HTMLならhast)を走査して書き換えるだけの関数です。それゆえ、markdownをremark-parseに通して結果を目視で確認する、というテストをやりたくなります。しかし検証すべきものは大きく2つに分かれ、それぞれ別の手法を必要とします:

  • ユニット: ある入力ノードに対して、変換が正しいノード形状を生成するか。エッジケースを含めて入力を完全に制御したい。markdownでは表現しづらいケースもここで扱います。

  • インテグレーション: パイプライン全体 -- パース、すべてのプラグイン、文字列化 -- が、期待どおりのHTMLを正確に出力し続けるか。そしてその出力を、別言語への移植の参照として使えるか。

この2つは相補的な両輪であって、二者択一ではありません。ユニットテストはノード形状の制御を、インテグレーションテストは組み上がったパイプラインの検証と言語横断の一致を可能にします。

前半:mdastツリーファクトリ(ユニット)

プラグインのユニットテストを快適にする要は、入力ツリーを手組みし、プラグインのTransformerを合成したRootに直接適用することです -- remark-parseを介しません。markdownのパースはノイズ(テキストノード、位置情報、空白の扱い)を持ち込み、さらに悪いことに、特定のノード形状を意図どおりに作るのが難しい、あるいは不可能になります。手組みのツリーなら、テストしたいノードをぴったり構築できます。むき出しのdefinitionノード、不正なURL、特定の入れ子の深さ、といった具合です。

小さなファクトリをいくつか用意するだけで大きな効果があります:

import type { Root, Link, Paragraph, Definition } from "mdast";

function makeLink(url: string): Link {
  return { type: "link", url, children: [{ type: "text", value: "link" }] };
}

function makeDefinition(url: string): Definition {
  return { type: "definition", url, identifier: "ref", label: "ref" };
}

function makeTree(...links: (Link | Definition)[]): Root {
  return {
    type: "root",
    children: links.map((link) =>
      link.type === "definition"
        ? link
        : ({
            type: "paragraph",
            children: [link],
          } as Paragraph),
    ),
  };
}

これらを使えば、テストはプラグインのファクトリを呼び、返ってきたトランスフォーマを合成ツリーに適用し、書き換えられたノードをその場でアサートします:

import { resolve } from "node:path";
import { describe, it, expect } from "vitest";
import { remarkResolveMarkdownLinks } from "../remark-resolve-markdown-links";
import { createTempProject, touch, cleanupTempProject } from "./test-helpers";

/** Options shared by every test in this suite -- only `contentRoot` varies per test, via the `rootDir` created in `beforeEach` below. */
function baseOptions() {
  return { contentRoot: rootDir, docsBasePath: "/docs" };
}

it("resolves a relative .mdx link to a URL", () => {
  touch(rootDir, "src/content/docs/guides/getting-started.mdx");
  touch(rootDir, "src/content/docs/guides/other-doc.mdx");

  const link = makeLink("./other-doc.mdx");
  const tree = makeTree(link);
  const file = {
    path: resolve(rootDir, "src/content/docs/guides/getting-started.mdx"),
  };

  const plugin = remarkResolveMarkdownLinks(baseOptions());
  plugin(tree, file);

  expect(link.url).toBe("/docs/guides/other-doc/");
});

linkはツリーが保持しているのと同一のオブジェクト参照なので、plugin(tree, file)のあとにlink.urlをアサートすれば、書き換え結果を直接読めます -- ツリーを再走査する必要はありません。

Tip

手組みのツリーはエッジケースで真価を発揮します。definitionノード(参照スタイルのリンク先)、ハッシュとクエリ文字列を持つURL、3段落の奥にネストしたリンク -- どれもファクトリなら1行ですが、markdownのサンプルとして書いて安定させようとすると厄介です。

決定論的なファイルシステム上のコンテキスト

上記のリンクリゾルバはツリーだけでは動作しません -- 相対パスを実際のファイルシステムに対して解決し、リンク先が存在するかを判定します。これを決定論的に保つため、テストごとに作り直す使い捨てのプロジェクトディレクトリを与えます:

import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { resolve } from "node:path";
import { tmpdir } from "node:os";

/** Create a unique temporary directory for testing. */
export function createTempProject(): string {
  const dir = resolve(
    tmpdir(),
    `md-plugins-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
  );
  mkdirSync(dir, { recursive: true });
  return dir;
}

/** Create a file (and parent dirs) with minimal markdown content. */
export function touch(base: string, filePath: string): void {
  const full = resolve(base, filePath);
  mkdirSync(resolve(full, ".."), { recursive: true });
  writeFileSync(full, "# Test");
}

/** Remove a temporary directory. */
export function cleanupTempProject(dir: string): void {
  rmSync(dir, { recursive: true, force: true });
}

これらをライフサイクルに組み込み、各テストがディスク上のクリーンで隔離されたツリーから始まるようにします:

describe("remarkResolveMarkdownLinks", () => {
  let rootDir: string;

  beforeEach(() => {
    rootDir = createTempProject();
  });

  afterEach(() => {
    cleanupTempProject(rootDir);
  });

  // ...tests call touch(rootDir, "...") to lay down the files they need
});

Note

ユニークなサフィックス(Date.now()とランダムな36進文字列)により、並列テストワーカーが同じディレクトリで衝突することはなく、afterEachのクリーンアップが一時フォルダの実行間のリークを防ぎます。これはテストごとにインメモリDBを用意するのと同じ隔離の規律です -- ただしリゾルバが読むのは実際のファイルシステムなので、本物のファイルシステム上で行うだけです。

後半:ゴールデンフィクスチャ群(インテグレーション)

ユニットテストは各変換を単体で証明します。しかし、すべてのプラグインが順に走ったあとで、組み上がったパイプラインが正しいHTMLを出力し続けるかは証明できません。それには、実物の.mdxフィクスチャ群を実際のunified()パイプラインに通し、出力をリポジトリに入れたexpected-html/*.htmlファイルと比較します。

import { readFileSync, readdirSync, writeFileSync, existsSync } from "node:fs";
import { resolve, dirname, basename } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, it, expect } from "vitest";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeRaw from "rehype-raw";
import rehypeStringify from "rehype-stringify";

const here = dirname(fileURLToPath(import.meta.url));
const fixturesDir = resolve(here, "../../__fixtures__");
const expectedDir = resolve(fixturesDir, "expected-html");
const updateMode = process.env.UPDATE_FIXTURES === "1";

function buildProcessor() {
  return unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkRehype, { allowDangerousHtml: true })
    .use(rehypeRaw)
    .use(rehypeStringify, { allowDangerousHtml: true });
}

フィクスチャのループが各.mdxをレンダリングし、そのアサーションはただ1つの環境変数フラグに依存します:

describe("md-plugins fixture corpus", () => {
  const fixtures = readdirSync(fixturesDir)
    .filter((f) => f.endsWith(".mdx"))
    .sort();

  for (const file of fixtures) {
    const name = basename(file, ".mdx");
    const expectedPath = resolve(expectedDir, `${name}.html`);

    it(`renders ${file}`, async () => {
      const src = readFileSync(resolve(fixturesDir, file), "utf8");
      const html = String(await buildProcessor().process(src));

      if (updateMode) {
        writeFileSync(expectedPath, html, "utf8");
      } else if (!existsSync(expectedPath)) {
        // A missing expected file must fail loudly, not auto-write against its
        // own output -- that would make a brand-new fixture green in CI on its
        // very first run, with nothing to compare against.
        throw new Error(
          `Missing expected file for ${file}. Run UPDATE_FIXTURES=1 locally and commit the diff.`,
        );
      }

      const expected = readFileSync(expectedPath, "utf8");
      expect(html).toBe(expected);
    });
  }
});

UPDATE_FIXTURESトグル

process.env.UPDATE_FIXTURES === "1"の分岐こそが、ゴールデンテストを保守可能にするものです。通常の実行では、各フィクスチャの出力がディスク上のファイルと一致することをアサートします。意図的にパイプラインを変更したときは、フラグを立てて一度実行し、期待ファイルを再生成します:

UPDATE_FIXTURES=1 pnpm vitest run fixtures

そのうえで、expected-html/*.htmlファイルに生じた差分を、他のあらゆる変更と同じようにレビューします。意外な差分は意図しないリグレッションであり、想定どおりの差分が新しいベースラインです。新しいフィクスチャを追加するときも同じ手順です:.mdxファイルを置き、一度UPDATE_FIXTURES=1で実行して期待値ファイルを生成します。通常の実行では、期待値ファイルの欠落を静かに書き込むのではなく、ハードな失敗として扱います -- そうしないと、コミット済みのベースラインを持たないフィクスチャが、自分自身の出力以外の何とも比較されないまま、追加された瞬間にCIでパスしてしまいます。

Warning

赤いテストを緑にするためにフィクスチャを無闇に再生成してはいけません。ゴールデンコーパスの価値のすべては、予期しない差分がシグナルになる点にあります。差分を読み、自分が意図した変更と一致することを確認し、そのうえではじめて再生成されたHTMLをコミットしてください。

一致のオラクルとしてのコーパス

ここがゴールデンコーパスがリグレッション検出を超えて本領を発揮するところです。すべてのフィクスチャについて信頼できるJSの参照出力が手に入れば、その出力は同じパイプラインを別言語へ移植する際の一致のオラクル(parity oracle)になります。変換をRustで再実装しているなら、同一のフィクスチャに対してRustのパイプラインを走らせ、その出力するHTMLをJSが生成したexpected-html/*.htmlバイト単位で比較します。

これが、変換パイプラインを言語をまたいで安全に移植する方法です。参照実装が真実を定義し、コーパスがその真実をディスクに固定し、一致は判断を要することなく文字列の一致そのものになります。あるプラグインがRust移植版とバイト単位で一致すれば、JS版を安心して引退させられます -- フィクスチャ群全体にわたって両者が同一の出力を生むことを、コーパスが証明してくれるからです。

Info

フィクスチャは二役をこなします。今日のJSパイプラインに対するリグレッションガードであり、明日のRust移植版に対する受け入れテストでもあります。同じ.htmlファイルが、修正なしで両方の役割を果たします。

本番との乖離をすべてコメントに記録する

ゴールデンコーパスが信頼に足るのは、それが実際に本番を反映しているときだけです。実際にはテストハーネスが本番スタック全体を起動できないことが多く、パイプラインの近似コピーを立ち上げることになります。フィクスチャを誠実に保つ規律は、意図的な乖離をすべてコメントに記録することです。そうすれば将来の読み手は、コーパスがどこで本番との一致をやめるのかを正確に把握できます:

/**
 * Pipeline composition mirrors the production config as closely as possible
 * without booting the full stack. Notable documented divergences:
 *
 *   - Shiki is not run; the captured HTML therefore does not include
 *     syntax-highlighted spans.
 *   - The filesystem-dependent link resolver is NOT run -- it needs a real
 *     source map the fixtures do not have.
 *   - MDX JSX (e.g. <Note>...</Note>) is parsed as raw HTML via rehype-raw,
 *     not as live MDX components -- that is an MDX-runtime concern.
 *
 * Set UPDATE_FIXTURES=1 to (re)write expected-html/*.html. Otherwise the
 * test asserts each fixture's pipeline output matches the on-disk file.
 */

Danger

記録されていない乖離こそが、ゴールデンコーパスが静かに本番から外れていく原因です。テストパイプラインがShikiやリンクリゾルバを省いているのに誰もそれを書き残していなければ、次の保守者はフィクスチャが出荷物を反映していると思い込み、緑のコーパスが見逃したリグレッションをそのまま出荷します。コメントが仕様そのものです。要となる情報として扱ってください。

移植版の出荷後

上記の一致のオラクルという枠組みが成り立つのは、移植が進行中で、すべてのフィクスチャについて参照実装の出力と移植版の出力の差分を実際に取り続けている間だけです。移植版が一致に達して出荷されると、その能動的な消費者は通常いなくなります -- 移行は「完了」し、比較を行っていたCIジョブはブランチごと削除され、コーパスは誰にも読まれないまま緑のまま取り残されます。

Warning

「いまだにパスしているコーパス」は、それが何かを守っている証拠にはなりません。「パスしている」が意味するのは、最近誰もゴールデンファイルに手を触れていないということだけであり、ビルドやCIジョブや人間が今なお何かと比較し続けていることではありません。能動的な消費者を持たない一致のコーパスは、安全網ではなく偽りの安心感だと見なしてください。

これが実際に下流で観測された劣化の形です。JSからネイティブへの移植版が出荷されたあとも、コーパスは誰も実行しなくなったJS参照実装に対して「パス」し続け、あるフィクスチャは移植版が意図的に修正したJS側のバグをひっそりとピン留めしていました。コーパスは中立な死荷重だったわけではありません -- 本番がすでに手放した挙動を、積極的にアサートし続けていたのです。それでいてパッケージ自身のドキュメントは、その何年も後のコミットに至るまで、それを一致のアセットだと説明し続けていました。

移植版が一致に達したら、次のどちらかを明示的に選んでください -- 決定をなりゆき任せにしないこと:

選択肢1: コーパスの照合先を出荷パイプラインに切り替える

ゴールデンアサーションの照合先を切り替え、JS参照実装の出力ではなく、移植版の実際の出力をexpected-html/*.htmlと突き合わせるようにします。フィクスチャと期待値ファイルは変わりません -- 変わるのは、テスト対象のhtmlをどのパイプラインが生成するかだけです:

import { execFileSync } from "node:child_process";

// Was: const html = String(await buildProcessor().process(src));
// Now: shell out to the shipping (ported) pipeline binary and assert its
// output against the same expected-html/*.html the JS reference produced.
function renderWithPort(fixturePath: string): string {
  return execFileSync("./target/release/md-plugins-port", [fixturePath], {
    encoding: "utf8",
  });
}

アサーションの切り替えが完了すれば、コーパスは再び本番のリグレッションガードとなり、JS参照実装は引退させられます -- その唯一の役目は、もはや誰も独立して消費しない真実を生成することだったからです。

選択肢2: 日付入りのメモとともにコーパスを引退させる

今後、移植版の出力をこれらのフィクスチャと突き合わせ続ける仕組みが何も残らないのであれば、コーパスを腐らせたまま放置せず削除してください。パッケージのREADMEの一行や、そこから参照できるコミットメッセージなど、いつ・なぜ引退させたかを記録した日付入りのメモを残します。そうすれば、gitの履歴でexpected-html/*.htmlを見つけた将来の読者が、それが偶発的な削除ではなく意図的な終了だったと分かります。

まず、参照実装のバグをピン留めしたフィクスチャを洗い出す

照合先を切り替える、あるいは引退させる前に、すべてのフィクスチャを洗い出し、移植版が意図的に修正した、参照実装の既知のバグをピン留めしているものがないか確認してください。そのようなフィクスチャは中立ではありません。選択肢1でそのまま放置すれば、照合先を切り替えた瞬間に失敗します(移植版は古いバグを再現しないため)。あるいはもっと悪いことに、何が・なぜ変わったのかを説明するコメントもないまま、移植版の修正済み出力に対して黙って再生成されてしまいます。選択肢2でそのまま放置すれば、既知の誤りではなく正当な一致のアーティファクトであるかのような姿でgitの履歴に残ってしまいます。

// KNOWN DIVERGENCE: nested-definition-list.html pins issue #482 (JS reference
// double-escapes ampersands in reference-style link titles). The port fixed
// this deliberately -- do not regenerate this fixture from the JS pipeline;
// it has already been hand-updated to the corrected (port) output.

そうしたフィクスチャは一つひとつ、コメントで注記するか再生成してください -- どちらの終着点を選ぶにせよ、静かにパスし続けるバグ固定フィクスチャを持ち込まないこと。いずれの道を選んでも、コーパスが記述すべきは本番が実際にどう振る舞うかであって、引退させた参照実装がかつてどう振る舞っていたかではありません。

両輪を組み合わせる

入力ノードを外科的に制御したいとき -- エッジケース、不正な入力、特殊なネスト -- そしてパーサのノイズなしに結果のノード形状をアサートしたいときは、ツリーファクトリを使います。組み上がったパイプライン全体が期待どおりのHTMLを正確に出力し続けるという確信が欲しいとき、とりわけその出力が言語横断の一致の参照として機能しなければならないときは、ゴールデンコーパスを使います。成熟したASTプラグインのテストスイートは両方を使います。変換ロジックには高速で焦点の絞られたユニットテストを、エンドツーエンドの契約を固定する小さなゴールデンコーパスを。

Revision History

作成更新