コードカバレッジ90%なのに、午前2時にページャーが鳴った。
ユニットテストはパスした。CIはグリーンだった。それでもバグは本番に潜り込んだ。カバレッジは嘘をつかなかったが、真実も語らなかった。実行された行を測定しただけで、実際に検証された振る舞いを測定したわけではないからだ。
ほとんどのチームは、苦い経験をしてこの事実に気づく。何百ものユニットテストを書き、カバレッジバッジが緑に変わるのを見て、要塞は安全だと思い込む。要塞には壁はある。ただ、屋根がないだけだ。
ユニットテストは、あなたが想定した通りの不具合しか検出しない
ユニットテストは、コードに対するあなたの仮説を検証する。問題は、バグがあなたの仮説など気にしないことだ。
単純な価格計算関数を考えてみよう:
def calculate_total(items, tax_rate):
subtotal = sum(item["price"] * item["quantity"] for item in items)
tax = subtotal * tax_rate
return round(subtotal + tax, 2)
典型的なユニットテストスイートは堅牢に見える:
def test_calculate_total_with_tax():
items = [{"price": 10.00, "quantity": 2}]
assert calculate_total(items, 0.08) == 21.60
def test_calculate_total_empty_cart():
assert calculate_total([], 0.08) == 0.00
両方パスする。カバレッジは100%。関数は出荷される。
日本の顧客が、¥100、¥100、¥100の3商品を購入したとしよう。税率は0.10。合計は¥330のはず。関数は¥330.00を返す。問題なし。
スイスの顧客が、CHF 12.35の商品を7.7%のVATで購入した。期待値:CHF 13.30。実際:CHF 13.30。まだ問題なし。
オレゴン州の顧客が、税率0.0で$0.01の商品を2つ購入した。期待値:$0.02。実際:$0.02。パス。
バグが発生するのは、税率がNoneの地域(税務サービスが不明な郵便番号に対してnullを返したため)の顧客が購入手続きをしようとしたときだ。関数はsubtotal * Noneを乗算し、TypeErrorを投げる。あなたのユニットテストは、税率が常にfloatであると仮定していたため、Noneを税率として渡すことはなかった。
これが根本的な限界だ。ユニットテストは、テストしようと思ったパスを実行する。バグは、テストしなかったパスに潜んでいる。
ユニットテストが届かない4つの領域
Integration Boundaries
ユニットテストは外部依存をモックで置き換える。モックは礼儀正しい。言われた通りのことを正確に行う。本物のAPIは礼儀正しくない。
モックのデータベースはミリ秒で行を返す。本番環境では秒単位で返すか、タイムアウトするか、存在すら知らなかった読み取りレプリカの遅延により重複行を返す。
モックのHTTPクライアントはきれいなJSONを返す。本物のサービスは火曜日に200で空のボディを返すこともある。
モックは、他のシステムに対するあなたの仮説に照らしてコードをテストする。本番環境は、現実に照らしてコードをテストする。これらは異なるテストスイートであり、パス率も異なる。
Stateful and Temporal Bugs
ユニットテストは隔離されて実行される。各テストは新鮮な状態を得る。本番環境は長時間実行されるプロセスであり、状態が蓄積し、漏洩し、自己相互作用を起こす。
負荷下でエントリを追い出すメモリキャッシュ。10,000リクエスト後に枯渇する接続プール。サマータイムの境界をまたいで実行されると失敗するタイムスタンプ比較。これらのバグは、時間、量、またはシーケンスを必要として顕在化する。ユニットテストには、これらのいずれもない。
Concurrency and Race Conditions
2人のユーザーが同時に同じレコードを更新する。1つのリクエストが残高を読み取り、もう1つが引き落とし、最初のリクエストが古い値を書き戻す。お金が消える。あなたのユニットテストはシングルスレッドで順次実行される。これは検出できない。
個々のロックプリミティブに対するユニットテストは書ける。しかし、システム全体がrace-freeであることを証明するユニットテストは書けない。状態空間が広すぎ、タイミングが非決定論的すぎるからだ。
The Environment Itself
あなたのテストはUbuntu 22.04、Python 3.11、RAM 4GB、ファイアウォールルールなしで実行される。本番環境はAlpine Linux、Python 3.11、RAM 512MB、60秒でアイドルなTCP接続を切断するセキュリティグループで動作している。
socketモジュールは異なる振る舞いをする。mmapの制限はより低い。ロケール設定により、strftimeがパーサーが予期しない形式で日付をフォーマットする。これらはコードのバグではない。context bugsだ。ユニットテストにはcontextがない。
カバレッジ率が誤解を招く理由
カバレッジツールは行の実行を測定し、アサーションの品質は測定しない。テストは関数のすべての行を実行し、意味のあることを何も検証しないこともある。
def test_poor_coverage_quality():
result = calculate_total([{"price": 1.0, "quantity": 1}], 0.0)
# Executed 100% of lines. Verified almost nothing.
assert result is not None
このテストは100%の行カバレッジを与えるが、信頼性はゼロだ。多くのチームは、そのメトリックが測定しやすいからという理由で最適化を行う。信頼性は測定しにくい。だから彼らは代わりにカバレッジを測定し、2つが相関することを期待する。
相関しない。
代わりに(あるいは追加で)何をテストすべきか
これはユニットテスト否定論ではない。ユニットテストは高速で決定的であり、アルゴリズム的ロジックを検証するのに優れている。ただ、不完全なだけだ。
以下は、CIパイプラインを45分もの負債に変えることなく、隙間を埋める方法だ。
Test at System Boundaries, Not Just Internals
データベースをモック化する代わりに、本物のテストデータベースにヒットするテストを書く。これらは遅いので、選択的に実行する。しかし、ORMクエリとクエリプランナーの実際の振る舞いの不一致を検出できる。
HTTPクライアントをモック化する代わりに、コンテナ内でダウンストリームサービスを起動する。これにより、schema drift、タイムアウトの振る舞い、実際の接続失敗時にのみ発動するリトライロジックを検出できる。
Add Contract Tests for External Services
CIで本物の依存関係を実行できない場合は、contract testsを使う。これらは、あなたのコンシューマー側の期待がプロバイダーの実際のAPIスキーマと一致することを検証する。
Pactのようなツールは、あなたのサービスとその依存関係との間の相互作用を記録する。プロバイダーがフィールド型を変更したりエンドポイントを削除したりすると、contract testはコードのデプロイ前に失敗する。integration testingほど良くはないが、モックが正確であることを祈るよりははるかに良い。
Use Property-Based Testing for Edge Cases
Hypothesis(Python)やfast-check(JavaScript)のようなproperty-based testingツールは、何千ものランダムな入力を生成し、invariantsが維持されることを検証する。
from hypothesis import given, strategies as st
@given(
st.lists(st.fixed_dictionaries({
"price": st.decimals(min_value=0, max_value=10000, places=2),
"quantity": st.integers(min_value=0, max_value=1000)
})),
st.one_of(st.none(), st.decimals(min_value=0, max_value=1, places=4))
)
def test_calculate_total_invariants(items, tax_rate):
if tax_rate is None:
with pytest.raises(TypeError):
calculate_total(items, tax_rate)
return
result = calculate_total(items, tax_rate)
assert result >= 0
assert result == round(result, 2)
このテストは、あなたがその特定のケースを書こうと思いつかなくても、None税率のバグを検出できただろう。空のリスト、巨大なリスト、ゼロ価格、最大精度の小数など、あなたが決して考えもしない入力を生成する。最初にそれらを想像する必要なく、あなたのロジックの端を見つけ出す。
Monitor Production Like It’s a Test Environment
最も誠実なテストスイートは、本番トラフィックだ。出荷前にバグを捕まえられないなら、被害が出る前に捕まえろ。
フィーチャーフラグを使って、まず1%のユーザーに変更をロールアウトする。エラー率、レイテンシーパーセンタイル、ビジネスメトリックを監視する。ユニットテストは、コードが隔離された環境で期待通りに動作するかを教えてくれる。本番モニターは、コードが現実の環境で期待通りに動作するかを教えてくれる。
ハードな失敗だけでなく、異常にもアラートを設定する。デプロイ後の500エラーが5%増加することは、race conditionやリソースリークが発生し始めた唯一のシグナルであることが多い。ユニットテストはこれを決して示さない。
正直なトレードオフ
ユニットテストは安価で高速であり、開発者のフィードバックループに適している。統合テストは高価で遅く、重要なバグを捕まえるのに適している。
両方が必要だ。100%のユニットテストカバレッジがあれば残りを省略できると思うのが罠だ。それは、簡単な部分を徹底的にテストしたということだ。難しい部分、夜中に目を覚まさせるような部分は、テストが見ていない場所に存在する。
ロジックとアルゴリズムのユニットテストから始める。すべてのシステム境界に統合テストを追加する。property-based testingを使って、思いつかなかった入力を見つける。すべてのテストが見逃したものを捕まえるために、本番環境を監視する。
カバレッジは虚栄のメトリックだ。唯一重要なメトリックは、夜通し安らかに眠れるかどうかだ。
テストが見逃したバグを捕まえようとしているなら、エラーデータを見ることから始めよう。Sentryは、ユニットテストが決して持ち得なかったスタックトレースとcontextと共に、本番環境で何が壊れているかを示してくれる。