一般社団法人 全国個人事業主支援協会

COLUMN コラム

目次

はじめに

テストで有名な「@t_wada」さんもおすすめしていた「単体テストの考え方/使い方」を読みました。
非常に勉強になりましたのでアウトプットしていきます。

書籍の内容を紹介する前に、自分が考える「テストで最も大切なこと」を記載します。

テストの目的は「プロジェクトの持続的な成長」です。
つまり、保守がしやすく、かつ、変更もしやすいソフトウェアを提供することです。
これを実現できないテストは意味がありません。
例えば、プロダクションコードをリファクタリングするとエラーになるようなテストには価値がないです。
価値がないというよりも、明らかな負債です。
そのことを念頭においてテストを作成する必要があります。

書籍では、保守がしやすく、かつ、変更もしやすいソフトウェアを提供できるようなエンジニアになるためのエッセンスが詰まっていましたので、これらを紹介していきます。

単体テストの概要

テストの目的

テストの目的は、ソフトウェア開発プロジェクトの成長を持続可能なものにすること。
プロジェクトが成長するにつれて、ソフトウェアは煩雑・無秩序になり、開発スピードが落ちる。
質の良いテストは、コードの変更に伴う退行を防ぎ、プロジェクトの持続可能的な成長に大きく貢献する。

「質の良いテスト」であることがポイント。
まず前提として、コードは全て負債であり、テストコードも負債である。
コードが増えれば、バグの可能性が増えたり、維持コストが高くなったりする。
特にテストコードの保守には以下のようなコストがかかる。

  • プロダクションコードのリファクタリングに伴ってテストコードをリファクタリングする
  • プロダクションコードを変更するたびにテストを実施する
  • テストが間違って失敗した際にその対処をする
  • プロダクションコードがどのように振る舞うのかを理解するためにテストコードを読む

そのため、質の悪いテストを作成すべきでない。

網羅率

システムの核となる部分(ドメイン層)に対して高い網羅率を維持できることは開発にとって良いことではあるが、網羅率が高いこととテストの質が良いことはイコールではない。
網羅率を目標にすると網羅率を高めるためだけの質の低いテストを作成することになり、負債が増える。

網羅率が低い場合はテストの質が低いことが多いので、参考として網羅率を取得するのはOK

テストの質

優れたテストの特徴

  • テストすることが開発サイクルの中に組み込まれている
    • コードに変更を加えるたびにテストが実施されるのが理想
  • コードベースの特に重要な部分のみがテスト対象になっている
    • ビジネスロジック(ドメインモデル)や複雑な処理を単体テストすべき
  • 最小限の保守コストで最大限の価値を生み出すようになっている
    • 価値のあるテストケースを認識・作成する

単体テストの定義

単体テストの定義は以下の通り。

  • 単体と呼ばれる少量のコードを検証する
  • 実行時間が短い
  • 隔離された状態で実行される

隔離された状態については、二つの解釈がある。
一つは「古典学派(デトロイト学派)」、もう一つは「ロンドン学派(モック主義)」。
筆者は古典学派であった。そのため、ロンドン学派に対しての古典学派の良い箇所の説明をする。

ロンドン学派

隔離された状態を、テスト対象システム(System Under Test)から協力者オブジェクト(Collaborator)を隔離した状態と考える。
つまり、依存を全てテストダブル(モックやスタブ)に置き換えるという考え方。

メリットは以下の通り。

  • より細かな粒度で検証ができる(一つのテストは一つのクラスをテストする)
  • 依存関係が複雑になっていても簡単にテストできる
  • テストが失敗した際、どの機能に問題があったのかを正確に見つけられるようになる

古典学派

各テストケースがお互いに影響を与えることなく、個別に実行できる状態を隔離された状態と考える。
つまり、複数のテストケースを同時に実行することを可能にすべきという考え方。
そのため、DBやファイルなどのプロセス外依存が存在する場合は、テストケース間で共有させるか、テストダブルで置き換える。
そうすることで、テスト実行時間も短くなる。

ロンドン学派のメリットに対して、筆者は以下のように考えている。

単体テストは、一単位のコードではなく、一単位の振る舞いを検証すべきである。
無理に一クラスにつき一つのテストを作成すると、テストが詳細になりすぎて、わかりづらくなる。
(後述するが、基本的にテストは全てブラックボックステストにすべき)

また、古典学派の場合は、依存関係が複雑になるとテストしづらくなるが、そもそも依存関係が複雑なコードは設計が誤っている。
さらに、テストが失敗した際の問題発見も、コード変更のたびにテストを実行していれば、最後の変更が原因とすぐに判断できる。

単体テストの定義を古典学派的に解釈すると以下のようになる。

  • 一単位の振る舞いを検証すること
  • 実行時間が短い
  • 他のテストケースから隔離された状態で実行されること

テストの構造

