はじめに
「種別」列がある。でも全部「正常系」と書いてある。
大量のテスト資産を抱えるプロジェクトでよく遭遇するこの状態に、86,627件規模で向き合うことになりました。種別が形骸化したまま運用されてきた Excel ファイルを対象に、キーワードマッチと AI を組み合わせた2パス方式で全件を再分類しました。
やって気づいたのは、「ラベルを直す」ことより、その分布を見ること自体がテスト設計の問題を教えてくれる、という点です。
縦軸は1つ。
「種別を正す」作業は、設計の偏りを問い直す入り口にすぎない。
Part 1: 86,627件の種別は形骸化していた
問題発見:「種別」列が機能していない
169ファイルに分散する 86,627件のテストケースを集計したところ、種別列はこんな状態でした。
- 空白のまま起票されたケースが多数
- 「とりあえず正常系」で埋められた行が大半
- 準正常系・異常系の定義がチーム内で統一されていない
まず openpyxl で全ファイルを一括スキャンし、実態を把握しました。
import openpyxl, glob
SKIP_SHEETS = {"サマリ", "表紙", "変更履歴", "シナリオ一覧"}
def find_type_column(ws, target="種別"):
for i, row in enumerate(ws.iter_rows(max_row=20, values_only=True), 1):
row_vals = [str(v) for v in row if v]
if target in row_vals:
return i, row_vals.index(target)
return None, None
results = []
for path in glob.glob("テスト項目書/**/*.xlsx", recursive=True):
wb = openpyxl.load_workbook(path, data_only=True, read_only=True)
for sname in wb.sheetnames:
if sname in SKIP_SHEETS:
continue
ws = wb[sname]
header_row, col_idx = find_type_column(ws)
if col_idx is None:
continue
for row in ws.iter_rows(min_row=header_row + 1, values_only=True):
val = row[col_idx] if len(row) > col_idx else None
if val:
results.append({"file": path, "sheet": sname, "種別": str(val)})
wb.close()
スキャン結果の種別分布はこうでした。
| 種別 | 件数 | 比率 |
|---|---|---|
| 正常系 | 76,812件 | 88.6% |
| 準正常系 | 8,274件 | 9.5% |
| 異常系 | 1,541件 | 1.8% |
| 合計 | 86,627件 | 100% |
なぜ「正常系 88.6%」になるのか
テスト設計の一般的な目安は、正常系 6割・準正常系 2割・異常系 2割程度とされています。今回の分布はこれから大きく外れています。
原因は「ラベルの付け忘れ」だけではありません。
- テンプレートのデフォルト値問題: 起票シートの種別欄が「正常系」で初期化されており、変更されないまま複製される
- 定義の曖昧さ: 「境界値テスト」が正常系か準正常系か迷い、多数決で正常系になる
- 設計習慣の偏り: 正常系は自然に出てくるが、異常系は意識的に設計しないと生まれない
「ラベルを直す」前に、まずこの構造を認識することが重要でした。
Part 2: キーワードマッチとAIによる2パス再分類
なぜ「全部AI」にしなかったか
86,627件を全て API に送ると、コスト・処理時間・説明可能性の3点で課題が生じます。一方でキーワードだけでは、曖昧な記述のケースをカバーできません。そこで2パスに分けました。
[Pass 1] キーワードマッチ ─── テキストパターンで高速・高信頼処理
↓ 判定不能
[Pass 2] AI(Claude API)── 曖昧ケースを精度優先で処理
↓ 確定度 < 閾値
人間レビューキュー
キーワードマッチが「速くて説明可能」、AI が「柔軟だが遅い」。この特性の差を2パスで活かすのが設計の肝です。
Pass 1: キーワードルールの設計
テスト概要・確認内容のテキストを対象に、種別ごとのキーワード群でマッチングします。
ABNORMAL_KW = [
"エラー", "失敗", "不正", "権限なし", "存在しない",
"タイムアウト", "上限超過", "無効", "拒否"
]
QUASI_KW = [
"境界値", "最大", "最小", "0件", "空", "先頭", "末尾",
"ちょうど", "上限値", "下限値"
]
def classify_by_keyword(text):
if any(kw in text for kw in ABNORMAL_KW):
return "異常系"
if any(kw in text for kw in QUASI_KW):
return "準正常系"
return None # → Pass 2 へ
設計で重要だったのは競合時の優先ルールです。「最大値を超えた場合のエラー」は ABNORMAL_KW(エラー)と QUASI_KW(最大)の両方にヒットします。この場合は異常系を優先する(ABNORMAL_KW を先に評価する)という明示的なルールを置きました。
またキーワードを増やすほど誤検知も増えます。「確実に分類できる語」だけに絞り、少しでも曖昧な語は Pass 2 に送る方針にした結果、Pass 1 の精度を高く保てました。
Pass 2: AI による曖昧ケースの処理
Pass 1 で None が返ったケースを Claude API に送ります。テスト概要・手順・期待結果をまとめてプロンプトに渡し、種別と確定度スコアを JSON 形式で受け取ります。
import anthropic, json
client = anthropic.Anthropic()
SYSTEM = """テストケースの「種別」を以下のいずれかに分類してください。
- 正常系: 通常の入力・操作で正常動作を確認するケース
- 準正常系: 境界値・空・最大最小など、正常の端境にあるケース
- 異常系: エラー・権限違反・不正入力など、例外動作を確認するケース
JSON形式で返してください: {"category": "...", "confidence": 0-100, "reason": "..."}"""
def classify_by_ai(summary, steps, expected):
resp = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=200,
system=SYSTEM,
messages=[{"role": "user", "content":
f"概要:{summary}\n手順:{steps}\n期待結果:{expected}"}]
)
return json.loads(resp.content[0].text)
確定度スコアに閾値(70)を設け、低スコアは人間レビューキューに積みます。
CONFIDENCE_THRESHOLD = 70
def classify(row_data):
text = f"{row_data.get('概要', '')} {row_data.get('確認内容', '')}"
# Pass 1
result = classify_by_keyword(text)
if result:
return result, 100, "keyword"
# Pass 2
ai = classify_by_ai(
row_data.get("概要", ""),
row_data.get("手順", ""),
row_data.get("期待結果", "")
)
if ai["confidence"] < CONFIDENCE_THRESHOLD:
return None, ai["confidence"], "要人間レビュー"
return ai["category"], ai["confidence"], "ai"
処理結果の内訳
| 分類手段 | 割合 | 特徴 |
|---|---|---|
| Pass 1(キーワード) | 約70% | 高速・説明可能・追加コストなし |
| Pass 2(AI) | 約20% | 柔軟・精度優先 |
| 人間レビューキュー | 約10% | 確定度 70 未満のケース |
キーワードマッチが7割をカバーできたのは、テスト記述に一定のパターンがあるからです。「確認内容に『エラーメッセージが表示される』と書いてあれば異常系」のような、テキストだけで一意に決まるケースが意外と多くありました。
Part 3: 分類結果が示した「設計の偏り」
数値が示す構造的な問題
再分類結果の分布(76,812件 / 8,274件 / 1,541件)を一般的な目安と並べると、問題の輪郭が浮かびます。
正常系 準正常系 異常系
一般的な目安 ██████████ ████ ████
~60% ~20% ~20%
今回の結果 ████████████████████████████████████████████▌ ████ ▉
88.6% 9.5% 1.8%
異常系が目安の 1/10 以下です。これは「ラベルが間違っていた」だけの問題ではありません。そもそも異常系のテストケースが設計・起票されていなかったことを意味しています。ラベルを正しく付け直しても、1.8% という比率は変わらないのです。
機能領域別に「穴」を地図化する
分類結果をフォルダ(機能領域)単位で集計すると、どこが薄いかが一目でわかります。
from collections import defaultdict
import pandas as pd
area_counts = defaultdict(lambda: {"正常系": 0, "準正常系": 0, "異常系": 0})
for row in results:
area = row["file"].split("/")[1] # フォルダ名を機能領域として使用
cat = row["種別"]
if cat in area_counts[area]:
area_counts[area][cat] += 1
df = pd.DataFrame(area_counts).T
df["異常系比率"] = df["異常系"] / df.sum(axis=1)
print(df.sort_values("異常系比率").head(10))
出力された「異常系比率が低い機能領域ランキング」が、そのまま追加テスト設計の優先リストになります。エラーハンドリング・権限制御・入力バリデーションに関わる機能が上位に来た場合は、設計見直しの優先度が高いサインです。
AI が判断に迷ったパターンは「定義が揺れている箇所」
確定度スコアが 70 未満になったケースを分析すると、チーム内の定義が曖昧な境界と一致していました。
| ケースの特徴 | AIが迷った理由 |
|---|---|
| 「入力が空の場合」 | 空入力は境界値(準正常系)か、エラー扱い(異常系)か |
| 「対象データが存在しない状態で操作」 | データなし状態は正常系か異常系か |
| 「最大件数ちょうどを登録」 | 上限値は準正常系、上限 +1 件は異常系 — ちょうどは? |
このリストをチームで共有すると「確かにうちでも議論になる」という反応でした。AI が迷ったポイントは、ドキュメント化されていない判断基準が存在する箇所のインデックスとして機能します。
おわりに
2パス再分類の直接の成果は「86,627件の種別ラベルを正しくする」ことでしたが、本当の価値は分類後の分布が設計の問題を教えてくれたことです。
| フェーズ | 手段 | 得られた価値 |
|---|---|---|
| 全件スキャン | openpyxl 一括読み込み | 169ファイルを手作業ゼロで集計 |
| 再分類 | キーワード+AIの2パス | 精度とコストを両立 |
| 分析 | 機能領域別集計 | 設計の穴を地図として可視化 |
「種別列が全部正常系」という状態は、多くのプロジェクトで起きているはずです。ラベルを直すだけで終わらせず、その分布を見て設計に問い直すところまで持っていくと、テスト品質改善の入り口になります。