テストで有名な「@t_wada」さんもおすすめしていた「単体テストの考え方/使い方」を読みました。
非常に勉強になりましたのでアウトプットしていきます。
書籍の内容を紹介する前に、自分が考える「テストで最も大切なこと」を記載します。
テストの目的は「プロジェクトの持続的な成長」です。
つまり、保守がしやすく、かつ、変更もしやすいソフトウェアを提供することです。
これを実現できないテストは意味がありません。
例えば、プロダクションコードをリファクタリングするとエラーになるようなテストには価値がないです。
価値がないというよりも、明らかな負債です。
そのことを念頭においてテストを作成する必要があります。
書籍では、保守がしやすく、かつ、変更もしやすいソフトウェアを提供できるようなエンジニアになるためのエッセンスが詰まっていましたので、これらを紹介していきます。
テストの目的は、ソフトウェア開発プロジェクトの成長を持続可能なものにすること。
プロジェクトが成長するにつれて、ソフトウェアは煩雑・無秩序になり、開発スピードが落ちる。
質の良いテストは、コードの変更に伴う退行を防ぎ、プロジェクトの持続可能的な成長に大きく貢献する。
「質の良いテスト」であることがポイント。
まず前提として、コードは全て負債であり、テストコードも負債である。
コードが増えれば、バグの可能性が増えたり、維持コストが高くなったりする。
特にテストコードの保守には以下のようなコストがかかる。
そのため、質の悪いテストを作成すべきでない。
システムの核となる部分(ドメイン層)に対して高い網羅率を維持できることは開発にとって良いことではあるが、網羅率が高いこととテストの質が良いことはイコールではない。
網羅率を目標にすると網羅率を高めるためだけの質の低いテストを作成することになり、負債が増える。
網羅率が低い場合はテストの質が低いことが多いので、参考として網羅率を取得するのはOK
優れたテストの特徴
単体テストの定義は以下の通り。
隔離された状態については、二つの解釈がある。
一つは「古典学派(デトロイト学派)」、もう一つは「ロンドン学派(モック主義)」。
筆者は古典学派であった。そのため、ロンドン学派に対しての古典学派の良い箇所の説明をする。
隔離された状態を、テスト対象システム(System Under Test)から協力者オブジェクト(Collaborator)を隔離した状態と考える。
つまり、依存を全てテストダブル(モックやスタブ)に置き換えるという考え方。
メリットは以下の通り。
各テストケースがお互いに影響を与えることなく、個別に実行できる状態を隔離された状態と考える。
つまり、複数のテストケースを同時に実行することを可能にすべきという考え方。
そのため、DBやファイルなどのプロセス外依存が存在する場合は、テストケース間で共有させるか、テストダブルで置き換える。
そうすることで、テスト実行時間も短くなる。
ロンドン学派のメリットに対して、筆者は以下のように考えている。
単体テストは、一単位のコードではなく、一単位の振る舞いを検証すべきである。
無理に一クラスにつき一つのテストを作成すると、テストが詳細になりすぎて、わかりづらくなる。
(後述するが、基本的にテストは全てブラックボックステストにすべき)
また、古典学派の場合は、依存関係が複雑になるとテストしづらくなるが、そもそも依存関係が複雑なコードは設計が誤っている。
さらに、テストが失敗した際の問題発見も、コード変更のたびにテストを実行していれば、最後の変更が原因とすぐに判断できる。
単体テストの定義を古典学派的に解釈すると以下のようになる。
テストの基本構造はAAAパターン。
(Given-When-Thenパターンもあるが構造的には同じ)
単体テストでは、同じフェーズを複数繰り返すべきではない。
つまり、「準備 -> 実行 -> 確認 -> 実行 -> 確認」のようなテストを作成すべきではないということ。
単体テストは一単位の振る舞いだけしか検証しない。
テストケースは簡潔で、実行時間が短く、簡単に理解できるようにすべきであり、同じフェーズを繰り返している場合は、複数のケースに分割する。
ただし、統合テストでは、実行時間を短くしたり、効率的に実行するために、同じフェーズを複数繰り返すことは許容する。
また、以下のように各フェーズを明確に区別すべき。
テストフィクスチャはテストの最初に作成するのではなく、テストケースごとに作成すべき。
そうすることで、一つのテストケースに関する修正が、他のテストケースに影響を与えなくなる。
例えば、プライベートなファクトリメソッドを導入することで、テストケースごとに作成できる。
名づけの指針。
重要なことは、非開発者に対してどのような検証をするのかが伝わるような名前をつけるということである。
例えば、テストメソッド名にテスト対象メソッド名をつけるのはアンチパターンである。
これだと何をテストするのか、非開発者にはわからない。
どのような条件の時に、どのような結果になるのかをテストメソッド名にいれるべきである。
また、テストメソッド名に「shoud be(べきである)」を使うのもアンチパターンである。
単体テストとは、一単位の振る舞いについての一つの不可分な事実(シナリオ)を伝えるものであるので、テストメソッド名に希望や願望を含めるべきではない。
「shoud be(べきである)」ではなく、「is(である)」を使うべきである。
さらに細かいが、テストメソッド名を英語でつける場合は、英語の文法として適切な表現にすべき。
そうすることでテストコードが読みやすくなる。
特に「a(単数系」や「s(複数系)」は正しくつけるべきである。
ただし、なくても伝わる単語は冗長なので削るべきである。
先ほど、非開発者に検証内容が伝わらないので、テストメソッド名にテスト対象メソッド名をつけるのはアンチパターンであると述べたが、他にもテスト対象メソッド名を含めるべきでない理由がある。
テストはアプリケーションの振る舞いをテストしているのであって、コードをテストしているのではない。
テスト対象のメソッド名を含めると、対象のメソッド名を変更したときに、振る舞い自体は変わっていないのにも関わらず、テストメソッド名も変える必要が出てしまう。
ただし、ユーティリティ系のコードには、ビジネスロジックは含まれていないので、メソッド名を含めても良い。
また、同様の理由でテスト対象のシステム名も対象システム名ではなく、sut(System Under Test)と名づける。
なお、単体テストでは一つの振る舞いをテストするので、複数のクラスにまたがったテストをすることもあるが、sutは振る舞いの入り口となるクラスのことを指す。
こうすることで、テスト対象システムとその依存とを明確に区別することもできるので、テストが見やすくなる。
検証する振る舞いが非常に複雑な場合はパラメータ化テストが便利。
パラメータ化テストはテストコードを劇的に減らせる一方で、テストメソッドが何の事実を表現しているかわかりづらくなる。
そのため、正常系のパラメータ化テストと異常系のパラメータ化テストをわけるべき。
良い単体テストを構成する四つの柱がある
退行とはバグのこと。
何らかの変更を加えたあとに既存の機能が意図したように動かなくなることを指す。
コードは資産ではなく、負債。
コードが増えるほど、潜在的なバグが増える。
そのため、持続的にプロジェクトが成長するためには、退行から保護する仕組みが必要。
退行に対する保護がテストにどのくらい備わっているかを把握するには次のことに目を向ける。
これらが高ければ、退行に対する保護がテストに備わっていると判断できる。
取るに足らないコード(例えばGetter, Setterなど)をいくらテストしても退行に対する保護は備わらない。
リファクタリングへの耐性とは、テストが失敗することなく、プロダクションコードのリファクタリングが行えること。
別の言い方をすると、偽陽性(プロダクションコードは正しいのにテストが失敗すること)の発生が少ないこと。
偽陽性の発生が多いと、テストコードへの信頼性が損なわれる。
そうなると、テストの失敗を無視するようになり、結果として問題のあるコードを本番環境にデプロイしてしまうことに繋がる。
また、リファクタリングによって頻繁に偽陽性が発生するとリファクタリングが敬遠されるようになる。
偽陽性はテストコードがプロダクションコードと密接に結びついていると発生しやすくなる。
偽陽性を持ち込まないようにする唯一の方法は、テストで検証する対象を最終的な結果(できれば非開発者にとっても意味があるもの)にし、実装の詳細はならないようにすることである。
そのため、ロンドン学派的に一単位のコードに対するテストではなく、古典学派的に一単位の振る舞いを検証すべき。
また、ホワイトボックステストではなく、ブラックボックステストを実施すべき。
「リファクタリングへの耐性」は「退行に対する保護」より軽視されがち。
プロジェクトの初期は「退行に対する保護」の方が重要であり、リファクタリングの機会は少ないからである。
ただし、プロジェクトが成長するにつれてリファクタリングの必要性が増し、「リファクタリングへの耐性」が重要になってくる。
フィードバックは早いほど(初期段階でバグが見つかるほど)、修正にかかるコストが少なくなる。
そのために、テストを速やかに行えるようになることが大切。
テストに時間がかかると、テストを実行する回数が減ってしまい、開発が間違った方向に進んでしまっても気づくまでに時間がかかる。
保守のしやすさがどのくらいテストに備わっているかは次の二つの視点で評価できる
「退行に対する保護」と「リファクタリングへの耐性」と「迅速なフィードバック」は互いに排反している。
例えば、E2E(End-to-End)テストが良い例。
E2Eテストはエンドユーザの視点をもって行われるテストであり、外部サービスなども本番同様に利用することが多い。
つまり、もっとも多くのプロダクションコードが実行されるテストであるので「退行に対する保護」が高い。
また、エンドユーザの視点をもって行うテストであるので、偽陽性が低く、「リファクタリングへの耐性」も高い。
ただし、テストの実行に時間がかかるため、「迅速なフィードバック」は低い。
このように、全てを最大限に満たすテストを作成することはできない。
また、テストケースの価値は四つの柱の掛け算で評価できる。
そのため、四つの柱がどれかひとつでも「0」の場合は、そのテストケースの価値は「0」になる。
テストを作成するときは現実的に、どの柱を優先し、どの柱を犠牲にするか(「0」にはしない)を決断して作成する。
ただし、「リファクタリングへの耐性」は「0」か「1」かしかないため、「リファクタリングへの耐性」を最大限にしつつ、「退行に対する保護」と「迅速なフィードバック」のどちらを優先するのかというバランスをとることになる。
また、「保守のしやすさ」にも注意を払う。
例えば、E2Eテストは「退行に対する保護」を優先し、単体テストは「迅速なフィードバック」を優先する。
結合テストはその中間となる。
「テストピラミッド」という有名な概念がある。
これは図を用いずに説明することが難しいので、書籍と近い説明をしているサイトを紹介する。
ざっくり解説すると、ピラミッドの形で、上からE2Eテスト、統合テスト、単体テストがある。
ピラミッドの横幅はテストケースの数を表し、ピラミッドの下の層(単体テスト)ほどテストケースが多い。
ピラミッドの高さはエンドユーザの観点の近さを表し、ピラミッドの上の層(E2Eテスト)ほどユーザ体験に近いことを表す。
前述したように、単体テストと結合テストとE2Eテストはそれぞれ、四つの柱のどれを重要視するのかが異なるので、それぞれ大切なテストである。
ではなぜ、E2Eテストのテストケースを最も少なくするのか。
それは、E2Eテストが迅速なフィードバックの度合いが極端に低く、保守もしづらいためである。
テストケースの価値は四つの柱の掛け算であり、E2Eテストの価値は他と比べると低い。
そのため、E2Eテストは単体テストや結合テストで検証できない重要な機能のみを対象にすべきである。
テストダブル(test double)は大きくモックとスタブに分けられる。
すべてのプロダクション・コードは次の二つの観点で分類できる
理想は、公開されたAPIが全て観察可能な振る舞いと一致し、実装の詳細が全てプライベートなAPIとなっていること。
これが満たされていない、つまり、実装の詳細が公開されている(漏洩している)と、クライアントが意図しない操作をし、データ不整合が起きる可能性がある。
これを防ぐためにカプセル化を行う必要がある。
カプセル化とは、間違ったことをする選択肢をコードベースに提供させないことであり、これによって、ソフトウェア開発の持続的な開発を目指す。
また、カプセル化と似たような原則に「尋ねるな、命じよ(Tell, Don’t Ask)」というものがある。
この原則は要するに、「実装の詳細は隠せ」「データを操作させるのにメソッドを経由させろ」ということ。
先ほどのモックとスタブの説明時に、モックは検証にも用いるが、スタブは検証に用いるべきでないという話をした。
スタブは実装の詳細を置き換えるだけであり、スタブの検証をしても観察可能な振る舞いの検証にならないからである。
実装の詳細をテストするとリファクタリングへの耐性が低くなり、テストの価値が低くなってしまう。
モックは検証に用いる。
ただし、モックを使う場面は限定すべきである。
具体的には、実装の詳細を置き換えてはいけない。
これについてもう少し説明するために、ヘキサゴナルアーキテクチャの説明をする。
これも図がないと説明しづらいので書籍に近い説明をしているサイトを紹介する。
ざっくり解説すると、一般的にアプリケーションはドメイン層とアプリケーションサービス層で構成されるが、
ドメイン層を内側に、アプリケーションサービス層を外側に配置するようにしたアーキテクチャがヘキサゴナルアーキテクチャである。
外部とのやりとりは全てアプリケーションサービス層が担い、ドメイン層にはビジネスロジックのみが含まれる。
ヘキサゴナルアーキテクチャの特徴は以下の通り。
さて、正しいモックの使い方の話に戻る。
結論から述べると、モックは外部アプリケーションとのコミュニケーションのみに用いるべきである。
外部アプリケーションとのインターフェースは常に同じ仕様に従うことが期待されているため、リファクタリングしても仕様が変わることはない。
そのため、テストが壊れやすくなることはない。
また、外部アプリケーションとのコミュニケーションはクライアントが実現したいことと直接的に関係しているため、テストすべき項目である。
(書籍では外部アプリケーションのことをプロセス外依存とも呼んでいる)
ただし、データベースのようなテスト対象のアプリケーションからしかアクセスされないプロセス外依存はモックすべきでない。
なぜなら、このようなプロセス外依存はインターフェースが常に同じ仕様に従うことが期待されていないからである。
これらは観察可能な振る舞いではなく、実装の詳細であるので、テストすべきではない。
単体テストには三つの手法がある
良い単体テストを構成する四つの柱のうち「退行に対する保護」と「迅速なフィードバック」はどれも変わらない。
過度にモックを使うと実行されるプロダクションコードが少なくなり、退行に対する保護が低くなるが、
これは、コミュニケーションベーステスト自体の問題ではなく、過度にモックを使うことの問題である。
「リファクタリングへの耐性」は出力値ベーステストが一番高く、コミュニケーションベーステストが一番低い。
それは、出力値ベーステストが実装の詳細ではなく振る舞いのみに着目したテストだからである。
また、「保守のしやすさ」も出力値ベーステストが一番高く、コミュニケーションベーステストが一番低い。
それは、出力値ベーステストは比較的にコード量が少なくなるからである。
状態ベーステストは状態を検証するコードが多くなりがちであり、コミュニケーションベーステストはテストダブルを用意する必要があり、テストコードの量が増えやすい。
単体テストの三つの手法のうち出力値ベーステストを実施すべきである。
ただし、出力値ベーステストは純粋関数のときにしか適用できない。
純粋関数とは、隠れた入力や出力がない関数、つまり、全ての入出力をメソッドシグネチャで表現できる関数のこと。
数学的な意味での関数の定義に従っているので数学的関数とも呼ばれる。
また、純粋関数は参照透過性(入力から出力が一意に決定され、副作用もない)をもつ。当然だが冪等性ももつ。
隠れた入出力には以下のものがある。
このような隠れた入出力がない関数を用いたプログラミングを関数型プログラミングと呼ぶ。
しかし、副作用のないアプリケーションは現実的に使い物にならない。
そのため、関数型アーキテクチャではビジネスロジックを扱うコードと副作用を起こすコードを明確に分離する。
この考え方はヘキサゴナルアーキテクチャに似ている。
というより、関数型アーキテクチャはヘキサゴナルアーキテクチャの一種であり、より強力な制限を課したアーキテクチャである。
具体的には、ヘキサゴナルアーキテクチャはドメイン層内に限定された副作用は許容しているが、関数型アーキテクチャは全ての副作用をビジネスロジックから分離しなければならない。
関数型アーキテクチャでは、副作用をビジネスオペレーションの最初や最後に持っていくことで、ビジネスロジックと副作用を分離する。
具体的なやり方を記載すると長くなるため、書籍を読んだり、関数型アーキテクチャを調べてほしい。
個人的な感想を述べると、関数型アーキテクチャを採用するには色々なハードルがあると感じた。
ひとまず、コマンド・クエリ分離の原則を意識して実装するぐらいのことから始めるのがお手軽そうである。
ヘキサゴナルアーキテクチャや関数型アーキテクチャで見たように、プロダクションコードの設計が悪ければテストが難しくなる。
具体的にはコード同士が密結合な設計の場合、テストが難しくなる。
ただし、テストがしやすい疎結合なコードが全て良い設計であるわけではないので注意。
ここでプロダクションコードの種類について見ていく。
プロダクションコードは二つの視点で分類できる。
これらを踏まえてプロダクションコードは四つに分類できる
原則、過度に複雑なコードは「ドメインモデル/アルゴリズム」と「コントローラー」に分離すべき。
そのための方法として、質素なオブジェクト(Humble Object)という設計パターンがある。
過度に複雑になるのは、テスト対象のコードがフレームワークとなる依存に直接結びつく場合である。
このような場合、過度に複雑なコードからテストを行いやすい部分を抽出する。
その抽出された部分を包み込む質素なクラスを作成し、その作成した質素なクラスに対してテストが難しい依存を結びつける。
こうすることで、テストを行いやすい部分のみをテストすることができる。
このとき、質素なクラスにはビジネスロジック含ませないことで、テスト不要にすることが大切。
このようにすることでテストがしやすくなるだけではなく、
かの有名な原則である単一責任の原則(Single Responsibilty Principal)を遵守することにも繋がり、良い設計となる。
良い設計はプロジェクトの持続的な成長に欠かせない要素である。
繰り返し述べるが、ビジネスロジックに関するコード(ドメインモデル/アルゴリズム)と連携の指揮に関するコード(コントローラー)とを分離することが大切。
書籍では具体的なサンプルコードを用いてリファクタリングの実例を紹介していたが、ここでは省略する。
詳しくは、書籍を確認してほしい。自分も少し間を空けて改めて再読したいと思っている。
以前、単体テストの定義を以下のように説明した。
これを一つでも満たさないものが統合テストである。
また、これも以前に説明したが、統合テストは単体テストよりも保守のしやすさは低くなるが、退行に対する保護は高くなる。
一般的に単体テストはビジネスシナリオにおける異常ケースをできるだけ多く検証するのに対し、
統合テストは一件のハッピーパス(正常系)と単体テストでは検証できないすべての異常ケースを検証することが適切。
つまり、できるだけ単体テストで担保して、必要なテストのみを統合テストで行うという考え方。
ハッピーパスを検証するのは、他のシステムと統合した状態で意図したように機能するのかを検証することは重要だからである。
ゆえに、一件で全ての外部システムとのやりとりが検証できることがベストだが、できない場合は複数のハッピーパスを検証する。
これも繰り返しになるが、外部システムに対する依存は以下の二つに分けられる。
管理下にない依存に対してのみモックを使うべきである
プロセス外依存に対してインターフェースを導入することが多い。それはなぜか。
多くの開発者はプロセス外依存に対して、実装クラスが一つしかないのにインターフェースを導入する傾向がある。
彼らはインタフェースを導入する理由として以下の二つをよく挙げる。
しかし、実装クラスが一つしかないのであれば、これらは間違いである。
まず、実装が一つしかない場合は、それは抽象ではなく、疎結合にもならない。
そして、新しい機能を追加できるようにインタフェースを導入することは、YAGNI(You Aren’t Goona Need It)原則から外れることになる。
YAGNI原則は以下の二つの点から遵守することが求められる。
ではなぜ、プロセス外依存にインターフェースを使うのか。
それは、モックを作れるようにするためである。
そのため、プロセス外依存に対してモックにする必要がない限り、その依存のインターフェースをつくるべきではない。
また、前述したようにテストにおいてモックを使うのは管理下にない依存に対してのみである。
つまり、管理下にない依存に対してのみインターフェースを用意するという結論になる。
勿論、インターフェースに対して複数の実装クラスができるような場合はモックの有無に関わらずインタフェースを作って良い。
統合テストを最大限に活用するために基本となる指針は以下のとおり。
前述したAAAパターンの説明時に、
「統合テストでは、実行時間を短くしたり、効率的に実行するために、同じフェーズを複数繰り返すことは許容する。」と述べた。
ただし、あくまで許容するだけであり、原則は同じフェースを複数繰り返すべきではない。
例えば、ユーザを作成するケースとユーザを削除するケースがあるとする。
ユーザを削除するためにはユーザを作成しておく必要があるため、この二つのケースを一つのケースで実施することは効率が良いことのように思う。
しかし、基本的にはそれぞれ別々にテストすべき。
それは、各テストケースが単一の振る舞いのみを対象としていれば、何を検証しようとしているのかを理解しやすくなり、必要に応じてテテストケースを変更しやすくなるからである。
ログ出力をテストすべきか。
それは、テスト対象とするログ出力がアプリケーションの観察可能な振る舞いの一部なのか、それとも、実装の詳細なのかによって変わる。
書籍では以下のように分類している
サポートログはビジネス要求による機能であるため、コードベース上にその機能がビジネス要求を反映したものであることを明確に伝える必要がある。
そこで、全てのサポートログが定義されたDomainLoggerクラスを作成し、このDomainLoggerクラスからサポートログを出力するようにすべき。
書籍では具体的な実装例が紹介されているので、気になる方は確認してほしい。
また、ログ出力もプロセス外依存であるため、ドメイン層に含めるべきではない。
このための方法も書籍では紹介されているが、ここでは割愛する。
データベースのような管理下にある依存はモックにせずにそのまま使用してテストすべき。
このとき、テストが再現できるように、スキーマを通常のコードと同じように管理する必要がある。
つまり、スキーマをGitなどのソースコード管理システムで管理する。
また、参照データ(アプリケーションが適切に機能するために事前に用意しなければならないデータ)を登録するINSERT文もスキーマと一緒にソースコード管理する。
データベースを使ってテストを行う場合、テストデータが他のテストケースに影響を与えないよう、一つずつ実行していくのが現実的。
では、テストケースを実行した後に残ったテストデータの後始末はどうすべきか。
選択肢としては四つ考えられる。
最後にこれまでの説明の中で紹介しきれなかったアンチパターンを紹介する。
プライベートなメソッドは実装の詳細であり、単体テストすべきではない。
公開されたメソッド経由でテストすべきである。
仮に、プライベートなメソッドを直接テストしないと十分にテストできない場合は、次の二つの問題が考えられる。
テストのためにプライベートな状態を公開してはならない
テストを作成する際、プロダクションコードに定義された特定のロジックやアルゴリズムをテストコードに持ってくるべきではない。
例えば、テストの実行結果を検証するときに、プロダクションコードの関数を利用して期待値を作成するようなことはNG。
そうではなく、プロダクションコードでやっている計算処理などを自分で実施して、それを期待値にする。
テストでのみ必要とされるコードをプロダクションコードに加えてはならない。
例えば、テスト実行時は振る舞いを変えるような処理をプロダクションコードに加えることがよくあるが、これはNG。
そうすると、プロダクションコードの保守コストが増えてしまう。
また、テスト時にしか実行されない処理が誤って本番環境で実行されてしまうリスクもある。
解決方法としては例えば、インターフェースを用いて、プロダクションコード実行時とテストコード実行時で渡す具象クラスを変えることで振る舞いを変えるという方法がある。
これまでインターフェースに対してテストダブルを用いてきたが、具象クラスに対してもテストダブルを用いることはできる。
その場合、元のクラスの機能をテストダブルに一部残せるようになり、テストの際に元のクラスの機能をそのまま使えるようにすることができる。
ただし、このようなテストダブルの使い方が求められるということは、クラスが単一責任の原則(Simple Responsibilty Principle)を遵守していない証拠である。
環境コンテキストとして現在日時を扱うのはアンチパターンである。
環境コンテキストとは、静的(static)なメソッドや変数のことである。
こうするとテストで現在日時を扱うことが難しくなる。
そのため、現在日時を依存として明示的に注入するべきである。
そうすることで、テスト時に任意の日時を注入することができるようになる。
書籍を読み「気をつけねば」と強く感じた部分をまとめて終わりにしたいと思います。
以上です。長くなってしまい、申し訳ございません。
お付き合いありがとうございました。