テストの基本構造はAAAパターン。
(Given-When-Thenパターンもあるが構造的には同じ)

  • 準備(Arrange)フェーズ
    • 肥大化しがちなので、ファクトリメソッドを作成するなど工夫する
      • オブジェクトマザー(Object Mother)やテストデータビルダー(Test Data Builder)が有名
      • これらのメソッドは最初は各テストクラスの中に配置し、重複が目立ってきた段階でヘルパークラスなどに移すと良い
  • 実行(Act)フェーズ
    • 一行のコードにすべき
      • 一つの振る舞いに対して、複数のコードを実行しなければならないような状態は危険。
        仮にユーザが一つのコードを実行し忘れたら、データ不整合が起きる可能性がある。
        一つのオペレーションに対して一つのコードで実行できるようにすべき(カプセル化)。
  • 確認(Assert)フェーズ
    • 肥大化しているのであれば、プロダクションコードでの抽象化が上手くいっていない可能性が高い
    • 例えば、テスト対象システムが戻り値として返すオブジェクトの全てのフィールドの値を確認するのではなく、そのオブジェクトが同等であることを確認する手段を用意する

単体テストでは、同じフェーズを複数繰り返すべきではない。
つまり、「準備 -> 実行 -> 確認 -> 実行 -> 確認」のようなテストを作成すべきではないということ。
単体テストは一単位の振る舞いだけしか検証しない。
テストケースは簡潔で、実行時間が短く、簡単に理解できるようにすべきであり、同じフェーズを繰り返している場合は、複数のケースに分割する。
ただし、統合テストでは、実行時間を短くしたり、効率的に実行するために、同じフェーズを複数繰り返すことは許容する。

また、以下のように各フェーズを明確に区別すべき。

  • 空白行で区別できるのであれば、空白行で区別する
  • 空白行で区別できなければ、各フェーズの先頭にコメントをつける

テストフィクスチャ

テストフィクスチャはテストの最初に作成するのではなく、テストケースごとに作成すべき。
そうすることで、一つのテストケースに関する修正が、他のテストケースに影響を与えなくなる。

例えば、プライベートなファクトリメソッドを導入することで、テストケースごとに作成できる。

テストメソッド名

名づけの指針。

  • 厳格な命名規則に縛られないようにする
  • 問題領域のことに精通している非開発者に対してどのような検証をするのかが伝わるような名前をつける
  • (英語の場合は)アンダースコアを使って単語を区切るようにする

重要なことは、非開発者に対してどのような検証をするのかが伝わるような名前をつけるということである。
例えば、テストメソッド名にテスト対象メソッド名をつけるのはアンチパターンである。
これだと何をテストするのか、非開発者にはわからない。
どのような条件の時に、どのような結果になるのかをテストメソッド名にいれるべきである。

また、テストメソッド名に「shoud be(べきである)」を使うのもアンチパターンである。
単体テストとは、一単位の振る舞いについての一つの不可分な事実(シナリオ)を伝えるものであるので、テストメソッド名に希望や願望を含めるべきではない。
「shoud be(べきである)」ではなく、「is(である)」を使うべきである。

