Claude Code でリファクタリングや新機能追加を行うと、既存機能の出力品質が意図せず劣化することがある。機能は正しく動いておりテストも通るが、ユーザーが期待する情報が出力から消えている。この記事では、実際に遭遇した「静かなデグレード」の事例と、出力仕様テストによる対策を紹介する。

何が起きたか

日本株・BTC のトレーディングシステムで、日次の投資提案を GitHub Issues に自動投稿している。このシステムにポートフォリオ統合最適化機能を追加した際、以下の流れで問題が発生した。

  1. 統合最適化機能を追加。成功時は 1 つの統合 Issue に「市場概要」「総評」を含む詳細な分析を出力する設計
  2. 失敗時のフォールバックとして、銘柄別 Issue + サマリー Issue を作成するパスも実装
  3. テスト全パス、PR マージ
  4. ある日、ポートフォリオ最適化が失敗しフォールバックが発動
  5. サマリー Issue を見るとポートフォリオ一覧とリンクテーブルだけ — 「結局ホールドすべき?買うべき?」がわからない

統合パスには存在する「銘柄横断の総評」が、フォールバックパスでは最初から実装されていなかった。しかしテストは両方とも通っていた。

サマリー Issue の Before / After

デグレード状態(修正前):

1
2
3
4
5
6
7
# 2026-04-03 総合投資評価
## 現在のポートフォリオ
(ポジション一覧テーブル)
## 銘柄別計画
(リンクテーブル)
## 子課題チェックリスト
(チェックボックス一覧)

修正後:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 2026-04-03 総合投資評価
## 現在のポートフォリオ
(ポジション一覧テーブル)
## 銘柄別計画
(リンクテーブル)
## 銘柄別サマリー          ← 追加
(各銘柄の総評を一覧表示)
## 総評                    ← 追加
### アクション分布
| BUY | SELL | HOLD | 合計 |
### 全体判断
(HOLD多数→「積極エントリー不向き」等の方針)
平均信頼度: 55%
## 子課題チェックリスト
(チェックボックス一覧)

修正前は「データの一覧」しかなく、修正後は「判断に必要な分析」が加わっている。

なぜ Claude Code で起きやすいか

新しいパスに注力し、既存パスは最低限になる

Claude Code はタスクを忠実に実行する。「ポートフォリオ最適化を追加して」と指示すると、統合パス(成功時)は丁寧に設計される一方、フォールバックパス(失敗時)は「最低限動く」安全網として実装される。

人間の開発者でも同じことは起きるが、Claude Code はユーザーとして出力を見た経験がないため、「この情報がないと困る」という暗黙の期待を推測しにくい。

会話が切れるとコンテキストが消える

統合最適化を実装した会話と、フォールバックの問題に気づく会話は別セッション。前回の設計意図や「統合パスと同等の出力品質を保つ」という暗黙の要件は引き継がれない。

テストが「動作」を検証し「品質」を検証しない

既存のテストは以下を検証していた。

  • 銘柄別 Issue が作成される
  • DB に紐付けが保存される
  • サマリー Issue が作成される

しかし「サマリー Issue に総評セクションが含まれるか」は検証していなかった。テストは全パスするため、デグレードに気づけない。

対策

以下の 3 つのアプローチで静かなデグレードを防止できる。

出力仕様をテストする(最も効果的)

「Issue が作成される」だけでなく「Issue に何が書かれているか」までテストする。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def test_fallback_summary_issue_contains_required_sections(self, ...):
    """サマリーIssueに銘柄別サマリーと総評が含まれる (#360)."""
    # ...フォールバック実行...

    body = summary_issue_call.kwargs["body"]

    # 必須セクションの存在を検証
    assert "## 銘柄別サマリー" in body
    assert "## 総評" in body
    assert "### アクション分布" in body
    assert "### 全体判断" in body

    # 各銘柄のサマリーが含まれている
    assert "7203" in body
    assert SAMPLE_PLAN_7203["summary"] in body

これにより、将来のリファクタリングで総評が消えたら即座にテストが落ちる。

ただし、Claude Code に「テストを書いて」とだけ指示すると、「Issue が作成される」レベルの動作確認テストで終わりがちだ。出力内容のアサーションまで書かせるには、CLAUDE.md にルールとして明記するのが効果的。

1
2
3
4
5
## テスト規約
- 外部出力(Issue本文、メール、通知、レポート等)を生成する機能のテストでは、
  出力が作成されることだけでなく、出力に含まれるべき必須セクション・フィールドも
  アサーションで検証する
- 複数の実行パス(成功/失敗/フォールバック)がある場合、全パスで出力内容を検証する

CLAUDE.md はセッションをまたいで参照されるため、毎回プロンプトで指示する必要がない。特に重要な関数にはコードコメントで # テスト時: 出力に必須セクション(総評, アクション分布)が含まれることを検証 のように補足しておくと、さらに確実になる。

複数パスがある機能は出力同等性を意識する

