サバンナ便り ~ソフトウェア開発の荒野を生き抜く~

第2回偽陽性と偽陰性 ~自動テストの信頼性をむしばむ現象を理解する

自動テストに期待することはいくつかありますが、⁠失敗することで、テスト対象の動きが予期せず変わったことをプログラマーに教えてくれる」という役割は特に重要です。

この観点における期待外れの自動テストは2つ考えられます。失敗すべきでないときに失敗するテストと、失敗すべきときに失敗しないテストです。

失敗すべきでないときに失敗してしまうことを「偽陽性」false positiveと言います。失敗すべきときに失敗してくれないことを「偽陰性」false negativeと言います。今回はこの2つを整理します。

4象限で整理する

偽陽性と偽陰性は4象限で整理すると理解しやすくなります。プロダクトコードの正しさ、自動テストの実行結果(成功/失敗)という2つの軸で整理すると、表1ができあがります。

表1 偽陽性と偽陰性
プロダクトコードが→
↓テスト結果が
正しい 誤っている
成功 期待どおり 偽陰性
失敗 偽陽性 期待どおり

偽陽性とは、プロダクトコードが正しいにもかかわらずテストが失敗してしまう状況です。偽陰性とは、プロダクトコードが誤っているにもかかわらずテストが成功してしまう状況です。これらは火災報知器をイメージするとわかりやすいでしょう。火が出ていないのに、火災報知器が鳴るなら誤検知(偽陽性)です。火が出ているのに、火災報知器が鳴らないなら検知漏れ(偽陰性)です。

偽陽性その1:脆いテスト

偽陽性は、自動テストの世界で近年よく議論されるようになりました。代表的な偽陽性のパターンは2つあり、その1つ目が「脆いテスト」です。脆いテストとは、テスト対象の構造変更に弱いテストのことです。プロダクトコードの詳細に不必要に依存したテストを書いてしまうと、外部から見た振る舞いやインタフェースには変更がなくとも、実装の詳細が変わっただけで失敗してしまうテストになりがちです。変わりやすい画面レイアウトに依存したテストも、画面変更のたびにメンテナンスが必要です。

偽陽性その2:信頼不能テスト

もう一つが「信頼不能テスト」flaky testです。信頼不能テストとは、テストコードにもプロダクトコードにも変更がないのにテストが成功したり失敗したりするような、不安定で決定性のないテストのことです。自動テストからブラウザを介して実システムを動かすE2Eend to endテストなどが代表例です。サーバやネットワークの負荷によってタイムアウトしたり、ブラウザが不安定でクリック要素出現の待ち時間を超えてしまったりと、コード外の要因によってテスト結果が左右されてしまいます。

偽陰性その1:空振り

代表的な偽陰性のパターンは3つあり、その中でも最も単純なものが「空振り」です。テストを書いて実行したつもりでも、実はそのテストは実行されていなかったという状況です。たとえば、テストメソッドの命名規則に従っていなかったり、アノテーションを付け忘れていたりして、メソッドがテストとして認識されないため実行されていなかったといった原因が考えられます。

偽陰性その2:カバレッジ不足

2つ目の代表的な偽陰性が「カバレッジ不足」です。カバレッジ不足とは、書かれるべきテストが書かれていないということです。さらに詳しくは、⁠実装レベルのカバレッジ不足」「仕様レベルのカバレッジ不足」があります。

実装レベルのカバレッジ不足は、テスト対象のコードにテストから実行されないコード片がある状況です。たとえば、プロダクトコードの条件分岐に誤りがある場合に、その分岐を通るテストが書かれていなければ誤りの検知漏れが発生してしまいます。

仕様レベルのカバレッジ不足は、書かれるべきプロダクトコードが書かれておらず、そのコードに対するテストコードも存在しない状況です。たとえば、本来書かれるべき条件分岐が実装者の認識漏れにより書かれておらず、その実装者がテストの書き手でもある場合には、条件分岐をテストするコードも書かれていないでしょう。プロダクトコードが不足し、対応するテストコードも等しく不足しているので、皮肉にもテスト全体では成功してしまいます。

実装レベルのカバレッジ不足はテストカバレッジツールで可視化できますが、仕様レベルのカバレッジ不足はそもそもコードが書かれていないので可視化が難しく、偽陰性の中でも特に手強い相手です。

偽陰性その3:テスト対象ロジックのテストコードへの漏れ出し

3つ目の代表的な偽陰性が「テスト対象ロジックのテストコードへの漏れ出し」です。テスト対象のロジックをテストコードのほうにも書いてしまうミスは、テストコードを書き慣れていないと気付きにくいものです。実例として、税込価格から税額を計算するメソッドとそのテストコードを見てみましょう。

コード プロダクトコード
class Item {
  // コンストラクタなど省略
  tax_amount() {
    const rate = (this.tax_rate / 100);
    return (this.price / (1 + rate)) * rate;
  }
}
コード テストコード
it('税込価格から税額を返す', () => {
  const item = new Item('技評茶', 130, 8);
  const expected =
        (130 / (1 + (8 / 100))) * (8 / 100);
  assert.equal(item.tax_amount(), expected);
});

実は、プロダクトコードの計算ロジックには誤りがあり、税額に1円未満の端数が発生しています(本来は端数切り捨てなどが必要です⁠⁠。しかし、テストコードでも同じロジックで期待値が計算されているため同額の端数が発生し、等価とみなされてテストが成功してしまいます。

プロダクトコードが誤っているものの、それに対応するテストコードも等しく誤っているので、むしろテストは成功してしまうというパターンです。このパターンはさまざまな形でテストコードに現れるので注意が必要です。テストコードでは期待値を直接書くか(意味不明な数字にならないように説明用変数を伴うのがよいでしょう⁠⁠、あるいは別のロジックで計算するなどの方法を用いないと偽陰性を招いてしまいます。

欠陥挿入:テストコードのテスト

発生がわかりやすい偽陽性と異なり、静かで気付きにくい偽陰性に立ち向かうには、成功しているテスト群を疑う姿勢が必要です。テストコードの質に不安があるのでテストコードのテストを行いたくなりますが、テストコードのテストコードを書いても際限がありません。テストコードのテストはプロダクトコードで行います。

具体的には、プロダクトコードに明らかな誤りを一時的に混入させ、その誤りをテストが検知できるかを確かめます。明らかな誤りを入れたのにテストが成功のままならば、偽陰性が発生しています。このような手法を欠陥挿入と言います。

おわりに

自動テストの偽陽性と偽陰性は、どちらも手強い問題です。偽陽性を放置すると、テストの失敗に対して鈍感になったり、テストが失敗しやすいためリファクタリングに後ろ向きになったりしてしまいます。偽陰性は、テストできていると思っていたらできていない、静かで恐ろしい問題です。まずは概念を正確に理解し、問題を認識できるようになることが上達への第一歩です。

おすすめ記事

記事・ニュース一覧