🤓

単体テストやめてみた

2024/06/07に公開

単体テストにかける工数が大きすぎる...
特にちょっとしたリファクタリングのたびにテストでエラーが出ては修正した内容に合わせてテストを書き直す、ということが不毛に思えて仕方なくなった。
関数のインプットとアウトプットが特に変わらないのにテストでエラーが出て、そこに対して時間をかけるのに意味はないのではないか。

この問いが気になったので読んでみた。

単体テストの考え方/使い方

https://www.amazon.co.jp/単体テストの考え方-使い方-Vladimir-Khorikov/dp/4839981728

単体テストの2つの派閥

やはり自分も疑問に思ったこの問いには長く議論がなされていたよう。
普段自分が書いているテストがリファクタリングに対して単体テストがエラーを吐くような作りになってしまっている要因としてはテスト対象の関数が外のクラスの関数を呼び出す際、その呼び出しをモック化していたことが挙げられる。
要は関数のインプットとアウトプットのみならず、モックを通じて関数内の細かい挙動までを監視している状態としていた。(下図)

このようにテスト対象の関数外との依存を全てモックで置き換えて単体テストを行う考え方は本書ではロンドン学派として触れられている。
一方で古典学派は極力モックを使用しない。
ロンドン学派の考え方からいくと、古典学派の単体テストは外部に依存したテストになるからそれじゃ単体テストではないだろと思うかもしれないが実際その通り。
古典学派の単体テストはロンドン学派でいう結合テストに近い。

ちなみに2つの派閥の違いは単体テストでモックを使用するしないという言い方をしているが、厳密には違う。
単体テストの定義の1つに「隔離された状態で実行されること」というものがあるが、この解釈の違いからスタイルが分かれ、モックの使い方の違いに繋がっている。
詳細はここでは触れない。

ここで単体テストはモックを使用することが当たり前ではないこと、そしてモックを極力しないことでリファクタリングなどで壊れにくいテストとしていくアプローチがあることを知った。

良い単体テストの4本の柱

本書では良い単体テストを構成する要素として以下が挙げられている。

  • 退行(regression)に対する保護
  • リファクタリングへの耐性
  • 迅速なフィードバック
  • 保守のしやすさ

退行(regression)に対する保護

退行とは何らかの変更を加えた後にバグが発生し、既存の機能が意図したように動かなくなることを指す。
そしてこの退行に対する保護を高める要素として、次の3つが挙げられている。

  • テスト時に実行されるプロダクションコードの量
  • そのコードの複雑さ
  • そのコードが扱っているドメインの重要性

テスト対象とするコードは量が多く、複雑で、バグが持ち込まれると被害が大きくなってしまうような重要なものにしましょう。取るに足らないコードは無視しましょう。ということ。

リファクタリングへの耐性

リファクタリングへの耐性が意味するところは、どれだけテストが失敗することなくテスト対象のコードをリファクタリングできるか、ということ。
テスト対象のコードの振る舞いは変わらず正しいのにテストが失敗してしまうことを偽陽性(嘘の警告)と呼び、この偽陽性が発生するほどテスト自体の意味や価値を下げてしまう。

ここがまさに自分が単体テストに対して疑問を持つようになったきっかけ。本書でも触れられているが自分の経験談としても偽陽性が当たり前となっているテストが実装されていると、リファクタリングが億劫になる。

「本当はここもリファクタリングしたい方いいな。」
「でも触るとここに関わるテストコードも修正しないといけなくなってしまう...」
「そこまでの工数を今かけるべきではないからいったん今は放っておこう。」
ってなっちゃう。

偽陽性を引き起こす原因はテスト対象のコードとテストコードの結合度が高くなっていること。
偽陽性を引き起こさないようにするためにテストコードが検証する対象を最終的な結果のみとし、テスト対象のコードがその結果を得るための細かい手順は見ないようにする必要がある。

迅速なフィードバック

