AI(愛)と二人で開発してても、 ユニットテストはやっぱり書く — テストルールと Branch Protection を整えた土曜の夜
中古品 AI 査定 Web サービスのバグ修正をしている途中で、 ふと『AI と二人で開発してるのにユニットテストって書くべき?』が気になって、 あいに意見もらった話。 テストルール明文化 + Branch Protection 設定 + 本番前 TODO の整理までやった夜の作業ログを書きます。
中古品 AI 査定 Web サービスのバグ修正をしている途中で、 ふと『AI と二人で開発してるのにユニットテストって書くべき?』が気になって、 あいに意見もらった話。 テストルール明文化 + Branch Protection 設定 + 本番前 TODO の整理までやった夜の作業ログを書きます。
土曜の夜、 ぼくはずっと Notion のバグリストと睨めっこしてました。 中古品 AI 査定の Web サービス、 もうすぐリリースなんですけど、 バグ調査用に Notion に 37 セクション分のスクショ付きレポートが上がってて、 これを 1 個ずつ潰してる最中です。
その作業の途中で、 ふと気になったことがありました。
あい、 今このバグ修正で E2E テスト書いてるけど、 これって正解なん? 本当は実装段階から書くもの? あとユニットテストは書いてないけど、 書くべき? 書くならどの粒度?
会話の出発点はそんな単純な疑問やったんですが、 そこから一晩で テストルールの明文化、 Branch Protection の設定、 本番前 TODO の棚卸しまで一気に進んでしまったので、 今日はその記録です。
きっかけになったのは、 Notion バグリストの「グループ A」と名付けた 3 件 ([9] [28] [29])。 全部「査定結果画面のボタン表示判定」に絡んでて、 これを 1 PR でまとめて修正してました。
最初、 ぼくは React コンポーネント内に直接 if (status === "ai_done" && price > 0) みたいな条件分岐をベタ書きする実装案で行こうとしてたんですけど、 あいが止めました。
「これ、 純粋関数として
lib/appraisal-button-visibility.tsに切り出した方がええよ。 status × price のパターンが多いし、 後で vitest で網羅テストできるようにしとこ。」
それで、 こんな構造にリファクタしました。
// lib/appraisal-button-visibility.ts
export interface AppraisalForVisibility {
status: string;
price: number | null;
}
export function shouldShowApplyButton(a: AppraisalForVisibility): boolean { /* ... */ }
export function shouldShowManualPendingMessage(a: AppraisalForVisibility): boolean { /* ... */ }
export function shouldShowHistoryCta(a: AppraisalForVisibility): boolean { /* ... */ }
export function shouldShowManualRequestButton(a: AppraisalForVisibility): boolean { /* ... */ }React 側は {showApplyButton && <Link ... />} だけになって、 条件分岐が JSX から消えました。 そして同じ条件式が結果画面と履歴画面の 2 箇所に散らばってたのも統合できた。
その上で、 ぼくは __tests__/lib/appraisal-button-visibility.test.ts に 38 件の vitest テストを書きました。 status × price の全パターンを網羅 + バグ再発防止のリグレッションテストとして。
これが完成して、 push して、 CI が緑になったところで、 ぼくは満足してました。 けど、 そこで冒頭の疑問が湧いたんです。
冷静になってみると、 ぼくの今のテスト運用、 けっこう場当たり的やったんですよね。
これって、 もしリリース後にやらかしたら結構痛い構造です。 で、 あいにストレートに聞きました。
ねぇ、 これって正解? それとも変えるべき?
返ってきた答えが、 思ってたよりだいぶ整理されてました。
あいの回答(要約)
E2E のタイミング: 新機能着手と 同時並走 が正解。 バグが出てから慌てて書くのは応急処置。 リグレッション目的では正しいけど、 仕様の生きたドキュメントにはならない。
ユニットテストの粒度:
CI / Vercel タイミング: 今は問題ない(preview only)。 本番リリース前に「CI 通過後にしか Vercel デプロイしない」運用に切り替え必須。
腑に落ちました。 で、 即ルール化することにしました。
CLAUDE.md にテスト戦略を追記ぼくは Claude Code(あい)と一緒にコードを書くとき、 プロジェクトルートの CLAUDE.md をルールブックとして使ってます。 あいはセッションのたびにこれを読み込んで、 ぼくの好みや禁止事項を引き継いでくれる。
今回新しく追加したのは、 こんな内容です(抜粋)。
### テスト戦略
#### 新機能のとき
- E2E テストは **実装と併走** で書く。 後追いで書かない。
- 純粋関数を切り出したら、 同 PR で vitest を書く。
#### バグ修正のとき
- 必ず **リグレッションテスト** を 1 件以上追加してから fix する。
- 同じバグが二度と再発しないことを担保。
#### 粒度の指針
| 対象 | テスト | 理由 |
|---|---|---|
| 純粋関数 | ✅ 必須(vitest) | リファクタ安全網 |
| Server Action | ✅ 必須 | 認証・validation 境界 |
| E2E smoke | ✅ 必須(Playwright) | デプロイ後検証 |
| React component | ❌ 不要 | E2E でカバー |
| Visual regression | ❌ 不要 | 目視 + AI 確認で十分 |
#### PR マージゲート
- lint / typecheck / build / vitest / E2E が全部緑になるまで merge 不可。これで、 次に新機能をやるとき、 ぼくもあいも「テストいつ書くんだっけ?」で迷わなくなる。 ルールは 書いた瞬間に効く んですよね。 もうこの瞬間から、 違反したら CI と Pull Request レビューで弾かれる(後述)。
gh CLI で設定CI を作っても、 「メインに直接 push」されたら意味がない。 ぼくは GitHub の Branch Protection を有効にしたかったんですけど、 ブラウザでぽちぽちするのが面倒で後回しになってました。
あいに聞いたら、 「gh CLI でできるよ」とのこと。
gh api -X PUT \
/repos/<owner>/<repo>/branches/main/protection \
--input - <<'EOF'
{
"required_status_checks": {
"strict": true,
"contexts": ["Lint / TypeScript / Build"]
},
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null
}
EOFこれを main と develop の両方に適用。 enforce_admins: true にしてるので、 ぼく(管理者)も例外なく PR 経由じゃないと merge できない。 ソロ開発でも、 これは入れておいた方がええです。 自分の手癖で直 push してしまう事故を構造で防げる。
ちなみに最初、 ぼくは contexts: ["CI"] って書いてエラー食らいました。 これは workflow ファイル名じゃなくて、 job 名 を指定する必要があった。 ぼくの ci.yml では job 名が Lint / TypeScript / Build なので、 それをそのまま入れる。 ハマる人多そうなポイントなので、 書いておきます。
CI と Vercel デプロイのタイミング、 これは今のところ問題ないんですけど、 リリース前に必ず直さないといけない構造的な穴です。
今の挙動:
git push するpreview 環境やからまだええんですけど、 本番ドメインに繋いだ瞬間、 これは事故の元です。 CI で落ちる変更が本番に出てしまう。
解決案は 3 つあって、 あいと話し合った結論はこんな感じ。
リリースまでまだ少し時間があるので、 今は 案 C で運用しつつ、 本番前 TODO リストに「Vercel 自動デプロイ制御」を追加しました。 ワークスペースの memory/projects/snapvalue.md に書き込んだので、 次に「リリース前チェック何だっけ?」って自分が聞いたら、 あいがこれを読んで思い出してくれる。
ここまでで、 ようやくきっかけの疑問 3 つに全部答えが出ました。
ここからが今日の本題というか、 ぼくが一番考えさせられた話。
ぼくは今、 中古品 AI 査定の Web サービスを、 基本的にあい(Claude Code)と二人だけ で作ってます。 チームには人間メンバーがいない。 PR レビュアーも、 コードを書くのも、 仕様を考えるのも、 ぼくとあいの 2 人だけ。
この状況で、 ぼくの中にずっと小さくあった疑問。
AI とのペア開発で、 そもそもユニットテストって必要?
- AI は型情報読んでロジック組むから、 そもそもバグが入りにくい
- テストもどうせ AI が書く → 自作自演になりそう
- 人間チームのレビュー文化を前提にしたテスト戦略は、 ぼくらには過剰なのでは?
これを正直にあいにぶつけました。 返ってきた答えは、 ぼくの想定よりだいぶ強い「書くべき」やった。
AI 駆動開発こそ、 ユニットテストは必要やと思う。
理由 4 つ:
- AI のリファクタ / 再生成の安全網。 AI は速い。 速いから、 検出が追いつかないと事故が増える。
- 「型は通ったが振る舞いはバグ」を捕まえる。 AI はエッジケースを忘れるし、 ぼくも忘れる。
- 未来の自分 + 未来の AI へのドキュメント。 会話の文脈は消える。 テストは残る。
- 人間 1 人で全レビューは物理的に不可能。 テストが代わりに見張ってくれる。
特に 4 つ目はぼくに刺さりました。 ぼくは確かに、 あいが書いたコードを「ふんふん、 OK」で merge してしまうことが多い。 全行精査してない。 これを補えるのはテストしかない。
そして、 比重の話も納得感が強かったです。
| 種類 | AI 駆動での扱い | 理由 |
|---|---|---|
| 純粋関数の unit test | 🟢 ガッツリ書く | AI リファクタ安全網 + 仕様の生きた記述 |
| Server Action ロジック | 🟢 書く | RLS / 認証 / validation の境界固め |
| E2E smoke | 🟢 書く | デプロイ後検証、 人間目視の代替 |
| Zod schema(複雑なやつ) | 🟡 必要分だけ | .refine() の仕様明示用 |
| React component snapshot | ❌ 捨てる | AI に画像見せて聞く方が速い |
| 形式的な Testing Library | ❌ 捨てる | E2E + Sentry で代替 |
| Visual regression | ❌ 捨てる | 目視 + AI 確認で十分 |
つまり、 AI 駆動でテストレスは短期効率は良いが、 6 ヶ月後の事故率が激増する。 これがあいの結論でした。 TypeScript strict + ESLint + Zod で 70% カバー、 unit test は補完する 30% やけど、 致命的なロジックバグが集中する場所。 ここは絶対に削っちゃいけない。
ぼくは、 これを「AI 駆動の 事故シミュレータ + 仕様の記憶装置」と呼ぶことにしました。 ワークスペースのメモリにもこの定義で書き込んでおきました。
ここまで来たら、 当然次にやるのは「既存コードのうち、 テストが書かれてないやつ全部洗い出す」やったんですけど、 さすがにこれは深夜にやり切るのは無理。
数えてみたら、 51 ファイル中 35 ファイルが未テスト でした。 内訳:
これは今夜は着手しません。 リスト化して、 ワークスペースのメモリ(snapvalue.md の「ユニットテスト未対応リスト」セクション)に置きました。 次回以降、 PR を分割して順次対応します。
後回しの作法
やることを思いついた瞬間に、 全部やろうとしない。 リストに書いて、 「いつやるか」だけ決めて、 ちゃんと忘れる仕組みを作る。 ぼくのワークスペース(~/.openclaw/workspace/、 卒業前の名残ですが今も愛用)に書いておけば、 あいが次回セッションで思い出してくれる。 これがぼくの「外部脳」運用です。
土曜の夜、 バグ修正 1 件のつもりが、 結果としてやったこと:
CLAUDE.md にテスト戦略ルールを明文化gh CLI で main + develop の Branch Protection を有効化「AI と二人で開発してても、 ユニットテストはやっぱり書く」というのが今日の結論。 むしろ AI 駆動だからこそ、 速く動く分だけ、 ちゃんと足跡を残しておく仕組みが要る。
人間が 1 人で見られる範囲には限界がある。 そこを埋めるのは AI でも、 ドキュメントでもなくて、 動くテストコード なんやな、 と思った夜でした。