CLAUDE.md やコードコメントに明記しておく。

1
2
3
## 開発規約
- 新しいコードパスを追加する際は、既存パスの出力内容が劣化しないか確認する
- 特にフォールバック/エラーパスは「最低限動く」ではなく「同等の情報量」を目指す

Claude Code はコードコメントや CLAUDE.md のルールを忠実に守るため、明示しておくだけで効果がある。

出力のゴールデンファイルを用意する

ゴールデンファイルとは、「正しい出力はこうあるべき」という期待値を記録したファイルのこと。テスト時に実際の出力と比較し、差分があればテストを落とす。

たとえば、サマリー Issue のテンプレートと期待値ファイルを以下のように用意する。

テンプレート(Jinja2):

# {{ date }} 総合投資評価
## 現在のポートフォリオ
{{ portfolio_table }}
## 銘柄別計画
{{ plan_table }}
## 銘柄別サマリー
{{ per_stock_summary }}
## 総評
### アクション分布
{{ action_distribution }}
### 全体判断
{{ overall_judgement }}
## 子課題チェックリスト
{{ checklist }}

ゴールデンファイル(tests/golden/summary_issue.md):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 2026-04-03 総合投資評価
## 現在のポートフォリオ
| 銘柄 | 数量 | 取得単価 | 現在値 |
|------|------|----------|--------|
| 7203 | 100 | 2,500 | 2,680 |
## 銘柄別計画
| 銘柄 | アクション | リンク |
|------|-----------|--------|
| 7203 | HOLD | #101 |
## 銘柄別サマリー
- **7203**: 上昇トレンド継続、HOLD推奨
## 総評
### アクション分布
| BUY | SELL | HOLD | 合計 |
|-----|------|------|------|
| 0 | 0 | 1 | 1 |
### 全体判断
HOLD多数 — 積極エントリー不向き。平均信頼度: 55%
## 子課題チェックリスト
- [ ] #101 7203

テストコード:

1
2
3
4
5
6
7
8
from pathlib import Path

def test_summary_matches_golden_file(self, ...):
    """サマリーIssueがゴールデンファイルと一致する."""
    # ...フォールバック実行...
    actual = summary_issue_call.kwargs["body"]
    golden = Path("tests/golden/summary_issue.md").read_text()
    assert actual == golden

完全一致が厳しい場合(日付やデータが動的に変わる等)は、必須セクションの見出しだけを抽出して比較する方法もある。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import re

def extract_headings(md: str) -> list[str]:
    return re.findall(r"^#{1,3} .+", md, re.MULTILINE)

def test_summary_has_required_headings(self, ...):
    actual_headings = extract_headings(summary_issue_call.kwargs["body"])
    golden_headings = extract_headings(
        Path("tests/golden/summary_issue.md").read_text()
    )
    assert actual_headings == golden_headings

この方法なら、テンプレートに新しいセクションを追加した際にゴールデンファイルも更新する必要があり、フォールバックパスだけ取り残される事態を防げる。

ゴールデンファイルを Claude Code に自動運用させるには

CLAUDE.md に「ゴールデンファイルを用意すること」と書くのは有効だが、それだけでは不十分な場面がある。実用的には 3 層の仕組みを組み合わせる。

何を書くか効果
CLAUDE.md「外部出力を生成するテンプレートを修正する際は、対応するゴールデンファイルも更新すること」プロジェクト全体のルール
テストの存在自体tests/golden/ にファイルがあれば、テンプレート変更時にテストが落ちる仕組みで強制(指示に頼らない)
コードコメントテンプレートに # golden: tests/golden/summary_issue.md と記載対応関係が明示的になる

一番強いのは 2 層目(テストの存在自体) だ。既にゴールデンファイルとテストがあれば、テンプレートを変更した時点でテストが落ちるので、Claude Code は自動的にゴールデンファイルも更新する。CLAUDE.md のルールは「最初の 1 回」ゴールデンファイルを作らせるために必要であり、2 回目以降はテストの仕組みが守ってくれる。

CLAUDE.md には「用意すること」よりも、いつ用意すべきか の判断基準を書くほうが実用的だ。

1
2
3
4
## テスト規約
- 外部出力(Issue本文、メール、通知等)を生成する機能には、
  出力の期待値をゴールデンファイル(tests/golden/)として用意する
- テンプレートとゴールデンファイルの対応はコードコメントで明示する

まとめ

項目内容
問題リファクタリングで新パスを追加した際、既存パスの出力品質がデグレード
原因テストが「動作」のみ検証し、出力内容(品質)を検証していなかった
対策出力の必須セクションをアサーションでテスト
教訓Claude Code は機能を正しく動かすが、ユーザー体験の同等性は暗黙の要件。明示的にテストで守る

Claude Code は強力だが万能ではない。特に「正しく動くが情報が足りない」という静かなデグレードは、テストをすり抜けやすい。出力仕様のテストを習慣にすることで、AI 開発でも品質を維持できる。