読書メモ。
- 単体テストの考え方/使い方
- https://book.mynavi.jp/ec/products/detail/id=134252
質の高いテストを行い、ソフトウェアに価値をもたらそう!
単体(unit)テストの原則・実践とそのパターン ― プロジェクトの持続可能な成長を実現するための戦略について解説。
優れたテストを実践すれば、ソフトウェアの品質改善とプロジェクトの成長に役立ちます。逆に間違ったテストを行えば、コードを壊し、バグを増やし、時間とコストだけが増えていきます。生産性とソフトウェアの品質を高めるため、優れた"単体テスト"の方法を学ぶことは、多くの開発者とソフトウェア・プロジェクトのために必須といえるでしょう。
本書“単体テストの考え方/使い方”では、単体テストと統合テストの定義を明確にします。そして、どのようなテストに価値があるのかを学び、どのテストをリファクタリング、もしくは削除するのか、ということについて考え、そのことがプロジェクトの成長にどう繋がるのかを見ていきます。C#のコード例で解説しますが、どの言語にも適用できる内容です。
Manning Publishing: Unit Testing Principles, Practices, and Patterns の翻訳書。
目次 #
- 第1章:なぜ、単体テストを行うのか?
- 第2章:単体テストとは何か?
- 第3章:単体テストの構造的解析
- 第4章:良い単体テストを構成する4本の柱
- 第5章:モックの利用とテストの壊れやすさ
- 第6章:単体テストの3つの手法
- 第7章:単体テストの価値を高めるリファクタリング
- 第8章:なぜ、統合(integration)テストを行うのか?
- 第9章:モックのベスト・プラクティス
- 第10章:データベースに対するテスト
- 第11章:単体テストのアンチ・パターン
第1章:なぜ、単体テストを行うのか? #
単体テストの現状 #
この 20 年間、単体テストはソフトウェア業界に広く浸透し、単体テストを導入することは当然のことと思われるようになってきました。そして、単体テストに関する議論は「単体テストを書くべきか?」から「良い単体テストを書くとはどういう意味なのか?」に移ってきており、この部分が単体テストに関して大きな混乱を生じているのだと本書では考えています。
本書では、理想的な単体テストとはどのようなテストなのかについて正確で科学的な定義をし、この定義がいかに実用的で現実の世界を反映しているのかを提示していきます。本書が望んでいることは、十分な数のテスト・ケースを用意したのにもかかわらず、なぜ、プロジェクトが期待した結果を得られないのか、そして、そのことを修正するためには何をすればよいのか、ということを理解する手助けとなることです。
まとめ #
- コードベースは成長と共に劣化する傾向がある。コードベースに変更を加えるたびにエントロピー(もしくは、無秩序の量)が増加するため、コードの整理やリファクタリングなどの適切な処理を常に施していないと、そのシステムはすぐに複雑になり、秩序がなくなってしまう。このような状況に陥ることを防ぐのがテストであり、テストを用意することで、変更によって発生する多くの退行(regression)を検出できるようになる。つまり、テストはソフトウェア開発におけるセーフティ・ネットとして見ることができる。
- 単体テストを作成することは重要だが、それと同じくらい重要なのが良い単体テストを作成することである。もし、単体テストの質が悪かったり、単体テストがまったく作成されていなかったりした場合、そのプロジェクトは最終的には停滞してしまい、変更のたびに多くの退行を生み出すようになる。
- 単体テストを行うことの目標は、ソフトウェア開発に関するプロジェクトの成長を持続可能なものにする、ということである。質の良いテスト・ケースで構成されたテスト・スイートを用意することでプロジェクトが停滞してしまうことを回避し、適切な開発スピードを長いあいだ維持できるようになる。このようになる理由は、このようなテスト・スイートがあれば、プロダクション・コードに変更を加えても退行が発生しないことに自身を持てるようになるからである。そして、このことはリファクタリングや新たな機能の追加を簡単に行えるようになることにも繋がる。
- すべてのテスト・ケースは平等に作られているわけではない。それぞれのテスト・ケースにはコストがかかる要素と価値をもたらす要素があり、テスト・ケースが負債となるのか、それとも、利益を生み出すのかを注意して見なければならない。そして、テスト・スイートにはコストよりも価値のほうが高いテスト・ケースだけを残し、その他のテスト・ケースを取り除くようにしなくてはならない。このことから、テスト・コードもプロダクション・コードと同じ用に、資産ではなく負債として見るべきである。
- 単体テストのしやすさは設計の質を評価するのに使えはするが、その評価は一方向にしか向いておらず、設計の欠点を見つけ出すことだけにしか本領を発揮しない。つまり、認識できるのは、単体テストが作成しづらいのであれば、設計の質が悪い、ということだけである。しかしながら、その逆は成立しない。つまり、単体テストを作成しやすいからと言って、設計の質が良いことを保証できない。
- 単体テストのしやすさと設計の質との関係と同じ用に、網羅率(coverage)はテスト・スイートの質が悪いことを評価できるが、良いことを評価できない。つまり、網羅率が低ければ、計測対象のテスト・スイートは十分なテストを行っていないことを示すことになるが、その逆に、網羅率が高かったとしても、テスト・スイートの質が良いと自動的に評価されるわけではない。
- 分岐網羅率(branch coverage)はテスト・スイートの網羅性についてコード網羅率(code coverage)より正確な結果を返すが、テスト・スイートの質が高いことを評価するものにはならない。こうなる理由は、分岐網羅率はテスト・ケースが何も確認していない場合であっても高い網羅率を出せるからであり、さらに、使われているライブラリの中身が計測対象から外れているからである。
- 網羅率の値に縛られることはソフトウェア開発において害となることである。システムの核となる部分に対して高い網羅率を出せることは良いことだが、そのことを強制することは開発の妨げとなる。
- 優れたテスト・スイートには次の特徴がある。
- テストすることが開発サイクルの中に組み込まれている:
- コードベースの特に重要な部分のみがテスト対象となっている
- 最小限の保守コストで最大限の価値を生み出すようになっている
- 単体テストを行うことの目標(プロジェクトの成長を持続可能にすること)に到達するためには次のことが行えなくてはならない:
- テスト・ケースの質の善し悪しを区別できること
- より価値をもたらすテスト・コードとなるようなリファクタリングを行えること
第2章:単体テストとは何か? #
単体テストにおける古典学派とロンドン学派の違い #
単体テストをどのように行うべきか、ということに対して2つの異なる学派は古典学派(デトロイト学派とも)とロンドン学派と呼ばれています。「古典学派」の由来、この学派が単体テストとテスト駆動開発の元来の取り組みを採用していることから来ています。一方、「ロンドン学派」という由来は、この学派がロンドンのプログラミング・コミュニティで生まれたことから来ています。
ロンドン学派では、もし、テスト対象となるクラスが他のクラスに依存しているのであれば、その依存をすべてテスト・ダブルに置き換えなくてはならない、という考えです。こうすることで、テスト対象の振る舞いは外部の影響から隔離されることになるので、そのクラスのことだけに専念できるようになります。
古典学派のスタイルで書かれた単体テスト
// 在庫が十分にある場合、購入は成功する
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
// 準備(Arrange)
var store = new Store();
store.AddInventory(Product.Shampoo, 10);
var customer = new Customer();
// 実行(Act)
bool success = customer.Purchase(store, Product.Shampoo, 5);
// 確認(Assert)
Assert.True(success);
Assert.Equal(5, store,GetInventory(Product.Shampoo));
}今回テスト対象システムとなるのが顧客を表す Customer クラスで、協力者オブジェクトとなるのが店を表す Store クラスになります。
古典学派のスタイルでは、協力者オブジェクトをテスト・ダブルに置き換えずに、そのまま Store クラスを使うようにしています。その結果、この単体テストは Customer クラスだけではなく Store クラスも自然と検証することになります。もし、Store クラス内の処理でバグがあった場合、Customer クラスが正しく動作していたとしても、この単体テストは失敗することになります。つまり、この単体テストでは、これら2つのクラスは隔離されていないことになるのです。
それでは、この単体テストをロンドン学派のスタイルで書き換えるとどうなるのかを見ていきましょう。
本書ではモックのフレームワークに Moq を使っています。
ロンドン学派のスタイルで書き換えた単体テスト
// 在庫が十分にある場合、購入は成功する
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
// 準備(Arrange)
var storeMock = new Mock<IStore>();
storeMock
.Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
.Returns(true); // モックの在庫確認が呼ばれた際に十分な在庫があることにする
var customer = new Customer();
// 実行(Act)
bool success = customer.Purchase(
storeMock.Object, Product.Shampoo, 5);
// 確認(Assert)
Assert.True(success);
storeMock.Verify(
x => x.RemoveInventory(Product.Shampoo, 5),
Times.Once); // このメソッドが一度呼ばれていることを確認
}古典学派でもモックなどのテスト・ダブルが使われることはありますが、ロンドン学派よりは少なくなります。通常、古典学派でテスト・ダブルが使われるのはテスト・ケース間で共有される状態を持つ依存に対してのみです。
ロンドン学派と古典学派は、隔離に対する考え方の違いが根底にあります。ロンドン学派、単体テストにおいて、テスト対象システムをその協力者オブジェクトから隔離しなくてはならない、と考えているのに対し、古典学派は、単体テストのテスト・ケースを別のテスト・ケースから隔離しなくてはならない、と考えています。ロンドン学派では、1つの単体が意味することは、1つのクラスのことと捉えています。この見解はオブジェクト志向プログラミングの考えから来ており、通常、開発者はクラスのことをすべてコードベースの基盤となる分解できない構成要素として見ています。古典学派では、1つの単体は1つのテスト・ケースであり、ストーリーだと見ています。
私個人は古典学派のスタイルのほうを好んでいます。その理由は、古典学派のほうが良質な単体テストを作成でき、単体テストの究極の目標である、プロジェクトの持続的な成長を促す、ということを達成するのに向いているからです。学派の違いで重要なのが、単体テストはプロダクション・コードのことをどのくらい把握しなくてはならないのか、ということです。一般的に、ロンドン学派のほうが古典学派よりも実装の詳細に深く結びつく傾向があります。そして、このことがモックの幅広い利用や一般的なロンドン学派のスタイルに私が賛同できない最大の理由になります。
まとめ #
- 本書における単体テストの定義は次の性質をすべて持つものである:
- 1単位の振る舞い(a unit of behavior)を検証すること
- 実行時間が短いこと
- 他のテスト・ケースから隔離された状態で実行されること
- 単体テストにおいて、もっとも議論されることは、隔離の意味をどのように捉えるのか、ということである。そして、この隔離に対する考え方の違いが古典学派(デトロイト学派)とロンドン学派(モック主義者)の2つの単体テストの学派を生み出した原因である。この隔離に対する考え方の違いは、単体とはなにか、そして、テスト対象システム(System Under Test: SUT)が必要とする依存をどのように扱うのか、ということに関する見解に影響を与えている:
- ロンドン学派では、テスト対象となる単体を他の単体から隔離すべきである、という考えを持っている。そして、ロンドン学派の考える「単体」とは、1単位のコード(a unit of code)、つまり、クラスのことと考えている。そのため、ロンドン学派の単体テストでは、不変依存を除くすべての依存がテスト・ダブルに置き換えられる。
- 古典学派では、クラスを隔離するのではなく、1単位の振る舞い(a unit of behavior)のことを指している。古典学派の単体テストでは、他のテスト・ケースの実行に影響を与えるであろう共有依存だけをテスト・ダブルに置き換えるようになっている。
- ロンドン学派の長所はより細やかな粒度で検証できることに加え、複雑に絡み合った依存関係を持つクラスのテストが簡単に行えるようになること、さらには、テストが失敗したときにその原因となるバグが潜んでいる箇所を見つけやすくなることがある。
- ロンドン学派の長所は魅力的なように思えるが、課題もいくつか抱えている。まず、単体テストにおいて、テスト対象の焦点をクラスに当てることは間違いである。焦点を当てなくてはならないのは1単位のコードではなく、1単位の振る舞いである。さらに、もし一部のコードを簡単にテストできないのであれば、それはコードの設計に問題があることを強く示唆している。しかも、この問題はテスト・ダブルを使っても解決できるものではなく、仮に、テスト・ダブルを使ってテストを行えるようにしたとしても、問題そのものはテストの際に隠れるようになっただけに過ぎない。また、ロンドン学派が単体テストの導入において利点として考えている、テストが失敗した際、どこにバグが潜んでいるのかを簡単に見つけられるようになる、ということは確かに有用な事実ではあるが、古典学派の単体テストであっても、プロダクション・コードを変更するたびに単体テストを実施するようにしていれば、どこに間違いが合ったのかをすぐに見つけられるからである(つまり、最後に修正をした部分がバグを持ち込んだ部分ということになる)。
- 統合(integration)テストとは、単体テストが持つべき3つの性質を1つでも欠いたテストのことである。一方、E2E(End-to-End)テストは統合テストの一種であり、エンド・ユーザの視点からシステムを検証するため、テスト対象のアプリケーションが使用するすべての(もしくは、ほぼすべての)プロセス外依存をそのまま使ってテストすることになる。
- 古典学派の聖典は Kent Beck 氏によって書かれた『Test-Driven Development: By Example』(訳書:『テスト駆動開発』オーム社、2017年)であり、ロンドン学派の聖典は Steve Freeman 氏と Nat Pryce 氏によって書かれた『Growing Object-Oriented Software, Guided by Tests』(訳書:『実践テスト駆動開発』翔泳社、2012年)である。この2冊は読んでおいたほうがよい書籍である。
第3章:単体テストの構造的解析 #
AAA パターンの利用 #
たとえば、次に示す Calculator クラスがあります。
public class Calculator
{
public double Sum(double first, double second)
{
return first + second;
}
}AAA パターンを用いてテスト・ケースを定義しています。
public class CalculatorTests
{
[Fact]
public void Sum_of_two_numbers()
{
// 準備(Arrange)
double first = 10;
double second = 20;
var calculator = new Calculator();
// 実行(Act)
double result = calculator.Sum(first, second);
// 確認(Assert)
Assert.Equal(30, result);
}
}AAA パターンで記述することで、テスト・スイートに含まれるすべてのテスト・ケースに対して統一された構造を持たせられるようになります。一貫した構造を持たせられるようになると、この構造に慣れてしまえば、どのようなテスト・ケースであっても読みやすさが向上し、何をしているのかを簡単に理解できるようになるため、テスト・スイート全体の保守コストを下げることに繋がります。
- 準備(Arrange)フェーズ:テスト・ケースの事前条件を満たすようにテスト対象システム(System Under Test: SUT)とその依存の状態を設定するフェーズ。
- 実行(Act)フェーズ:テスト対象システムのメソッドを呼び出すことで、振る舞いを実行させるフェーズ。
- 確認(Assert)フェーズ:実行結果が想定した結果であることを確認するフェーズ。
読者の中には、Given-When-Then パターンについて聞いたことがある人もいるかもしれません。Given は Arrange に、When は Act に、Then は Assert に対応していて、AAA パターンと同じ構造です。
もし、1つのテスト・ケースの中に複数の実行フェーズや複数の確認フェーズを保つ場合は、そのテスト・ケースの中で複数の振る舞いを検証していることになります。それは単体テストではなく、統合テストに属するものになります。単体テストとして扱うのであれば、そのような構造のテスト・ケースは避けなくてはなりません。
単体テストにおいて回避すべきこと:if 文の使用 #
単体テストのなかで if 文を用いることはアンチ・パターンとなります。本来、テスト・ケースは、それが単体テストなのか統合テストなのかにかかわらず、分岐のない単純な流れにしなくてはなりません。
テスト・ケースの中に if 文が含まれる場合、それは1つのテスト・ケースのなかであまりにも多くのことを検証している、ということを示唆するものとなります。その場合は複数のテスト・ケースに分割しましょう。
各フェーズのサイズはどのくらいが適切なのか? #
通常、準備(Arrange)フェーズは3つのフェーズのなかでもっとも大きくなります。
通常、実行(Act)フェーズは1行だけで足りるはずです。もし複数行になるのであれば、このことは、テスト対象システムで公開されている API がきちんと設計されていないことを示唆していることになります。
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
// 準備(Arrange)
var store = new Store();
store.AddInventory(Product.Shampoo, 10);
var customer = new Customer();
// 実行(Act)*2行になってしまった実行フェーズ
bool success = customer.Purchase(store, Product.Shampoo, 5);
store.RemoveInventory(Product.Shampoo, 5);
// 確認(Assert)
Assert.True(success);
Assert.Equal(5, store.GetInventory(Product.Shampoo));
}商品の購入という1単位の振る舞いを機能させるために、2つのメソッドを呼び出す必要があることが示唆されています。もし2つ目のメソッドの呼び出しが忘れられてしまうと、データの整合性が損なわれることになります。これはテスト自体の問題ではなく、API 設計の問題です。このようなことが起こるのを防ぐためには、カプセル化が行われるように設計しなくてはなりません。
確認(Assert)フェーズは、テスト・ケースでの結果を検証するのに必要な行数を割きましょう。
まとめ #
- 単体テストのすべてのテスト・ケースは準備(Arrange)、実行(Act)、確認(Assert)の3つのフェーズで構成される AAA パターン(もしくは、3A パターン)を適用して記述されるべきである。もし、テスト・ケースに同じフェーズが複数含まれる場合、そのテスト・ケースは複数の1単位の振る舞いを一度に検証しようとしていることを示唆している。そして、そのテスト・ケースが単体テストに属するものであるのなら、そのテスト・ケースを複数のテスト・ケースに分割し、1つのテスト・ケースで1単位の振る舞いを検証するように修正しなくてはならない。
- もし、実行フェーズに記述するコードが1行を越すのであれば、このことはテスト対象となるコードの API がきちんと設計されていないことを示唆している。なぜなら、テスト対象のコードを呼び出す側(クライアント)は1単位の振る舞いを適切に実行するために複数のメソッドを呼び出すことを常に覚えていることが求められるからである。このことは不変条件(invariant)の侵害と呼ばれる整合性の欠落を招くことになる。そして、この不変条件の侵害が起こらないように保護する設計がカプセル化である。
第4章:良い単体テストを構成する4本の柱 #
まとめ #
- 良い単体テストを構成する柱とは、次の4つの性質のことであり、これらの柱を使うことで、単体(unit)テスト、統合(integration)テスト、E2E(End-to-End)テストなどのすべての自動化されたテストを分析できるようになる:
- 退行(regression)に対する保護
- リファクタリングへの耐性
- 迅速なフィードバック
- 保守のしやすさ
- 退行に対する保護:とは、テストをすることで退行(もしくはバグ)の存在をいかに検出できるのかを示す性能である。テストによって実行されるコードが多くなるほど、この性質を備えたテスト・ケースはより多くのバグを見つけ出せるようになる。
- リファクタリングへの耐性:とは、いかに偽陽性(false positive)を生み出すこと無く、プロダクション・コードに対してリファクタリングを行えるかを示す性能である。
- 迅速なフィードバック:とは、テストの実行時間がどのくらい短くなるのかに影響する性質である。
- 保守のしやすさ:とは、テストコードの読みやすさ、テスト実施の容易性の評価である。
第5章:モックの利用とテストの壊れやすさ #
観察可能な振る舞い(observable behavior)と実装の詳細(implementation detail) #
テスト・ケースが偽陽性(false positive)を生み出してしまう(そして、その結果、リファクタリングへの耐性を失ってしまう)最大の原因はテスト・ケースが実装の詳細と結び付いてしまうことです。このようなことになることを回避する唯一の方法は、テスト・ケースがテスト対象の内部的なコード(実装の詳細)を可能な限り扱わないようにしつつ、検証する対象をテスト対象のコードが生み出す最終的な結果(観察可能な振る舞い)だけにすることです。言い換えると、検証の際に目を向けるのは何(What)でありどのように(How)ではない、ということです。
この User クラスと UserController クラスはきちんと設計された API になります。ユーザの名前を 50 文字以内に収めることはアプリケーション自体に課せられた制約に過ぎず、User クラスの NormalizeName メソッドは実装の詳細であり、プライベートにしなければなりません。
さらに言えば、このメソッドを直接テストできるようにすべきではないのです。そうではなく、検証すべきなのはこのクラスの観察可能な振る舞いの一部となるコードだけなのです。つまり、User クラスを検証するのであれば、Name プロパティの set アクセサがテスト対象となるのです。
public class User
{
private string _name;
public string Name
{
get => _name;
set => _name = NormalizeName(value);
}
private string NormalizeName(string name)
{
// 受け取った名前を 50 文字以内にする
}
}
public class UserController
{
public void RenameUser(int userId, string newName)
{
User user = GetUserFromDatabase(userId);
user.Name = newName;
SaveUserToDatabase(user);
}
}第6章:単体テストの3つの手法 #
単体テストには3つの手法があります。
- 出力値ベース・テスト(戻り値を確認するテスト)
- 状態ベース・テスト(状態を確認するテスト)
- コミュニケーション・ベース・テスト(オブジェクト間のやり取りを確認するテスト)
出力値ベース・テスト
テスト対象のコードに入力値を渡した後、そこから返される結果を検証する、というものです。
public class PriceEngine
{
public decimal CalculateDiscount(params Product[] products)
{
decimal discount = products.Length * 0.01m;
return Math.Min(discount, 0.2m);
}
}
// 商品が2個ある場合の割引率
[Fact]
public void Discount_of_two_products()
{
var product1 = new Product("Hand wash");
var product2 = new Product("Shampoo");
var sut = new PriceEngine();
decimal discount = sut.CalculateDiscount(product1, product2);
Assert.Equal(0.02m, discount);
}PriceEngine クラスの CalculateDiscount メソッドでは、受け取った商品の数に対して 1% をかけることで割引率を算出し、もしその結果が 20% を超える場合は割引率を 20% にして返すようになっています。
このとき、内部で保持するコレクションに商品を追加したり、受け取った商品をデータベースに保存したりするようなこと(つまり、算出結果を返す以外のこと)は何も行いません。
そのため、このメソッドを実行することで発生する結果は割引率、つまり、出力値だけとなります。
状態ベース・テスト
検証する処理の実行が終わった後にテスト対象の状態を検証します。ここで言う状態とは、テスト対象システムの状態、その協力者オブジェクトの状態、データベースやファイル・システムなどのプロセス外依存の状態のことを指します。
public class Order
{
private readonly List<Product> _products = new List(Product)();
public IReadOnlyList<Product> Products => _products.ToList():
public void AddProduct(Product product)
{
_products.Add(product);
}
}
// 注文に商品を追加する
[Fact]
public void Adding_a_product_to_an_order()
{
var product = new Product("Hand wash");
var sut = new Order();
sut.AddProduct(product);
Assert.Equal(1, sut.Products.Count);
Assert.Equal(product, sut.Products[0]);
}このテスト・ケースでは、AddProduct メソッドの実行が終わったあと、テスト対象システム(sut)が保持する商品のコレクションを検証しています。このテスト・ケースで検証される対象は、テスト対象システムの変更された状態になります。
コミュニケーション・ベース・テスト
コミュニケーション・ベース・テストでは、モックを用いてテスト対象システムとその協力者オブジェクトとのあいだで行われるコミュニケーションを検証します。
// 挨拶のメールを送信する
[Fact]
public void Sending_a_greetings_email()
{
var emailGatewayMock = new Mock<IEmailGateway>();
var sut = new Controller(emailGatewayMock.Object);
sut.GreetUser("[email protected]");
emailGatewayMock.Verify(
x => x.SendGreetingsEmail("[email protected]"),
Times.Once);
}関数型アーキテクチャおよび出力値ベース・テストへの移行 #
テスト対象システムの性質によって採用できるテスト手法は変わります。基本的に 出力値ベース・テスト > 状態ベース・テスト > コミュニケーション・ベース・テスト の順で、リファクタリングへの耐性が強く、保守もしやすいコードとなります。
プロダクション・コードをリファクタリングすることで、出力値ベース・テストによる検証が可能となるのであればそうすべきです。
ここでは、サンプル・プロジェクトに対して、どのようにリファクタリングするか見ていきます。このリファクタリングは次の2つの順を踏んで行うことで実施されます。
- プロセス外依存の利用からモックの利用への移行
- モックの利用から関数型アーキテクチャの利用への移行
今回見ていくサンプル・プロジェクトは訪問者がいつ訪れたのかを記録するシステムです。このシステムでは、テキストファイルに訪問者の記録を残しており、訪問者が訪れるたびに、その訪問者の名前と訪問した日時を最新の訪問者記録ファイルの最後に追加するようになっています。もし、すでに書き込まれている記録の数が訪問者記録ファイルの上限に達していた場合、新たなインデックス(番号)を持つ訪問者記録ファイルを作成し、そこに新たな訪問者の記録を書き込みます。
audit_01.txt
Peter; 2019-04-06T16:30:00
Jane; 2019-04-06T16:40:00
Jack; 2019-04-06T17:30:00audit_02.txt
Mary; 2019-04-06T17:30:00次が現時点での訪問者記録システムです。
public class AuditManager
{
private readonly int _maxEntriesPerFile;
private readonly string _directoryName;
public AuditManager(int maxEntriesPerFile, string directoryName)
{
_maxEntriesPerFile = maxEntriesPerFile;
_directoryName = directoryName;
}
public void AddRecord(string visitorName, DateTime timeOfVisit)
{
string[] filePaths = Directory.GetFiles(_directoryName);
(int index, string path)[] sorted = SortedByIndex(filePaths);
string newRecord = visitorName + ';' + timeOfVisit;
if (sorted.Length == 0)
{
string newFile = Path.Combine(_directoryName, "audit_1.txt");
File.WriteAllText(newFile, newRecord);
return;
}
(int currentFileIndex, string currentFilePath) = sorted.Last();
List<string> lines = File.ReadAllLines(currentFilePath).ToList();
if (lines.Count < _maxEntriesPerFile)
{
lines.Add(newRecord);
string newContent = string.Join("\r\n", lines);
File.WriteAllText(currentFilePath, newContent);
} else {
int newIndex = currentFileIndex + 1;
string newName = $"audit_{newIndex}.txt";
string newFile = Path.Combine(_directoryName, newName);
File.WriteAllText(newFile, newRecord);
}
}
}コードは少し長いですが、単純なことしか行っていません。AuditManager クラスがこのシステムの目的を司るクラスとなっており、コンストラクタの引数から1つの訪問者記録ファイルに書き込める最大の行数(maxEntriesPerFile)と訪問者記録ファイルが置かれているディレクトリ(directoryName)を受け取るようになっています。そして、このクラスが唯一公開しているメソッドが AddRecord です。
現状の AuditManager クラスはファイル・システムと深く結びついているため、そのままテストすることは簡単にはできません。開発者は各テスト・ケースを実行する前に、使用する訪問者記録ファイルを対象ディレクトリに正しく配置する必要があり、その上、テスト対象の処理が終われば、訪問者ファイルを読み込んで中身の正当性を確認しなければなりません。さらに、テスト終了後はテスト・ケースで更新した内容を元に戻す必要があります。また、複数のテスト・ケースを同時に実行することも難しいでしょう。ファイル・システムへのアクセスが発生するため、テストの実行時間も長くなります。
テスト・ケースがプロセス外依存と深く結びついてしまった場合、その依存をモックに置き換えることで問題を解決できます。
public interface IFileSystem
{
string[] GetFiles(string directoryName);
void WriteAllText(string filePath, string content);
List<string> ReadAllLines(string filePath);
}public class AuditManager
{
private readonly int _maxEntriesPerFile;
private readonly string _directoryName;
private readonly IFileSystem _fileSystem; // インタフェースでファイル・システムを表現
public AuditManager(
int maxEntriesPerFile,
string directoryName,
IFileSystem fileSystem)
{
_maxEntriesPerFile = maxEntriesPerFile;
_directoryName = directoryName;
_fileSystem = fileSystem;
}
public void AddRecord(string visitorName, DateTime timeOfVisit)
{
string[] filePaths = _fileSystem.GetFiles(_directoryName); // インタフェースを呼び出す
(int index, string path)[] sorted = SortedByIndex(filePaths);
string newRecord = visitorName + ';' + timeOfVisit;
if (sorted.Length == 0)
{
string newFile = Path.Combine(_directoryName, "audit_1.txt");
_fileSystem.WriteAllText(newFile, newRecord); // インタフェースを呼び出す
return;
}
(int currentFileIndex, string currentFilePath) = sorted.Last();
List<string> lines = _fileSystem.ReadAllLines(currentFilePath); // インタフェースを呼び出す
if (lines.Count < _maxEntriesPerFile)
{
lines.Add(newRecord);
string newContent = string.Join("\r\n", lines);
_fileSystem.WriteAllText(currentFilePath, newContent); // インタフェースを呼び出す
} else {
int newIndex = currentFileIndex + 1;
string newName = $"audit_{newIndex}.txt";
string newFile = Path.Combine(_directoryName, newName);
_fileSystem.WriteAllText(newFile, newRecord); // インタフェースを呼び出す
}
}
}これで AuditManager クラスとファイル・システムを分離できたので、モックを使った次のようなテスト・ケースが可能となりました。
// 現時点でのファイルが上限に達したときに、新しいファイルが作成される
[Fact]
public void A_new_file_is_created_when_the_current_file_overflows()
{
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock
.Setup(x => x.GetFiles("audits"))
.Returns(new string[]
{
@"audits\audit_1.txt",
@"audits\audit_2.txt"
});
fileSystemMock
.Setup(x => x.ReadAllLines(@"audits\audit_2.txt"))
.Returns(new List<string>
{
"Peter; 2019-04-06T16:30:00",
"Jane; 2019-04-06T16:40:00",
"Jack; 2019-04-06T17:30:00"
});
var sut = new AuditManager(3, "audits", fileSystemMock.Object);
sut.AddRecord("Alice", DateTime.Parse("2019-04-06T18:00:00"));
fileSystemMock.Verify(x => x.WriteAllText(
@"audits\audit_3.txt",
"Alice;2019-04-06T18:00:00"));
}モックのおかげでテストは可能になりましたが、準備(Arrange)フェーズに記述されるコードは複雑です。保守コストの観点で言えば、理想的とは言えません。
関数型アーキテクチャへとさらにリファクタリングして、AuditManager クラスから副作用を完全に取り除くようにしていきましょう。そのためには、AuditManager クラスの責務をファイルに対して何をするのかの決定を下すことだけとし、AuditManager クラスが決定を下すのに必要な情報を提供したり、下された決定に基づいた処理を行ったりすることを別のクラス(新たに加えられる Persister クラス)に任せ、そのクラスにファイル・システムへの更新を行わせるようにします。
public class FileContent
{
public readonly string FileName;
public readonly string[] Lines;
public FileContent(string fileName, string[] lines)
{
FileName = filename;
Lines = lines;
}
}public class FileUpdate
{
public readonly string FileName;
public readonly string NewContent;
public FileUpdate(string fileName, string newContent)
{
FileName = filename;
NewContent = newContent;
}
}public class AuditManager
{
private readonly int _maxEntriesPerFile;
public AuditManager(int maxEntriesPerFile)
{
_maxEntriesPerFile = maxEntriesPerFile;
}
public FileUpdate AddRecord(
FileContent[] files,
string visitorName,
DateTime timeOfVisit)
{
(int index, FileContent file)[] sorted = SortByIndex(files);
string newRecord = visitorName + ';' + timeOfVisit;
if (sorted.Length == 0)
{
return new FileUpdate(
"audit_1.txt", newRecord); // ファイルの更新に関する決定を返す
}
(int currentFileIndex, string currentFile) = sorted.Last();
List<string> lines = currentFile.Lines.ToList();
if (lines.Count < _maxEntriesPerFile)
{
lines.Add(newRecord);
string newContent = string.Join("\r\n", lines);
return new FileUpdate(
currentFile.FileName, newContent); // ファイルの更新に関する決定を返す
} else {
int newIndex = currentFileIndex + 1;
string newName = $"audit_{newIndex}.txt";
return new FileUpdate(
newName, newRecord); // ファイルの更新に関する決定を返す
}
}
}public class Persister
{
public FileContent[] ReadDirectory(string directoryName)
{
return Directory
.GetFiles(directoryName)
.Select(x => new FileContent(
Path.GetFileName(x),
File.ReadAllLines(x)))
.ToArray();
}
public void ApplyUpdate(string directoryName, FileUpdate update)
{
string filePath = Path.Combine(directoryName, update.FileName);
File.WriteAllText(filePath, update.NewContent);
}
}AuditManager クラスに訪問者記録ファイルの情報を収集させるのではなく、FileContent クラスの配列を渡すようにしています。
さらに、AuditManager クラスは訪問者記録ファイルに対して直接的に何かをするのではなく、訪問者記録ファイルに対して何をするのか(どのような副作用を起こすのか)を指示する決定内容 FIleUpdate を返すようにしています。
この決定内容をうけ、処理を行う(副作用を起こす)のが Persister クラスになります。
ビジネス・ロジックの複雑さはすべて AuditManager が引き受け、ファイル更新はすべて Persister が引き受けます。このことが、ビジネス・ロジックと副作用の分離なのです。
そして、これらすべてを連携させるためのアプリケーション・サービスの役割を担うクラスが次です。
public class ApplicationService
{
private readonly string _directoryName;
private readonly AuditManager _auditManager;
private readonly Persister _persister;
public ApplicationService(
string directoryName, int maxEntriesPerFile)
{
_directoryName = directoryName;
_auditManager = new AuditManager(maxEntriesPerFile);
_persister = new Persister();
}
public void AddRecord(string visitorName, DateTime timeOfVisit)
{
FileContent[] files = _persister.ReadDirectory(_directoryName);
FileUpdate update = _auditManager.AddRecord(
files, visitorName, timeOfVisit);
_persister.ApplyUpdate(_directoryName, update);
}
}このような実装になったことで、振る舞いを簡単にテストできるようになりました。
// 現時点でのファイルが上限に達したときに、新しいファイルが作成される
[Fact]
public void A_new_file_is_created_when_the_current_file_overflows()
{
var sut = new AuditManager(3);
var files = new FileContent[]
{
new FileContent("audit_1.txt", new string[0]),
new FileContent("audit_2.txt", new string
{
"Peter; 2019-04-06T16:30:00",
"Jane; 2019-04-06T16:40:00",
"Jack; 2019-04-06T17:30:00"
})
};
FileUpdate update = sut.AddRecord(
files, "Alice", DateTime.Parse("2019-04-06T18:00:00"));
Assert.Equal("audit_3.txt", update.FileName);
Assert.Equal("Alice;2019-04-06T18:00:00", update.NewContent);
}複雑なモックの設定は不要となり、入力値と出力値だけを使ったテストを行えるようになりました。
関数型アーキテクチャの欠点 #
関数型アーキテクチャに関してよく議論になるのはシステム全体に影響を与えるパフォーマンスの劣化が起こることです。たしかに、関数型アーキテクチャを導入すると保守性が向上する一方、パフォーマンスは落ちます。パフォーマンスを重視するシステムであれば、伝統的なアーキテクチャの選択するほうがうまく機能するかもしれません。結局のところ、すべての課題を解決できるアーキテクチャなどは存在しないのです。
第7章:単体テストの価値を高めるリファクタリング #
(省略)
第8章:なぜ、統合(integration)テストを行うのか? #
(省略)
第9章:モックのベスト・プラクティス #
(省略)
第10章:データベースに対するテスト #
(省略)
第11章:単体テストのアンチ・パターン #
プライベートなメソッドに対する単体テスト #
単体テストに関することで何度も尋ねられることの1つに、プライベートなメソッドをどのようにテストするのか、ということがあります。この質問に対する答えは、そのようなテストを一切すべきではない、ということになります。
プライベートな状態の公開 #
単体テストを行えるようにすることだけを目的にプライベートな状態を公開する、ということがあります。プライベートな状態に関する指針はプライベートなメソッドの指針と同じであり、外部から隠し続けなくてはならない状態であるのなら、その状態を決して公開してはなりません。
テストへのドメイン知識の漏洩 #
たとえば、次に示す計算アルゴリズムを見てください(今回は例を示したいだけなので、複雑さがほぼないアルゴリズムになっています)。
public static class Calculator
{
public static int Add(int value1, int value2)
{
return value1 + value2;
}
}このクラスをテストするのに「間違った」方法を使っているのが次になります。
public class CalculatorTests
{
// 2つの数値を足す
[Fact]
public void Adding_two_numbers()
{
int value1 = 1;
int value2 = 3;
int actual = Calculator.Add(value1, value2);
int expected = value1 + value2;
Assert.Equal(expected, actual);
}
}このテストには何も問題がないように思えます。しかしながら、プロダクション・コードのアルゴリズム(value1 + value2)をテスト・コードにそのまま持ち込むことは問題であり、このことはアンチ・パターンとなります。
基本的にこのようなテストはプロダクション・コードをテスト・コードにコピー&ペーストしているのと何ら変わりがないことになります。
結局のところ、このようなテストは実装の詳細と結びついた別の形の例に過ぎません。そのため、このようなテストはリファクタリングへの耐性をほぼ持っておらず、テストとしての価値がないことになります。プロダクション・コードのアルゴリズムに変更を加えるとテストが失敗する可能性が高いのです。開発者は、テストが失敗しても、テスト・コードに新しくなったアルゴリズムのコードを写すだけで、テストが失敗した本来の原因を調べることをしなくなります(とはいえ、もともとのテスト・コードがプロダクション・コードから写しただけのものであるため、このように修正してしまうのは理解できることです)。
そうなると、このアルゴリズムを適切にテストするにはどうすればよいのでしょうか?そのためには、テストを作成する際、プロダクション・コードに定義された特定のロジックやアルゴリズムをテスト・コードに持ってこないようにしなくてはなりません。つまり、テスト対象のアルゴリズムをテスト・コードに複製するようなことを決して行ってはならない、ということです。その代わりに、期待値そのものをテスト・コードに直接書き込みます。
public class CalculatorTests
{
// 2つの数値を足す
[Fact]
public void Adding_two_numbers()
{
int value1 = 1;
int value2 = 3;
int actual = Calculator.Add(value1, value2);
int expected = 4;
Assert.Equal(expected, actual);
}
}最初、期待値に値を直接書き込むことに抵抗があるかもしれません。しかしながら、単体テストにおいて、期待値を直接書くことは実践すべきプラクティスです。テスト対象のコードとは異なる方法で取得した期待値と実行結果を比較することが単体テストにおいて意味のある確認となるのです。
プロダクション・コードへの汚染 #
プロダクション・コードへの汚染とは、テストでのみ必要とされるコードをプロダクション・コードに加えることを指します。
よくあるのがテストとして実行されている場合にだけ振る舞いを変える様々な種類の切り替えです。
public class Logger
{
private readonly bool _isTestEnvironment;
public Logger(bool isTestEnvironment)
{
_isTestEnvironment = isTestEnvironment;
}
public void Log(string text)
{
if (_isTestEnvironment)
return;
// ログの出力 ...
}
}
// Logger クラスを使うコントローラ
public class Controller
{
public void SomeMethod(Logger logger)
{
logger.Log("SomeMethod is called");
}
}
// Controller クラスのテスト
[Fact]
public void Some_test()
{
var logger = new Logger(true); // テスト時であれば true を設定しログ出力を抑止する
var sut = new Controller();`
sut.SomeMethod(logger);
// 確認処理 ...
}この Logger クラスでは、コンストラクタの引数からテスト時か否かを示す真偽値(bool)を受け取るようになっています。真偽値の切り替えを行うことでログの出力を抑止できるようになります。
このようなプロダクション・コードへの汚染によってもたらされる問題は、テストに関するコードがプロダクション・コードに混ざってしまい、プロダクション・コードへの保守コストが増えてしまうことです。
解決策は、ILogger インターフェースを導入して、本番環境で使われる実装クラスとテスト環境で使われる実装クラスの2つの実装クラスを用意することです。
そして、コントローラに対してインターフェースを受け取らせるようにリファクタリングします。
public interface ILogger
{
void Log(string text);
}
public class Logger : ILogger
{
public void Log(string text)
{
// ログの出力 ...
}
}
public class Controller
{
public void SomeMethod(ILogger logger)
{
logger.Log("SomeMethod is called");
}
}
// テスト・コード
public class FakeLogger : ILogger
{
public void Log(string text)
{
// 何もしない
}
}
[Fact]
public void Some_test()
{
var logger = new FakeLogger();
var sut = new Controller();
sut.SomeMethod(logger);
// 確認処理 ...
}ILogger インターフェースの導入も間違いなくプロダクション・コードへの汚染ではあります。なぜなら、このインターフェースの導入はテストのためだけに行ったことだからです。
しかし、インターフェースの導入によって生じるプロダクション・コードへの汚染は真偽値を用いた場合と比べて影響が少なく、その対処も簡単に行なえます。さらに、真偽値を用いていたときとは異なり、テスト時にしか使われないはずのコードが間違って本番環境で呼び出されることもなくなります。加えて、インターフェースにはバグを含めることはできません。なぜなら、インターフェースは単なる契約であり、具体的なコードを持つわけではないからです。このように、インターフェースの導入は、真偽値を用いる場合と異なり、処理に関するコードがそこに含まれるわけではないため、そのことが原因でバグが生じることはないのです。
単体テストにおける現在日時の扱い #
アプリケーションの機能の中には現在時刻を扱わなくてはならないものが多々あります。このような現在日時に依存する機能をテストする場合、何も考慮していないと、テスト・ケースに偽陽性が含まれる可能性が高くなります。なぜなら、実行フェーズで扱う現在日時は確認フェーズに移った時には変わっている可能性があるからです。
環境コンテキスト(ambient context)として現在日時を扱う方法
まずは、現在日時を環境コンテキストと呼ばれるパターンを用いて扱う方法です。これはアンチ・パターンです。
環境コンテキストを次で示すような独自のクラス(DateTimeServer)として用意します。そして組み込みの DateTime.Now の代わりに、その独自に作成したクラスを使うようにします。
public class DateTimeServer
{
private static Func<DateTime> _func;
public static DateTime Now => _func();
public static void Init(Func<DateTime> func)
{
_func = func;
}
}
// 本番環境で使う場合
DateTimeServer.Init(() => DateTime.Now);
// テスト環境で使う場合
DateTimeServer.Init(() => new DateTime(2020, 1, 1));しかしながら、ログ出力のときと同じく、環境コンテキストから現在日時を扱うことはアンチ・パターンです。なぜなら、環境コンテキストの利用はプロダクション・コードを汚すことであり、テストの実施をより難しくすることになるからです。加えて、静的(static)なフィールドを使うことはテスト・ケース間で共有される依存を持ち込むことになってしまいます。
明示的な依存として現在日時を扱う方法 ー サービスとして注入、値として注入
環境コンテキストよりも優れた方法を見ていきます。それは、現在日時を依存として明示的に注入することです。
public interface IDateTimeServer
{
DateTime Now { get; }
}
public class DateTimeServer : IDateTimeServer
{
public DateTime Now => DateTime.Now;
}
public class InquiryController
{
private readonly IDateTimeServer _dateTimeServer;
public InquiryController(
IDateTimeServer dateTimerServer)
{
_dateTimeServer = dateTimerServer; // サービスとして現在日時を注入する
}
public void ApproveInquiry(int id)
{
Inquiry inquiry = GetById(id);
inquiry.Approved(_dateTimeServer.Now); // 値として現在日時を注入する
SaveInquiry(inquiry);
}
}サービスと値のうち、現在日時を注入するのにより好ましいのは値として注入する方法です。なぜなら、プロダクション・コードにおいて、現在日時を単に値として扱うほうが簡単だからです。さらに、テストにおいても、値を置き換える方が簡単です。
しかしながら、現在日時を常に値として注入できるわけではありません。なぜなら、依存の注入(Dependency Injection: DI)を扱うフレームワークは値オブジェクトとの相性が悪いからです。そこで、この課題を解決するために、ビジネス・オペレーションの最初のほうで現在日時をサービスとして注入し、それ以降は、そのサービスから取得する現在日時の値を受け渡すようにします。
このクラスはインスタンス生成時に DateTimeServer クラス(サービス)を受け取るようになっており、あとで呼ばれることになる ApproveInquiry メソッドでは、そのサービスから取得した DateTime 型の値を現在日時としてドメイン・クラスである Inquiry クラスの Approve メソッドに渡しています。