2026-09-05 · テスト · typescript · 設計 · 個人開発 · ci
テストが全部通るのに、本物のデータを1件も読めなかった
GitHub の Issue を読んで、対象範囲が重なっている組を報告するツールを作りました。
テストは fixtures を8本用意して、全部通っていました。 ルールは6つあって、全部が期待どおりに出ます。
本物のリポジトリに掛けたら、Issue を1件も読めていませんでした。
何が起きたか
出力はこうでした。11件すべてが同じ指摘です。
error #21 対象範囲の節はありますが、パスが1つも書かれていません。
error #19 対象範囲の節はありますが、パスが1つも書かれていません。
error #16 対象範囲の節はありますが、パスが1つも書かれていません。
...
節はある。中身が読めていない。
実際の Issue はこう書かれていました。
## Scope
```
README.md, .github/workflows/self-check.yml
```
コードブロックの中に、1行で、カンマ区切り。
私のパーサはコードブロックの中を1行=1パスとして扱い、
そのあと「空白を含むものはパスではない」と捨てていました。
README.md, .github/... は空白を含むので、全部捨てられていました。
fixtures はこう書いてありました
## 対象範囲
- `src/auth/**`
- `src/routes/login.tsx`
箇条書きです。 1行1パス。バッククォート付き。
なぜこう書いたか。私がそう書くと思ったからです。
そして、そのテンプレートを作ったのも私です。 自分のツールが、自分の作った形式を読めていませんでした。
テストが失敗しなかった理由
ここが本題です。
fixtures は、パーサを書いた同じ人間が、同じ日に、同じ思い込みのまま書いています。
パーサ 「対象範囲は箇条書きで書かれる」という前提で実装
fixtures 「対象範囲は箇条書きで書かれる」という前提で作成
テスト 両者が一致するので、必ず通る
前提が2か所に複製されているので、テストはその前提を検証できません。 検証しているのは「実装が fixtures と一致すること」だけです。
これは実装のバグではありません。テストの設計の問題です。 どれだけカバレッジを上げても見つかりません。 通る経路が増えるだけで、間違った前提の上を通るからです。
直し方は「もっとテストを書く」ではない
fixtures を10本に増やしても、私が書く限り同じ前提で書きます。
自分が書いていないデータを1本入れます。
gh api "repos/owner/name/issues?state=closed&per_page=100" \
--jq '[.[] | select(.pull_request|not) | {number,title,body}]' \
> test/fixtures/real-issues.json
そのまま回帰テストにしました。
// 実データの Scope はコードブロックに1行で並んでいた。
// 作りかけの parser はこれを1つも読めず、全部 scope-empty にしていた
const real = JSON.parse(readFileSync(`${F}real-issues.json`, "utf8"))
.map((i) => ({ id: `#${i.number}`, title: i.title, body: i.body ?? "" }));
ok(!check(real, { files }).some((f) => f.rule === "scope-empty"),
"コードブロックの Scope を読めている");
このテストは、私の思い込みの外側にあります。 データを作ったのは私ではなく、過去の実際のやりとりだからです。
パーサ側はこう直しました。
if (inFence) {
// コードブロックの中は、1行にカンマや空白で並べて書かれることが多い。
// 実際の Issue がこの形だったので、行のまま扱うと1つも読み取れない
for (const tok of line.split(/[,\s]+/)) if (tok) out.push(tok);
continue;
}
同じ日に、同じ形をもう1つ踏みました
README にこう書きました。
Requires Node 22.6 or newer
CI が落ちました。
SyntaxError: Cannot use import statement outside a module
"type": "module" が無いと、Node は .ts を CommonJS として読みます。
手元の Node が 25 だったので、動いていました。
これも同じ構造です。
README 「22.6 で動く」と主張
CI 25 で検証
検証されているもの 25 で動くこと
検証されていないもの 22.6 で動くこと(= 主張そのもの)
主張と、検証している場所がずれていました。
直し方は、主張している境界で試すことです。
strategy:
matrix:
node: ["22.18", "24"]
(22.6 では型注釈の除去に旗が要ることが分かったので、下限は 22.18 に直しました。 「動く版はどこからか」を調べ直したのは、CI が落ちたからです。 落ちなければ、嘘のまま出していました。)
一般化
自分で用意したデータは、自分の思い込みまで一緒に再現します。
危ないのは、こういう場所です。
- 外から来る入力を解釈するもの(パーサ、インポータ、Webhook の受け口)
- 他人が書いたテキストを扱うもの
- 自分で決めた書式(これが一番危ない。「決めたとおりに書かれている」と信じてしまう)
規則は1つで足ります。
入力を解釈するものは、自分が書いていない入力で1本テストする。
本番のデータを1件持ってきて、固定して、回帰テストにする。 それだけで、思い込みの外側に足場が1つできます。
そして主張については、もう1つ。
主張した境界で検証する。 「N 以降で動く」なら、N で試す。
まとめ
- fixtures は実装と同じ思い込みで書かれるので、その思い込みは検証できない
- カバレッジを上げても見つからない。間違った前提の上を通る経路が増えるだけ
- 自分が書いていないデータを1本入れる。 本番から取って固定する
- いちばん危ないのは自分で決めた書式。決めたとおりに書かれていると信じてしまう
- README の主張は、主張した境界で試さない限り検証されていない
- CI が落ちて初めて調べ直した。落ちなければ嘘のまま出していた
あわせて読む
Claude Code に設計・実装・レビューを別々の人格として分担させ、GitHub Issue と
ブランチを軸に並列開発を回すための設定一式を MIT で公開しています。
コピーして ./setup.sh を叩けば動きます。技術スタックには依存しません。
https://github.com/quintetkit/quartet
このワークフローだけで実際にツールを 1 つ作りました。Issue の分割から PR、 レビュー、マージまで記録が全部残っています。うまくいかなかった箇所も消していません。
https://github.com/quintetkit/mdlinkcheck
UI 設計人格・レビュー基準・Issue 単位の並列実行スクリプト・実践ガイド 10 章を 足したものは製品ページにあります。
実践ガイド全10章は Zenn Book で読めます(¥1,500・2章まで無料)。
https://zenn.dev/quintetkit/books/claude-code-parallel-workflow
設定一式(5人格・スクリプト・ガイド全文)の配布はこちらです。