執筆ルールを心がけではなくテストとして固定する
目次
落ちないルールテストは、無いより悪い
個人ブログの執筆規約をテストとして書いた。「実体験のない記事を書かない」「公開前に置いてはいけないリンクを置かない」といった規約を、npm test で落ちる形にした。
結論から言うと、書いた直後にわざと規約違反を作って、テストが落ちることを確認するべきだ。 落ちないルールテストは、守られているという誤った確信を与える分だけ、無いより悪い。実際、落ちることは確認できたのに、別の書き方をすると素通りする穴が2つ見つかった。
なぜ文章の規約では足りないか
最初は規約を Markdown に書いた。「一次体験のない記事を書かない」「本業の固有情報を書かない」といった内容だ。
問題は、これが忙しいときに最初に崩れる種類の約束だということ。しかもこのリポジトリでは執筆を AI エージェントに手伝わせる前提があり、規約ファイルを読ませてはいるものの、読ませたことと守らせたことは別だ。
そこで、機械的に検査できるものだけをテストに落とした。
規約をテストにする
記事のソース(ビルド結果ではなく Markdown そのもの)を読んで検査する。ソースの規約を見ているので、レンダリング結果ではなく原稿を対象にするのが正しい。
const DIR = new URL('../src/data/posts/', import.meta.url);
async function loadPosts() {
const names = (await readdir(DIR)).filter(
(n) => n.endsWith('.md') && !n.startsWith('_')
);
return Promise.all(names.map(async (name) => ({
name,
raw: await readFile(new URL(name, DIR), 'utf8'),
})));
}
検査項目は4つにした。禁止カテゴリの記事が無いこと、禁止ドメインへのリンクが無いこと、本文が一定の文字数以上あること、見出しが2つ以上あること。
重要なのは、どのファイルが違反したかを報告すること。
it('禁止カテゴリの記事を置かない', async () => {
const posts = await loadPosts();
const bad = posts.filter((p) => frontmatterValue(p.raw, 'category') === 'revenue');
expect(bad.map((p) => p.name)).toEqual([]);
});
expect(bad.length).toBe(0) と書くと「何かが違反している」としか分からない。ファイル名の配列で比較すれば、失敗メッセージがそのまま違反ファイルの一覧になる。記事が25本を超えたときに効いてくる。
わざと壊して、落ちることを確認する
テストを書いたら、規約を破ってみる。既存記事の frontmatter を禁止カテゴリに書き換えて npm test を走らせた。
× 禁止カテゴリの記事を置かない
- Expected - 0
+ Received + 1
+ [ "hello-world.md" ]
意図どおり、該当ファイル名を名指しして落ちた。確認したら元に戻す。
禁止リンクの正規表現も、実際の計測用 URL の形で検査した。対象サービスそれぞれの実際のリンク形式を投げて発火することと、通常の外部リンクやサービス名の言及では発火しないことの両方を確かめた。後者が抜けていると、記事中でサービス名に触れただけでテストが落ち、やがて誰も直さなくなる。
穴その1:引用符を変えるとすり抜ける
frontmatter から値を取り出す関数を、正規表現で雑に書いていた。二重引用符しか剥がしていなかった。
category: "revenue"→ 検出されるcategory: revenue→ 検出されるcategory: 'revenue'→ すり抜けるcategory: revenue # メモ→ すり抜ける
どちらも妥当な YAML で、スキーマ検証も通る。つまり規約違反の記事が、テストが緑のまま公開される。
このテストは規約を強制する唯一の自動検査だった。そして執筆を AI に手伝わせる前提がある以上、単引用符が出てくる可能性は十分にある。引用符の対応と、引用の外側にある # 以降の切り捨てを入れて塞いだ。
穴その2:サブディレクトリの記事が全ルールから漏れる
もっと悪いのがこちらだった。「どのファイルが記事か」の判定が、2箇所で食い違っていた。
- 記事を収集する側のグロブ:
**/*.mdで再帰的にマッチ - テストの
loadPosts:readdirが非再帰
結果、sub/foo.md は公開されるのにテストからは見えない。記事が増えてフォルダ分けした瞬間、4つの規約が全部、黙って効かなくなる。
さらに、アンダースコア始まりのファイルは除外されていたが、ディレクトリは除外されていなかった。_drafts/foo.md は公開対象に入る。「_ で始まるものはビルドされない」と手元のメモには書いてあったが、ディレクトリには当てはまっていなかった。
両側の意味を揃えた。テスト側は再帰的に読み、パスの途中にアンダースコア始まりのセグメントがあれば除外する。収集側のグロブにもディレクトリ除外を足した。
副次的に分かったことがある。動的ルートが単一セグメント([id])だったため、サブディレクトリに記事を置くとビルドが落ちる。つまり「黙って公開される」のではなく「派手に失敗する」挙動だった。危険度は見積もりより低かったが、ルールが効かない状態だったこと自体は事実なので直した。
フェーズが変わっても機能するテストにする
禁止リンクのテストには、時限的な欠陥があった。
いまは特定ドメインへのリンクを一律禁止している。しかし将来それを解禁する予定がある。解禁する日、このテストは削除される。 そして削除された瞬間、そのリンクに伴って必要になるはずの開示文がページに無くても、何も検知されない。
そこで、禁止を条件付きに書き換えた。
it('該当リンクがあるなら、開示文が存在すること', async () => {
const posts = await loadPosts();
const hasLink = posts.some((p) => LINK_DOMAINS.test(p.raw));
if (!hasLink) return; // いまはここで抜ける
const policy = await readFile(POLICY_PAGE, 'utf8');
expect(policy).toContain('開示に使う語');
});
いまは前件が偽なので素通りする。そして将来リンクを置いた瞬間から、開示文の有無を検査し始める。 削除される予定だったテストが、削除されるべきタイミングで役割を切り替える。
前件が偽のあいだ何も検査しないので、「常に緑の無意味なテスト」に見える。そうならないよう、リンクがある状態を作って実際に落ちることを確認してからコメントを添えた。コメントが無いと、将来誰かが「意味のないテスト」として消す。
検討したが採らなかった選択肢
pre-commit フックで止める。 コミット前に弾けるので早い。ただしフックはローカル環境に依存し、--no-verify で簡単に迂回でき、CI でも回らない。テストなら npm test の中で必ず走る。早さより「迂回できないこと」を採った。
frontmatter のパーサライブラリを入れる。 引用符の穴は、自前の正規表現で frontmatter を切り出していたことが原因だ。パーサを入れれば構造的に消える。ただし検査したいのはソースの文字列そのもので、# 以降の扱いや引用符の差も含めて「原稿にどう書かれているか」を見たかった。依存を増やさず、引用符と # の2点だけ手当てした。この判断は分が悪くなる可能性があると思っている。三つ目の穴が出たらパーサに切り替える。
人間が気をつける運用のまま続ける。 これは最初の状態で、実際に破れたので採らなかった。
まとめ
- ルールをテストにしたら、わざと破って落ちることを確認する。落ちないテストは誤った確信を与える
- 「どのファイルが対象か」の判定が2箇所にあると、静かに食い違う。片方が漏れるとルール全体が効かなくなる
- 将来削除される予定のテストは、条件付きに書き換えて役割を切り替えさせる