はじめに
テストコードのレビュー基準において、
カバレッジのパーセンテージを評価の指針にする手法、よく採用されているんじゃないでしょうか?
カバレッジとは、プログラムコードのどの部分がどれだけ実行されたかを示す網羅率を指します。
「数値を追えば追うほど工数はかかりますが、それでもバグが減らない。」
結構現場で見ることが多い光景かな、と感じます。
本記事では カバレッジではなく「テスト容易性(Testability)」こそが本質的な指標である ということをお話出来ればと思います。
少しふわっとした記事ですが、なにかしら持ち帰っていただけるものがあれば幸いかな、と。
カバレッジは「品質保証」ではない。単なる「通過したコードの割合」に過ぎない。
Assertion-Free Testing
以下のAdd関数とそのテストを見てください。
※ セクションタイトル名通り、「アサーションがフリー」になっているコードです。
// Add は2つの数の和を返却
func Add(a, b int) int {
return a + b
}
func TestAdd_Bad(t *testing.T) {
Add(1, 2) // 呼んでいるだけ。何も検証していない。
}
% go test --cover ./...
ok demo 0.496s coverage: 100.0% of statements
カバレッジは100%です。
しかしAddがa * bを返しても、このテストは通ります。
正しいテストとはこうかなと。
func TestAdd_Good(t *testing.T) {
got := Add(1, 2)
if got != 3 {
t.Errorf("Add(1, 2) = %d, want 3", got)
}
}
「コードを通過したか」と「コードが正しく動くか」は全く別物 です。
カバレッジが測っているのは前者の「コードが通過したか」だけです。
グッドハートの法則
今回の記事を書くにあたっていろいろと調べました。
グッドハートの法則、ご存知でしょうか?
「ある指標が目標になると、その時点でその指標は "良い指標" ではなくなる」
この法則を開発現場に当てはめると、「カバレッジを目標にした瞬間、チームには次の悪循環が生まれるリスクが出てくる」と言えるのかなと。
- 数値を満たすためだけの
assertionのないテストが量産される - テストコード自体が保守負債になり、変更のたびにテスト修正に追われる
- 「カバレッジは高いのにバグが出る」→ さらにカバレッジ閾値を上げる → 負債が加速する
なぜチームは「カバレッジを上げろ」に逃げるのか
カバレッジは「楽な指標」です。
数値化しやすく、CIに組み込みやすく、責任者への説明も簡単です。
しかし、楽であるがゆえに本質を見落とします。
実装者、レビュワーともに、以下の3つの陥りがちなパターンがあるかなと。
-
「80%ルールがあるから大丈夫」
→ テストの中身を見ていない。
assertionなしのテストが混在していても数値は満たせる。 -
「新規PRにはテスト必須」
→ テストの有無は見ているが、assertionの質までレビューしていない。 -
「AIにテスト書かせれば上がる」
→ テストの量は増えるが、質は人間が担保しなければ意味がない。
いずれも 「カバレッジ」という数値を見て「テスト」の中身を見ていない 点で共通しています。
カバレッジ指標の正しい使い分け C0/C1/C2
カバレッジには3段階の粒度があります。
※ すべてを同じレベルで計測する必要はありません。
C0: 命令網羅(Statement Coverage)
各行が1回以上実行されたかを見ます。
最もコストが低く要はザルです。
func Discount(price int, isMember bool) int {
if isMember {
return price * 80 / 100
}
return price
}
// C0: isMember=true を通すだけで Discount 内の全行を実行できる
func TestDiscount_C0(t *testing.T) {
got := Discount(1000, true)
if got != 800 {
t.Errorf("Discount(1000, true) = %d, want 800", got)
}
}
C1: 分岐網羅(Branch Coverage)
すべての分岐(true/false)を通します。
ビジネスロジックの最低ラインです。
func TestDiscount_C1(t *testing.T) {
tests := []struct {
name string
price int
isMember bool
want int
}{
{"会員割引あり", 1000, true, 800},
{"通常価格", 1000, false, 1000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Discount(tt.price, tt.isMember)
if got != tt.want {
t.Errorf("Discount(%d, %v) = %d, want %d", tt.price, tt.isMember, got, tt.want)
}
})
}
}
C2: 条件網羅(Condition Coverage)
複合条件の各部分がtrue/falseの両方を取るケースを網羅します。
コストは高いですが、決済・認証などクリティカルなロジックでは必要です。
func CanAccess(isAdmin bool, isOwner bool) bool {
return isAdmin || isOwner
}
func TestCanAccess_C2(t *testing.T) {
tests := []struct {
name string
isAdmin bool
isOwner bool
want bool
}{
{"両方true", true, true, true},
{"adminのみ", true, false, true},
{"ownerのみ", false, true, true},
{"両方false", false, false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CanAccess(tt.isAdmin, tt.isOwner)
if got != tt.want {
t.Errorf("CanAccess(%v, %v) = %v, want %v", tt.isAdmin, tt.isOwner, got, tt.want)
}
})
}
}
※ C1, C2準拠を綺麗に書いているチームは、よくテーブル駆動テストが採用されている現場が多いと思います。
指標の使い分け基準
じゃあ「C2で全部行こうよ。」は愚策かなと思います。
テストにかけるコストが肥大化しますし、CIの実行自体の時間も伸びてしまいます。
従って開発速度が落ちますよね。
多少定性的ではあると思うんですが、
「コアドメインにはC2を、それ以外にはC1/C0を。」
と、レイヤーごとに指標を使い分けることで、コストと品質のバランスが取れるのかなと。
| 指標 | 適用範囲 | コスト感 |
|---|---|---|
| C0 | ユーティリティ、薄いラッパー | 低 |
| C1 | 一般的なビジネスロジック(最低ライン) | 中 |
| C2 | 決済・認証・コアドメイン | 高 |
上記はあくまで一例です。
プロジェクトの特性に合わせて、チームで判断基準を決めるべきかなと。
本質的指標:テスト容易性(Testability)
ここまで読んで「じゃあ何を指標にすればいいん?」と感じた方もいるかなと思います。
勿論場面にもよりますが、
私は 実装コード自体のテスト容易性を疑い続けること が重要だと感じております。
もっというと、「変更に強いコードを担保すること」がテストの目的だと思います。
「循環的複雑度が高いコードは必要なテストケースが指数関数的に増える」と、よく聞きますがまさにその通りですよね。
テストコードで品質を担保することに工数やコストを使うのは、本質的なテストを書く意味に直結しません。
※ 循環的複雑度については以下の記事が参考になりました
Before:テスト容易性が低い設計
func CalcShippingFee(weight float64, zone string, isMember bool, isFragile bool) int {
fee := 0
if zone == "local" {
if weight < 5 {
fee = 500
} else if weight < 20 {
fee = 1000
} else {
fee = 2000
}
} else if zone == "remote" {
if weight < 5 {
fee = 1000
} else if weight < 20 {
fee = 2000
} else {
fee = 4000
}
}
if isMember {
fee = fee * 90 / 100
}
if isFragile {
fee += 300
}
return fee
}
まぁー、読む気にならないコードですよね。
説明するまでもなく明らかですが、この関数の循環的複雑度は高いです。
C1を満たすだけでも多数のテストケースが必要です。
After:テスト容易性が高い設計
func baseFee(weight float64) int {
switch {
case weight < 5:
return 500
case weight < 20:
return 1000
default:
return 2000
}
}
func zoneMultiplier(zone string) int {
if zone == "remote" {
return 2
}
return 1
}
func CalcShippingFee(weight float64, zone string, isMember bool, isFragile bool) int {
fee := baseFee(weight) * zoneMultiplier(zone)
if isMember {
fee = fee * 90 / 100
}
if isFragile {
fee += 300
}
return fee
}
関数を分離することで、各関数の循環的複雑度が下がり、テストケースが単純になります。
baseFeeとzoneMultiplierはそれぞれ独立してテストでき、CalcShippingFeeのテストは組み合わせの確認だけで済みます。
Go言語でよく使う複雑度への対策方法
カバレッジの閾値を上げるより、複雑度の上限を設ける 方がコードの品質改善に直結します。
golangci-lintだと以下がよく使われるlinterです。
循環的複雑度
認知的複雑度
設定方法
デフォルトのしきい値は30みたいです。
これ、私は「結構高めだよなぁ」という印象を持っています。
勿論、しきい値の指定も出来ますので。
linters:
settings:
gocyclo:
min-complexity: 10
gocognit:
min-complexity: 10
AI時代のテスト戦略
※ 本記事では具体的なプロンプトの明示は行いません。
生成AIはカバレッジを上げることが得意です。
しかし「コードを通過するテスト」を量産しているだけで、意味のあるテストかどうかは別問題です。
チームとしてAI生成テストの品質を担保するために、3つのガードレールを導入する必要があるかなと。
1. レビュー基準の明文化
カバレッジ数値よりアサーションの質を見るようなプロンプトが必要かなと。
この辺はチームの指針、運用ルールとも大きく絡んできそうですよね。
どのチームもまだ確立しきれてない、模索段階なのかなとも思います。
2. ミューテーションテストの導入
ミューテーションテストは、意図的に変異させたバグを加え、テストがそれを検出できるかを検証する手法です。
「テストの質を測るテスト」として機能します。
AI生成を多用しているプロジェクトでの、ミューテーションテストの導入はかなり現実的な戦略かと。
3. CIパイプラインでのベースライン設定
レガシーコードに対して一律のカバレッジ閾値を課すのは現実的ではないですよね。
既存コードにはベースラインを設け、新規コードにのみ高い基準を適用する運用が有効かなと。
(実際この辺りはかなり難しいところですよね...)
まとめ
今回はナレッジ系じゃなく、自身のテストに対する見解をまとめてみました。
- 「カバレッジのスコアが上がらない」は「テストが足りない」ではありません。
- 「カバレッジのスコアを上げること」を目的としないで。
本来の目的は「変更に強いコードを担保すること」を満たすこと。
"カバレッジを上げるより、実装コード自体のテスト容易性を疑い続けることが大事"です。
循環的複雑度を下げれば、テストは自然とシンプルになるかなと。
以上です。ここまで読んでいただきありがとうございました。