いかにテストを速やかに行えるようになるか。
テストを速やかに行えるようになれば用意されるテストケースも増え、テストの頻度も増えていく。
そうするとバグが持ち込まれたらテストによってバグをすぐ検出できるようになり、バグ修正のコストを下げることができるよね。というロジック。

保守のしやすさ

保守をしやすくするために「テストケースを簡単に理解できること」「テストが簡単に行えること」の2つの観点に触れられている。

この観点を満たすために

  • テストコードの量が増えると理解が難しくなるので重要なコードに絞ってテストを書く。
  • プロセス外依存(アプリケーション自体の外に対する依存)を減らす。
  • (迅速なフィードバックを得るためにも繋がるが)CIを導入する。
    などが考えられる。

これらを踏まえて

良いテストの条件として触れた4つの柱のうち、保守のしやすさ以外の3つは相反関係にある。
つまり、全てを完璧に満たすテストは不可能だということをまず理解しておく。
その上で自分が何を取りたいかを考えながらテストの方針を組み立てていく必要がある。

例えば、

  • 実装の詳細まで検証するテストとしていれば、退行に対する保護や迅速なフィードバックは良くなるがリファクタリングへの耐性が悪くなる。
  • テストコード量を増やせば退行に対する保護は良くなるが時間がかかってしまうため、迅速なフィードバックは悪くなる。
  • 退行に対する保護がない(バグを検出しない)テストが良しとされているということはつまりテストをするに値しないテストである。
    ということになる。(下図)

自分の場合はリファクタリングへの耐性がない壊れやすいテストを書いていたがためにテストの保守への工数やスピード感の観点で疑問が生じる開発となってしまっていた。

これらより、テスト方針に以下を取り入れることにしてみた。

モックを極力使用しない

  • モックによる他クラスとの隔離をやめてインターフェース層からデータ永続層までの一連の処理をまとめてテスト
  • つまり、関数ごとのテスト(単体テスト)をやめて、機能ごとのテスト(結合テスト)をメインとする。
  • ただし、プロセス外依存はテスト実施の難易度が上がるし外部APIの種類によってはお金がかかってしまうため、そういう場合はモック化

テスト対象を選別する

  • 対象とするテストを絞る。
    • 今まではテストのカバレッジ(網羅率)を是とする考え方を持っていたが改める。
    • 自分の場合はWeb系なので主に画面に表示されない機能を中心にテスト対象とする方針とした。
    • 別途手動でのE2Eテストを実施しているが画面に表示される機能はそちらの確認で良しとする。

実際に試してみて

明らかにテストに対する工数を減らすことができたが、少し書いてみて以下のデメリットを感じた。

  • テストケースが煩雑になる。
  • テストでエラーが出た時に原因を特定しにくい。
  • 偽陰性(バグの見落とし)が出ないか心配。

そりゃそうだが、特定のクラス、関数に絞ったテストではなくなり、1つ1つのテスト自体は重くなったのでテストケースはより複雑になり、エラーの原因の種類も増える。
テストの数が増えてきた場合、管理が煩雑になりそうなことや原因を特定しにくいという側面もあるので、その観点では「保守のしやすさ」や「迅速なフィードバック」にマイナスに働いている部分はあると思う。
なんらかの管理手法の検討を先々は頭の片隅入れておいた方がいいかもしれない。

偽陰性の心配はインプットとアウトプットしか見なくなった分、単体テストに比べると検証している箇所が少なくなったので、なんとなく気になるなという感じ。根拠があって心配しているわけではない。

だが、いくつかのデメリットも感じている中で「リファクタリングへの耐性」を得られたのは個人的にはうれしい。
あとモック化しない分、テストコードの分量が減り、記述が楽になった。

自分が携わっているサービスでガチガチに単体テスト組んでいるプロダクト、結合テスト主体のプロダクト、双方あるので今後の運用でお互いの良い部分、悪い部分を見極められたらなと思う。

Discussion