さらに細かいが、テストメソッド名を英語でつける場合は、英語の文法として適切な表現にすべき。
そうすることでテストコードが読みやすくなる。
特に「a(単数系」や「s(複数系)」は正しくつけるべきである。
ただし、なくても伝わる単語は冗長なので削るべきである。

先ほど、非開発者に検証内容が伝わらないので、テストメソッド名にテスト対象メソッド名をつけるのはアンチパターンであると述べたが、他にもテスト対象メソッド名を含めるべきでない理由がある。
テストはアプリケーションの振る舞いをテストしているのであって、コードをテストしているのではない。
テスト対象のメソッド名を含めると、対象のメソッド名を変更したときに、振る舞い自体は変わっていないのにも関わらず、テストメソッド名も変える必要が出てしまう。
ただし、ユーティリティ系のコードには、ビジネスロジックは含まれていないので、メソッド名を含めても良い。

また、同様の理由でテスト対象のシステム名も対象システム名ではなく、sut(System Under Test)と名づける。
なお、単体テストでは一つの振る舞いをテストするので、複数のクラスにまたがったテストをすることもあるが、sutは振る舞いの入り口となるクラスのことを指す。
こうすることで、テスト対象システムとその依存とを明確に区別することもできるので、テストが見やすくなる。

パラメータ化テスト

検証する振る舞いが非常に複雑な場合はパラメータ化テストが便利。
パラメータ化テストはテストコードを劇的に減らせる一方で、テストメソッドが何の事実を表現しているかわかりづらくなる。
そのため、正常系のパラメータ化テストと異常系のパラメータ化テストをわけるべき。

良い単体テストを構成する要素

良い単体テスト

良い単体テストを構成する四つの柱がある

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

退行に対する保護

退行とはバグのこと。
何らかの変更を加えたあとに既存の機能が意図したように動かなくなることを指す。

コードは資産ではなく、負債。
コードが増えるほど、潜在的なバグが増える。
そのため、持続的にプロジェクトが成長するためには、退行から保護する仕組みが必要。

退行に対する保護がテストにどのくらい備わっているかを把握するには次のことに目を向ける。

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

これらが高ければ、退行に対する保護がテストに備わっていると判断できる。
取るに足らないコード(例えばGetter, Setterなど)をいくらテストしても退行に対する保護は備わらない。

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

リファクタリングへの耐性とは、テストが失敗することなく、プロダクションコードのリファクタリングが行えること。
別の言い方をすると、偽陽性(プロダクションコードは正しいのにテストが失敗すること)の発生が少ないこと。

偽陽性の発生が多いと、テストコードへの信頼性が損なわれる。
そうなると、テストの失敗を無視するようになり、結果として問題のあるコードを本番環境にデプロイしてしまうことに繋がる。
また、リファクタリングによって頻繁に偽陽性が発生するとリファクタリングが敬遠されるようになる。

偽陽性はテストコードがプロダクションコードと密接に結びついていると発生しやすくなる。
偽陽性を持ち込まないようにする唯一の方法は、テストで検証する対象を最終的な結果(できれば非開発者にとっても意味があるもの)にし、実装の詳細はならないようにすることである。
そのため、ロンドン学派的に一単位のコードに対するテストではなく、古典学派的に一単位の振る舞いを検証すべき。
また、ホワイトボックステストではなく、ブラックボックステストを実施すべき。

「リファクタリングへの耐性」は「退行に対する保護」より軽視されがち。
プロジェクトの初期は「退行に対する保護」の方が重要であり、リファクタリングの機会は少ないからである。
ただし、プロジェクトが成長するにつれてリファクタリングの必要性が増し、「リファクタリングへの耐性」が重要になってくる。

迅速なフィードバック

フィードバックは早いほど(初期段階でバグが見つかるほど)、修正にかかるコストが少なくなる。
そのために、テストを速やかに行えるようになることが大切。
テストに時間がかかると、テストを実行する回数が減ってしまい、開発が間違った方向に進んでしまっても気づくまでに時間がかかる。

保守のしやすさ

保守のしやすさがどのくらいテストに備わっているかは次の二つの視点で評価できる

  • テストケースを理解することがどのくらい難しいのか
    • テストケースの作成に手を抜いてはいけない
  • テストを行うことがどのくらい難しいのか
    • DBや外部サービスなどのプロセス外依存が多いとテストを行うのが難しい

理想的なテスト

「退行に対する保護」と「リファクタリングへの耐性」と「迅速なフィードバック」は互いに排反している。

例えば、E2E(End-to-End)テストが良い例。
E2Eテストはエンドユーザの視点をもって行われるテストであり、外部サービスなども本番同様に利用することが多い。
つまり、もっとも多くのプロダクションコードが実行されるテストであるので「退行に対する保護」が高い。
また、エンドユーザの視点をもって行うテストであるので、偽陽性が低く、「リファクタリングへの耐性」も高い。
ただし、テストの実行に時間がかかるため、「迅速なフィードバック」は低い。

このように、全てを最大限に満たすテストを作成することはできない。
また、テストケースの価値は四つの柱の掛け算で評価できる。
そのため、四つの柱がどれかひとつでも「0」の場合は、そのテストケースの価値は「0」になる。

テストを作成するときは現実的に、どの柱を優先し、どの柱を犠牲にするか(「0」にはしない)を決断して作成する。
ただし、「リファクタリングへの耐性」は「0」か「1」かしかないため、「リファクタリングへの耐性」を最大限にしつつ、「退行に対する保護」と「迅速なフィードバック」のどちらを優先するのかというバランスをとることになる。
また、「保守のしやすさ」にも注意を払う。

例えば、E2Eテストは「退行に対する保護」を優先し、単体テストは「迅速なフィードバック」を優先する。
結合テストはその中間となる。

テストピラミッド

「テストピラミッド」という有名な概念がある。
これは図を用いずに説明することが難しいので、書籍と近い説明をしているサイトを紹介する。

ざっくり解説すると、ピラミッドの形で、上からE2Eテスト、統合テスト、単体テストがある。
ピラミッドの横幅はテストケースの数を表し、ピラミッドの下の層(単体テスト)ほどテストケースが多い。
ピラミッドの高さはエンドユーザの観点の近さを表し、ピラミッドの上の層(E2Eテスト)ほどユーザ体験に近いことを表す。

前述したように、単体テストと結合テストとE2Eテストはそれぞれ、四つの柱のどれを重要視するのかが異なるので、それぞれ大切なテストである。
ではなぜ、E2Eテストのテストケースを最も少なくするのか。
それは、E2Eテストが迅速なフィードバックの度合いが極端に低く、保守もしづらいためである。
テストケースの価値は四つの柱の掛け算であり、E2Eテストの価値は他と比べると低い。
そのため、E2Eテストは単体テストや結合テストで検証できない重要な機能のみを対象にすべきである。

モックとスタブ

テストダブル(test double)は大きくモックとスタブに分けられる。

  • モック
    • テスト対象システムから外部に向かうコミュニケーション(出力)を模倣・検証する
      • メールの送信など
    • スパイもモックの一種
      • スパイとは開発者の手によって実装される手書きのモックのこと
    • 模倣だけでなく検証も行う
      • メール送信などの外部とのコミュニケーションは、システムが生み出す最終的な結果であり、検証する必要がある
    • コマンド・クエリ分離の原則でいうコマンド(副作用をもたらし、いかなる値も返さない)
    • メール送信のような外部とのコミュニケーションのみにモックを利用する
      • それ以外にモックを利用すると実装の詳細と密接につながることになり、テストが壊れやすくなる
  • スタブ
    • テスト対象システム内部に向かって行われるコミュニケーション(入力)を模倣する
      • データの取得など
    • ダミーやフェイクもスタブの一種
    • 模倣だけしか行わない
      • 「リファクタリングへの耐性」でも述べたとおり、テストで検証する対象を最終的な結果にする必要がある。
        スタブが提供しているものはシステムが最終的な結果を生み出すための一過程にすぎないため、検証はしない。
    • コマンド・クエリ分離の原則でいうクエリ(いかなる副作用をもたらさず、何らかの値を返す)

観察可能な振る舞いと実装の詳細

すべてのプロダクション・コードは次の二つの観点で分類できる

  • 公開されたAPIなのか、それとも、プライベートなAPIなのか
  • 観察可能な振る舞いなのか、それとも、実装の詳細なのか
    • 観察可能な振る舞いはさらに以下の二つに分類できる
      • クライアントが目標を達成するために使う公開された操作
      • クライアントが目標を達成するために使う公開された状態

理想は、公開されたAPIが全て観察可能な振る舞いと一致し、実装の詳細が全てプライベートなAPIとなっていること。
これが満たされていない、つまり、実装の詳細が公開されている(漏洩している)と、クライアントが意図しない操作をし、データ不整合が起きる可能性がある。
これを防ぐためにカプセル化を行う必要がある。
カプセル化とは、間違ったことをする選択肢をコードベースに提供させないことであり、これによって、ソフトウェア開発の持続的な開発を目指す。
また、カプセル化と似たような原則に「尋ねるな、命じよ(Tell, Don’t Ask)」というものがある。
この原則は要するに、「実装の詳細は隠せ」「データを操作させるのにメソッドを経由させろ」ということ。

ヘキサゴナルアーキテクチャ

先ほどのモックとスタブの説明時に、モックは検証にも用いるが、スタブは検証に用いるべきでないという話をした。
スタブは実装の詳細を置き換えるだけであり、スタブの検証をしても観察可能な振る舞いの検証にならないからである。
実装の詳細をテストするとリファクタリングへの耐性が低くなり、テストの価値が低くなってしまう。

モックは検証に用いる。
ただし、モックを使う場面は限定すべきである。
具体的には、実装の詳細を置き換えてはいけない。

これについてもう少し説明するために、ヘキサゴナルアーキテクチャの説明をする。
これも図がないと説明しづらいので書籍に近い説明をしているサイトを紹介する。

ざっくり解説すると、一般的にアプリケーションはドメイン層とアプリケーションサービス層で構成されるが、
ドメイン層を内側に、アプリケーションサービス層を外側に配置するようにしたアーキテクチャがヘキサゴナルアーキテクチャである。
外部とのやりとりは全てアプリケーションサービス層が担い、ドメイン層にはビジネスロジックのみが含まれる。

ヘキサゴナルアーキテクチャの特徴は以下の通り。

  • ドメイン層とアプリケーションサービス層との関心の分離
    • ビジネスロジックをドメイン層に、外部とのやりとりをアプリケーションサービス層に完全に分離する
  • アプリケーション内でのコミュニケーション
    • 依存の流れがアプリケーションサービス層からドメイン層への一方向となる
    • ドメイン層のクラスはドメイン層のクラスだけにしか依存しない
  • 外部アプリケーションとのコミュニケーション
    • 外部アプリケーションとのコミュニケーションはアプリケーションサービス層にある共通のインターフェースを介して行われる
    • 全ての外部アプリケーションはドメイン層に直接アクセスできない

さて、正しいモックの使い方の話に戻る。
結論から述べると、モックは外部アプリケーションとのコミュニケーションのみに用いるべきである。
外部アプリケーションとのインターフェースは常に同じ仕様に従うことが期待されているため、リファクタリングしても仕様が変わることはない。
そのため、テストが壊れやすくなることはない。
また、外部アプリケーションとのコミュニケーションはクライアントが実現したいことと直接的に関係しているため、テストすべき項目である。
(書籍では外部アプリケーションのことをプロセス外依存とも呼んでいる)

ただし、データベースのようなテスト対象のアプリケーションからしかアクセスされないプロセス外依存はモックすべきでない。
なぜなら、このようなプロセス外依存はインターフェースが常に同じ仕様に従うことが期待されていないからである。
これらは観察可能な振る舞いではなく、実装の詳細であるので、テストすべきではない。

単体テストの三つの手法

単体テストには三つの手法がある

  • 出力値ベーステスト
    • 戻り値を確認するテスト
    • 純粋関数のみに適用可能
      • 詳細は後述するが、副作用などがない関数のこと
  • 状態ベーステスト
    • 状態を確認するテスト
    • テスト対象システムの状態、協力者オブジェクトの除隊、DBやファイルなどのプロセス外依存の状態などを検証する
  • コミュニケーションベーステスト
    • オブジェクト間のやりとりを確認するテスト
    • モックを用いてテスト対象システムとその協力者オブジェクトとの間で行われるコミュニケーションを検証する

良い単体テストを構成する四つの柱のうち「退行に対する保護」と「迅速なフィードバック」はどれも変わらない。
過度にモックを使うと実行されるプロダクションコードが少なくなり、退行に対する保護が低くなるが、
これは、コミュニケーションベーステスト自体の問題ではなく、過度にモックを使うことの問題である。

「リファクタリングへの耐性」は出力値ベーステストが一番高く、コミュニケーションベーステストが一番低い。
それは、出力値ベーステストが実装の詳細ではなく振る舞いのみに着目したテストだからである。
また、「保守のしやすさ」も出力値ベーステストが一番高く、コミュニケーションベーステストが一番低い。
それは、出力値ベーステストは比較的にコード量が少なくなるからである。
状態ベーステストは状態を検証するコードが多くなりがちであり、コミュニケーションベーステストはテストダブルを用意する必要があり、テストコードの量が増えやすい。

関数型アーキテクチャについて

単体テストの三つの手法のうち出力値ベーステストを実施すべきである。
ただし、出力値ベーステストは純粋関数のときにしか適用できない。

純粋関数とは、隠れた入力や出力がない関数、つまり、全ての入出力をメソッドシグネチャで表現できる関数のこと。
数学的な意味での関数の定義に従っているので数学的関数とも呼ばれる。
また、純粋関数は参照透過性(入力から出力が一意に決定され、副作用もない)をもつ。当然だが冪等性ももつ。

隠れた入出力には以下のものがある。

  • 副作用(隠れた出力)
    • オブジェクトの状態を変更する
    • DBのデータを更新する
  • 例外のスロー(隠れた出力)
    • スローされた例外は呼び出し元のどこかでキャッチされる
  • 内部もしくは外部の状態への参照(隠れた入力)
    • DateTiem.Nowのような静的プロパティを介してその時点の日時を取得する
    • DBからデータを取得する

このような隠れた入出力がない関数を用いたプログラミングを関数型プログラミングと呼ぶ。

しかし、副作用のないアプリケーションは現実的に使い物にならない。
そのため、関数型アーキテクチャではビジネスロジックを扱うコードと副作用を起こすコードを明確に分離する。

この考え方はヘキサゴナルアーキテクチャに似ている。
というより、関数型アーキテクチャはヘキサゴナルアーキテクチャの一種であり、より強力な制限を課したアーキテクチャである。
具体的には、ヘキサゴナルアーキテクチャはドメイン層内に限定された副作用は許容しているが、関数型アーキテクチャは全ての副作用をビジネスロジックから分離しなければならない。

関数型アーキテクチャでは、副作用をビジネスオペレーションの最初や最後に持っていくことで、ビジネスロジックと副作用を分離する。
具体的なやり方を記載すると長くなるため、書籍を読んだり、関数型アーキテクチャを調べてほしい。
個人的な感想を述べると、関数型アーキテクチャを採用するには色々なハードルがあると感じた。
ひとまず、コマンド・クエリ分離の原則を意識して実装するぐらいのことから始めるのがお手軽そうである。

プロダクションコードの種類の識別

ヘキサゴナルアーキテクチャや関数型アーキテクチャで見たように、プロダクションコードの設計が悪ければテストが難しくなる。
具体的にはコード同士が密結合な設計の場合、テストが難しくなる。
ただし、テストがしやすい疎結合なコードが全て良い設計であるわけではないので注意。

ここでプロダクションコードの種類について見ていく。
プロダクションコードは二つの視点で分類できる。

  • コードの煩雑さ、もしくは、ドメインにおける重要性
    • コードの複雑さは分岐の数で計測できる
    • 煩雑なコードやドメインにおける重症性が高いコードは単体テストを行う価値が高い
  • 協力者オブジェクトの数
    • 協力者オブジェクトは可変やプロセス外依存などのこと
    • 協力者オブジェクトが多いほど、テストの負担が大きくなる

これらを踏まえてプロダクションコードは四つに分類できる

  • ドメインモデル/アルゴリズム
    • 「コードの煩雑さ、もしくは、ドメインにおける重要性」が高く、「協力者オブジェクトの数」が低い
    • これに対する単体テストは非常に価値があるのにも関わらず、保守コストは低い
  • 取るに足らないコード
    • 「コードの煩雑さ、もしくは、ドメインにおける重要性」と「協力者オブジェクトの数」の両方が低い
    • テストしても労力に見合った価値が得られないので、テストすべきでない(テストコードは負債)
  • コントローラー
    • 「コードの煩雑さ、もしくは、ドメインにおける重要性」が低く、「協力者オブジェクトの数」が高い
    • 統合テストでテストすべき
  • 過度に複雑なコード
    • 「コードの煩雑さ、もしくは、ドメインにおける重要性」と「協力者オブジェクトの数」の両方が高い
    • いわゆる太った(fat)コントローラー。テストすべきだがテストが難しい。

リファクタリング

原則、過度に複雑なコードは「ドメインモデル/アルゴリズム」と「コントローラー」に分離すべき。
そのための方法として、質素なオブジェクト(Humble Object)という設計パターンがある。

過度に複雑になるのは、テスト対象のコードがフレームワークとなる依存に直接結びつく場合である。
このような場合、過度に複雑なコードからテストを行いやすい部分を抽出する。
その抽出された部分を包み込む質素なクラスを作成し、その作成した質素なクラスに対してテストが難しい依存を結びつける。
こうすることで、テストを行いやすい部分のみをテストすることができる。
このとき、質素なクラスにはビジネスロジック含ませないことで、テスト不要にすることが大切。

このようにすることでテストがしやすくなるだけではなく、
かの有名な原則である単一責任の原則(Single Responsibilty Principal)を遵守することにも繋がり、良い設計となる。
良い設計はプロジェクトの持続的な成長に欠かせない要素である。

繰り返し述べるが、ビジネスロジックに関するコード(ドメインモデル/アルゴリズム)と連携の指揮に関するコード(コントローラー)とを分離することが大切。

書籍では具体的なサンプルコードを用いてリファクタリングの実例を紹介していたが、ここでは省略する。
詳しくは、書籍を確認してほしい。自分も少し間を空けて改めて再読したいと思っている。

統合テスト

統合テストとは

以前、単体テストの定義を以下のように説明した。

  • 一単位の振る舞いを検証すること
  • 実行時間が短い
  • 他のテストケースから隔離された状態で実行されること

これを一つでも満たさないものが統合テストである。
また、これも以前に説明したが、統合テストは単体テストよりも保守のしやすさは低くなるが、退行に対する保護は高くなる。

一般的に単体テストはビジネスシナリオにおける異常ケースをできるだけ多く検証するのに対し、
統合テストは一件のハッピーパス(正常系)と単体テストでは検証できないすべての異常ケースを検証することが適切。
つまり、できるだけ単体テストで担保して、必要なテストのみを統合テストで行うという考え方。
ハッピーパスを検証するのは、他のシステムと統合した状態で意図したように機能するのかを検証することは重要だからである。
ゆえに、一件で全ての外部システムとのやりとりが検証できることがベストだが、できない場合は複数のハッピーパスを検証する。

どのようなプロセス外依存をモックに置き換えるべきか

これも繰り返しになるが、外部システムに対する依存は以下の二つに分けられる。

  • 管理下にある依存
    • データベースなど
    • モックを使わない
  • 管理下にない依存
    • メールサービスなど
    • モックを使う

管理下にない依存に対してのみモックを使うべきである

インターフェースを使った依存の抽象化

プロセス外依存に対してインターフェースを導入することが多い。それはなぜか。

多くの開発者はプロセス外依存に対して、実装クラスが一つしかないのにインターフェースを導入する傾向がある。
彼らはインタフェースを導入する理由として以下の二つをよく挙げる。

  • プロセス外依存を抽象化できるようになり、その結果、疎結合を実現できるようになるから
  • 既存のコードを変更することなく、新しい機能を追加できるようになり、解放/閉鎖原則(Open-Closed Principle)を遵守しやすくなるから

しかし、実装クラスが一つしかないのであれば、これらは間違いである。
まず、実装が一つしかない場合は、それは抽象ではなく、疎結合にもならない。
そして、新しい機能を追加できるようにインタフェースを導入することは、YAGNI(You Aren’t Goona Need It)原則から外れることになる。
YAGNI原則は以下の二つの点から遵守することが求められる。

  • 機会に対するコスト
    • 将来のための開発は現時点で必要な機能の開発から時間を奪う
    • また、将来必要になったとき、現時点で想定していたよりも多くのことを望まれることが非常に多い
  • コード量は少ない方が良い
    • コードは負債

ではなぜ、プロセス外依存にインターフェースを使うのか。
それは、モックを作れるようにするためである。
そのため、プロセス外依存に対してモックにする必要がない限り、その依存のインターフェースをつくるべきではない。

また、前述したようにテストにおいてモックを使うのは管理下にない依存に対してのみである。
つまり、管理下にない依存に対してのみインターフェースを用意するという結論になる。
勿論、インターフェースに対して複数の実装クラスができるような場合はモックの有無に関わらずインタフェースを作って良い。

統合テストのベストプラクティス

統合テストを最大限に活用するために基本となる指針は以下のとおり。

  • ドメインモデルの境界を明確にする
    • ドメインモデルは単体テスト、コントローラーは統合テストで検証するため、ドメインモデルの境界が明確になっていれば単体テストと統合テストの区別がしやすい
  • アプリケーションを構成する層を減らす
    • 基本的に、ドメイン層、アプリケーションサービス層、インフラ層だけで十分。
      層が増えると、各層にビジネスロジックが散らばる。
      ビジネスロジックが散らばると、コードの見通しが悪くなり、テストも実施しづらくなる。
  • 循環依存を取り除く
    • 循環依存があるとコードの見通しが悪くなり、テストにおいてもモックを使って依存関係を分解する必要が出てくる

前述したAAAパターンの説明時に、
「統合テストでは、実行時間を短くしたり、効率的に実行するために、同じフェーズを複数繰り返すことは許容する。」と述べた。
ただし、あくまで許容するだけであり、原則は同じフェースを複数繰り返すべきではない。

例えば、ユーザを作成するケースとユーザを削除するケースがあるとする。
ユーザを削除するためにはユーザを作成しておく必要があるため、この二つのケースを一つのケースで実施することは効率が良いことのように思う。
しかし、基本的にはそれぞれ別々にテストすべき。
それは、各テストケースが単一の振る舞いのみを対象としていれば、何を検証しようとしているのかを理解しやすくなり、必要に応じてテテストケースを変更しやすくなるからである。

ログ出力

ログ出力をテストすべきか。
それは、テスト対象とするログ出力がアプリケーションの観察可能な振る舞いの一部なのか、それとも、実装の詳細なのかによって変わる。

書籍では以下のように分類している

  • サポートログ
    • システムのサポートスタッフやシステム管理者によって見られることを意図した特定のイベントを記録するログ
    • テストすべき
  • 診断ログ
    • 開発者がアプリケーション内で何が起こっているのかを把握できるようにするためのログ
    • テストすべきでない

サポートログはビジネス要求による機能であるため、コードベース上にその機能がビジネス要求を反映したものであることを明確に伝える必要がある。
そこで、全てのサポートログが定義されたDomainLoggerクラスを作成し、このDomainLoggerクラスからサポートログを出力するようにすべき。
書籍では具体的な実装例が紹介されているので、気になる方は確認してほしい。

また、ログ出力もプロセス外依存であるため、ドメイン層に含めるべきではない。
このための方法も書籍では紹介されているが、ここでは割愛する。

モックのベストプラクティス

  • モックを適用するのは管理下にない依存のみにする
  • そのような依存とのコミュニケーションを検証する際、モックの置き換え対象とするのはシステムの境界に位置するものにする
    • コントローラーからその依存に向かう流れの中で最後のコンポーネントとなるものをモックの置き換え対象にする
    • テストを実施する際に経由されるクラスの数が増え、より強力な退行に対する保護を得られるようになる
  • モックの利用は統合テストに限定する(単体テストでは使わない)
  • モックに対して行われた呼び出しの回数を常に確認する
    • 想定する呼び出しが行われていることと、想定しない呼び出しが行われていないことを確認する
  • モックの対象になる型は自分のプロジェクトが所有する型のみにする
    • サードパーティ製のライブラリが提供するものを直接モックにするのではなく、そのライブラリに対するアダプタを独自に作成し、そのアダプタに対してモックを作成する
    • こうする理由は以下の通り
      • サードパーティ製のライブラリが実際にどのように機能しているのかを深く知ることは滅多にできないが、これらをモックに置き換えると、モックの振る舞いとサードパーティ製のライブラリの実際の振る舞いとが一致することを保証しなくてはならなくなり、リスクを伴うから
      • アダプタを挟むことで、サードパーティ製のライブラリに含まれるビジネス的に本質でない技術的な詳細を隠蔽できるようになり、自身のアプリケーションの用語を用いてライブラリとの関係を定義できるうようになるから
    • さらに、これにより以下のメリットがある
      • サードパーティ製のライブラリが持つ複雑性を抽象化できるようになる
      • サードパーティ製のライブラリが提供する機能の中で、必要な機能のみを公開できる
      • 自身のプロジェクトで使っているドメインの用語を使える

データベースをテストするのに必要な事前準備

データベースのような管理下にある依存はモックにせずにそのまま使用してテストすべき。
このとき、テストが再現できるように、スキーマを通常のコードと同じように管理する必要がある。

つまり、スキーマをGitなどのソースコード管理システムで管理する。
また、参照データ(アプリケーションが適切に機能するために事前に用意しなければならないデータ)を登録するINSERT文もスキーマと一緒にソースコード管理する。

テストデータのライフサイクル

データベースを使ってテストを行う場合、テストデータが他のテストケースに影響を与えないよう、一つずつ実行していくのが現実的。
では、テストケースを実行した後に残ったテストデータの後始末はどうすべきか。
選択肢としては四つ考えられる。

  • 各テストケースを実行する前にバックアップからデータベースを復元させる
    • テストに費やされる時間が長くなる
  • テストケースの実行後にデータの後始末をする
    • 例えば、テストを途中で中断した場合などに、データの後始末処理が実行されずに問題が起こる
  • 各テストケースを一つのデータベーストランザクション内で行い、コミットせずにロールバックする
    • AAAパターンの各フェーズで独立してトランザクションを行うべき
      • 本番同様に、テスト対象システム内で定義されたトランザクションが行われるべき
      • なお、書籍にはないが、PythonのDjangoはテスト対象システム内のトランザクションは使用しつつ、テストケース完了後にロールバックしてくれる仕組みになっているので、このような場合は問題ないと思われる
  • テストケースの実行前にデータの後始末をする
    • これが一番優れている

アンチパターン

最後にこれまでの説明の中で紹介しきれなかったアンチパターンを紹介する。

プライベートなメソッドに対する単体テスト

プライベートなメソッドは実装の詳細であり、単体テストすべきではない。
公開されたメソッド経由でテストすべきである。
仮に、プライベートなメソッドを直接テストしないと十分にテストできない場合は、次の二つの問題が考えられる。

  • デッドコードになっている
  • 抽象化が欠落している
    • 抽象化できる部分を別のクラスとして抽出すべき

プライベートな状態の公開

テストのためにプライベートな状態を公開してはならない

テストへのドメイン知識の漏洩

テストを作成する際、プロダクションコードに定義された特定のロジックやアルゴリズムをテストコードに持ってくるべきではない。
例えば、テストの実行結果を検証するときに、プロダクションコードの関数を利用して期待値を作成するようなことはNG。
そうではなく、プロダクションコードでやっている計算処理などを自分で実施して、それを期待値にする。

プロダクションコードへの汚染

テストでのみ必要とされるコードをプロダクションコードに加えてはならない。
例えば、テスト実行時は振る舞いを変えるような処理をプロダクションコードに加えることがよくあるが、これはNG。
そうすると、プロダクションコードの保守コストが増えてしまう。
また、テスト時にしか実行されない処理が誤って本番環境で実行されてしまうリスクもある。

解決方法としては例えば、インターフェースを用いて、プロダクションコード実行時とテストコード実行時で渡す具象クラスを変えることで振る舞いを変えるという方法がある。

具象クラスに対するテストダブル

これまでインターフェースに対してテストダブルを用いてきたが、具象クラスに対してもテストダブルを用いることはできる。
その場合、元のクラスの機能をテストダブルに一部残せるようになり、テストの際に元のクラスの機能をそのまま使えるようにすることができる。
ただし、このようなテストダブルの使い方が求められるということは、クラスが単一責任の原則(Simple Responsibilty Principle)を遵守していない証拠である。

単体テストにおける現在日時の扱い

環境コンテキストとして現在日時を扱うのはアンチパターンである。
環境コンテキストとは、静的(static)なメソッドや変数のことである。
こうするとテストで現在日時を扱うことが難しくなる。
そのため、現在日時を依存として明示的に注入するべきである。
そうすることで、テスト時に任意の日時を注入することができるようになる。

最後に

書籍を読み「気をつけねば」と強く感じた部分をまとめて終わりにしたいと思います。

  • テストの目的は、ソフトウェア開発プロジェクトの成長を持続可能なものにすること
  • 壊れやすいテストコードは価値がない
  • テストコードを含む全てのコードは負債である

以上です。長くなってしまい、申し訳ございません。
お付き合いありがとうございました。

The following two tabs change content below.

植木 宥登

最新記事 by 植木 宥登 (全て見る)

この記事をシェアする

  • Twitterでシェア
  • Facebookでシェア
  • LINEでシェア