ユニットテスト、テスト可能なコードの書き方、そしてなぜそれが重要なのか
公開: 2022-03-11単体テストは、真面目なソフトウェア開発者のツールボックスに欠かせない手段です。 ただし、特定のコードに対して適切な単体テストを作成するのは非常に難しい場合があります。 開発者は、自分自身または他の誰かのコードをテストするのが難しいため、基本的なテスト知識や秘密の単体テスト手法が不足していることが原因であると考えることがよくあります。
この単体テストのチュートリアルでは、単体テストが非常に簡単であることを示すつもりです。 単体テストを複雑にし、コストのかかる複雑さをもたらす実際の問題は、設計が不十分でテスト不可能なコードの結果です。 コードをテストするのが難しい理由、テスト容易性を向上させるために避けるべきアンチパターンと悪い習慣、およびテスト可能なコードを作成することで達成できるその他の利点について説明します。 単体テストの作成とテスト可能なコードの生成は、テストの煩わしさを軽減するだけでなく、コード自体をより堅牢にし、保守を容易にすることでもあることがわかります。
ユニットテストとは何ですか?
基本的に、単体テストは、アプリケーションのごく一部をインスタンス化し、他の部分から独立してその動作を検証する方法です。 一般的な単体テストには3つのフェーズがあります。最初に、テストするアプリケーションの小さな部分(テスト対象システム、またはSUTとも呼ばれます)を初期化し、次にテスト対象システムに刺激を適用します(通常はその上でメソッド)、そして最後に、それは結果として生じる振る舞いを観察します。 観察された動作が期待と一致する場合、単体テストは合格します。それ以外の場合は失敗し、テスト対象のシステムのどこかに問題があることを示します。 これらの3つの単体テストフェーズは、アレンジ、アクト、アサート、または単にAAAとも呼ばれます。
単体テストでは、テスト対象のシステムのさまざまな動作の側面を検証できますが、ほとんどの場合、状態ベースまたは相互作用ベースの2つのカテゴリのいずれかに分類されます。 テスト対象のシステムが正しい結果を生成すること、または結果の状態が正しいことを確認することを状態ベースの単体テストと呼び、特定のメソッドを適切に呼び出すことを確認することを相互作用ベースの単体テストと呼びます。
適切なソフトウェア単体テストのメタファーとして、カエルの脚、タコの触手、鳥の羽、犬の頭を備えた超自然的なキメラを作りたいと考えているマッドサイエンティストを想像してみてください。 (この比喩は、プログラマーが実際に仕事で行うことにかなり近いものです)。 その科学者は、自分が選んだすべての部品(またはユニット)が実際に機能することをどのように確認しますか? ええと、彼は、たとえば、1つのカエルの足を取り、それに電気刺激を加えて、適切な筋肉の収縮をチェックすることができます。 彼が行っているのは、基本的に単体テストのアレンジ-アクト-アサーションの手順と同じです。 唯一の違いは、この場合、ユニットは、プログラムを構築する抽象オブジェクトではなく、物理オブジェクトを参照することです。
この記事のすべての例でC#を使用しますが、説明されている概念はすべてのオブジェクト指向プログラミング言語に適用されます。
単純な単体テストの例は次のようになります。
[TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }
単体テストと統合テスト
考慮すべきもう1つの重要なことは、単体テストと統合テストの違いです。
ソフトウェアエンジニアリングの単体テストの目的は、他の部分から独立して、比較的小さなソフトウェアの動作を検証することです。 単体テストは範囲が狭く、すべてのケースをカバーできるため、すべてのパーツが正しく機能することが保証されます。
一方、統合テストは、システムのさまざまな部分が実際の環境で連携して機能することを示しています。 これらは複雑なシナリオを検証し(統合テストは、システム内で高レベルの操作を実行するユーザーと考えることができます)、通常、データベースやWebサーバーなどの外部リソースが存在する必要があります。
マッドサイエンティストの比喩に戻り、彼がキメラのすべての部分をうまく組み合わせたと仮定しましょう。 彼は、結果として得られたクリーチャーの統合テストを実行して、たとえば、さまざまな種類の地形を歩くことができることを確認したいと考えています。 まず第一に、科学者は生き物が歩くための環境をエミュレートする必要があります。 次に、彼はその生き物をその環境に投げ込み、棒で突いて、設計どおりに歩いたり動いたりするかどうかを観察します。 テストを終えた後、マッドサイエンティストは彼の素敵な実験室に散らばっているすべての土、砂、岩をきれいにします。
単体テストと統合テストの大きな違いに注意してください。単体テストは、環境や他の部分から分離されたアプリケーションの小さな部分の動作を検証し、実装が非常に簡単です。一方、統合テストは、さまざまなコンポーネント間の相互作用を対象としています。現実に近い環境であり、追加のセットアップおよび分解フェーズを含む、より多くの労力が必要です。
ユニットテストと統合テストの合理的な組み合わせにより、すべてのユニットが他のユニットから独立して正しく機能し、これらすべてのユニットが統合されたときにうまく機能することが保証され、システム全体が期待どおりに機能するという高いレベルの信頼性が得られます。
ただし、実装しているテストの種類(ユニットテストまたは統合テスト)を常に特定することを忘れないでください。 違いは時々欺くことができます。 ビジネスロジッククラスの微妙なエッジケースを検証するための単体テストを作成していて、Webサービスやデータベースなどの外部リソースが存在する必要があることに気付いた場合、何かが正しくありません。基本的に、スレッジハンマーを使用してナットを割る。 そしてそれは悪いデザインを意味します。
良いユニットテストを作るものは何ですか?
このチュートリアルの主要部分に飛び込んで単体テストを作成する前に、優れた単体テストの特性について簡単に説明しましょう。 ユニットテストの原則では、優れたテストは次のとおりです。
書きやすい。 開発者は通常、アプリケーションの動作のさまざまなケースや側面をカバーするために多くの単体テストを作成するため、多大な労力をかけずにこれらすべてのテストルーチンを簡単にコーディングできるはずです。
読み取り可能。 単体テストの意図は明確でなければなりません。 優れた単体テストは、アプリケーションの動作の側面についてのストーリーを伝えるため、テストされているシナリオを簡単に理解でき、テストが失敗した場合は、問題に対処する方法を簡単に検出できるはずです。 優れた単体テストがあれば、実際にコードをデバッグしなくてもバグを修正できます。
信頼性のある。 単体テストは、テスト対象のシステムにバグがある場合にのみ失敗するはずです。 それはかなり明白に思えますが、バグが導入されていなくてもテストが失敗すると、プログラマーはしばしば問題に遭遇します。 たとえば、テストは1つずつ実行すると合格しますが、テストスイート全体を実行すると失敗する場合があります。または、開発マシンに合格して継続的インテグレーションサーバーで失敗する場合があります。 これらの状況は、設計上の欠陥を示しています。 優れた単体テストは、再現性があり、環境や実行順序などの外部要因から独立している必要があります。
速い。 開発者はユニットテストを作成して、繰り返し実行し、バグが発生していないことを確認できるようにします。 単体テストが遅い場合、開発者は自分のマシンでの単体テストの実行をスキップする可能性が高くなります。 1つの遅いテストでは大きな違いはありません。 さらに千を追加すると、私たちは確かにしばらく待って立ち往生しています。 単体テストが遅い場合は、テスト対象のシステムまたはテスト自体が外部システムと相互作用し、環境に依存していることを示している場合もあります。
統合ではなく、真のユニット。 すでに説明したように、ユニットテストと統合テストには異なる目的があります。 単体テストとテスト対象システムの両方が、外部要因の影響を排除するために、ネットワークリソース、データベース、ファイルシステムなどにアクセスしないようにする必要があります。
それだけです—ユニットテストを書く秘訣はありません。 ただし、テスト可能なコードを記述できるようにする手法がいくつかあります。
テスト可能およびテスト不可能なコード
一部のコードは、適切な単体テストを作成するのが難しい、または不可能でさえあるような方法で作成されています。 では、何がコードのテストを難しくしているのでしょうか? テスト可能なコードを作成するときに避けるべきいくつかのアンチパターン、コードの臭い、および悪い習慣を確認しましょう。
非決定論的要因によるコードベースのポイズニング
簡単な例から始めましょう。 スマートホームマイクロコントローラー用のプログラムを作成していると想像してください。要件の1つは、夕方または夜間に裏庭で動きが検出された場合に、裏庭のライトを自動的にオンにすることです。 おおよその時刻(「夜」、「朝」、「午後」、「夕方」)の文字列表現を返すメソッドを実装することから始めました。
public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }
基本的に、このメソッドは現在のシステム時刻を読み取り、その値に基づいて結果を返します。 では、このコードの何が問題になっていますか?
単体テストの観点から考えると、このメソッドに対して適切な状態ベースの単体テストを作成することは不可能であることがわかります。 DateTime.Now
は基本的に非表示の入力であり、プログラムの実行中またはテストの実行中に変更される可能性があります。 したがって、それを後で呼び出すと、異なる結果が生成されます。
このような非決定論的な動作により、システムの日付と時刻を実際に変更せずにGetTimeOfDay()
メソッドの内部ロジックをテストすることは不可能になります。 そのようなテストをどのように実装する必要があるかを見てみましょう。
[TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }
このようなテストは、前述の多くのルールに違反します。 書き込みにはコストがかかり(セットアップとティアダウンのロジックが重要であるため)、信頼性が低く(たとえば、システムのアクセス許可の問題により、テスト対象のシステムにバグがない場合でも失敗する可能性があります)、速く走る。 そして最後に、このテストは実際には単体テストではありません。単純なエッジケースをテストするふりをしますが、特定の方法で環境を設定する必要があるため、単体テストと統合テストの間のテストになります。 結果は努力する価値がありませんね?
これらのテスト容易性の問題はすべて、低品質のGetTimeOfDay()
APIが原因であることがわかりました。 現在の形式では、このメソッドにはいくつかの問題があります。
これは、具体的なデータソースと緊密に結合されています。 他のソースから取得した、または引数として渡された日時を処理するためにこのメソッドを再利用することはできません。 このメソッドは、コードを実行する特定のマシンの日付と時刻でのみ機能します。 密結合は、ほとんどのテスト容易性の問題の主な原因です。
これは、単一責任原則(SRP)に違反しています。 この方法には複数の責任があります。 情報を消費し、処理します。 SRP違反のもう1つの指標は、単一のクラスまたはメソッドに変更する理由が複数ある場合です。 この観点から、
GetTimeOfDay()
メソッドは、内部ロジックの調整のため、または日付と時刻のソースを変更する必要があるために変更される可能性があります。それはその仕事を成し遂げるために必要な情報についてです。 開発者は、実際のソースコードのすべての行を読んで、どの隠し入力が使用され、どこから来ているのかを理解する必要があります。 メソッドのシグネチャだけでは、メソッドの動作を理解するのに十分ではありません。
予測と維持は困難です。 可変のグローバル状態に依存するメソッドの動作は、ソースコードを読み取るだけでは予測できません。 以前に変更された可能性のある一連のイベント全体とともに、現在の値を考慮する必要があります。 実際のアプリケーションでは、それらすべてを解明しようとすると、本当に頭痛の種になります。
APIを確認したら、最終的に修正しましょう。 幸いなことに、これはすべての欠陥について説明するよりもはるかに簡単です。緊密に結びついた懸念を解消する必要があります。
APIの修正:メソッド引数の導入
APIを修正する最も明白で簡単な方法は、メソッド引数を導入することです。
public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }
このメソッドでは、呼び出し元がこの情報を自分で密かに探すのではなく、 DateTime
引数を指定する必要があります。 ユニットテストの観点からは、これは素晴らしいことです。 メソッドは決定論的であるため(つまり、戻り値は入力に完全に依存します)、状態ベースのテストは、 DateTime
値を渡して結果を確認するのと同じくらい簡単です。
[TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }
この単純なリファクタリングは、処理するデータと処理方法の間に明確な継ぎ目を導入することで、前述のすべてのAPIの問題(密結合、SRP違反、APIの不明確で理解しにくい)も解決したことに注意してください。
優れています—メソッドはテスト可能ですが、クライアントはどうですか? 現在、 GetTimeOfDay(DateTime dateTime)
メソッドに日付と時刻を提供するのは呼び出し元の責任です。つまり、十分な注意を払わないと、テストできなくなる可能性があります。 それをどのように処理できるか見てみましょう。
クライアントAPIの修正:依存性注入
スマートホームシステムの作業を継続し、 GetTimeOfDay(DateTime dateTime)
メソッドの次のクライアントを実装するとします。これは、時刻と動きの検出に基づいてライトをオンまたはオフにする前述のスマートホームマイクロコントローラーコードです。 :
public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }
痛い! 同じ種類の隠されたDateTime.Now
入力の問題があります。唯一の違いは、抽象化レベルの少し高い位置にあることです。 この問題を解決するために、別の引数を導入して、署名ActuateLights(bool motionDetected, DateTime dateTime)
を使用して新しいメソッドの呼び出し元にDateTime
値を提供する責任を再び委任することができます。 ただし、問題をコールスタックの上位レベルに移動する代わりに、 ActuateLights(bool motionDetected)
メソッドとそのクライアントの両方をテスト可能に保つことができる別の手法を採用しましょう。制御の反転(IoC)です。
制御の反転は、コードを分離するため、特に単体テストのための、単純ですが非常に便利な手法です。 (結局のところ、物事を互いに独立して分析できるようにするためには、物事をゆるく結合しておくことが不可欠です。)IoCの重要なポイントは、意思決定コード(何かをするとき)とアクションコード(何かが起こったときに何をするか)を分離することです。 )。 この手法により、柔軟性が向上し、コードがよりモジュール化され、コンポーネント間の結合が減少します。
制御の反転は、さまざまな方法で実装できます。 1つの特定の例(コンストラクターを使用した依存性注入)と、それがテスト可能なSmartHomeController
の構築にどのように役立つかを見てみましょう。
まず、日付と時刻を取得するためのメソッドシグネチャを含むIDateTimeProvider
インターフェイスを作成しましょう。
public interface IDateTimeProvider { DateTime GetDateTime(); }
次に、 SmartHomeController
にIDateTimeProvider
実装を参照させ、日付と時刻を取得する責任をSmartHomeControllerに委任します。
public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }
これで、制御の反転がいわゆる「制御の反転」と呼ばれる理由がわかります。日付と時刻の読み取りに使用するメカニズムの制御が反転し、 SmartHomeController
自体ではなく、 SmartHomeController
のクライアントに属します。 これにより、 ActuateLights(bool motionDetected)
メソッドの実行は、外部から簡単に管理できる2つのものに完全に依存しますmotionDetected
引数と、 SmartHomeController
コンストラクターに渡されるIDateTimeProvider
の具体的な実装です。
ユニットテストにとってこれが重要なのはなぜですか? これは、本番コードと単体テストコードで異なるIDateTimeProvider
実装を使用できることを意味します。 実稼働環境では、実際の実装が注入されます(たとえば、実際のシステム時間を読み取る実装)。 ただし、単体テストでは、特定のシナリオのテストに適した定数または事前定義されたDateTime
値を返す「偽の」実装を挿入できます。
IDateTimeProvider
の偽の実装は、次のようになります。
public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }
このクラスの助けを借りて、 SmartHomeController
を非決定論的要因から分離し、状態ベースの単体テストを実行することができます。 モーションが検出された場合、そのモーションの時刻がLastMotionTime
プロパティに記録されていることを確認しましょう。

[TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }
すごい! このようなテストは、リファクタリング前には不可能でした。 非決定論的要因を排除し、状態ベースのシナリオを検証したので、 SmartHomeController
は完全にテスト可能だと思いますか?
副作用によるコードベースのポイズニング
非決定論的な隠し入力によって引き起こされた問題を解決し、特定の機能をテストできたにもかかわらず、コード(または少なくともその一部)はまだテストできません!
ライトのオンとオフをActuateLights(bool motionDetected)
メソッドの次の部分を確認してみましょう。
// If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }
ご覧のとおり、 SmartHomeController
は、ライトをオンまたはオフにする責任を、シングルトンパターンを実装するBackyardLightSwitcher
オブジェクトに委任します。 このデザインの何が問題になっていますか?
ActuateLights(bool motionDetected)
メソッドを完全に単体テストするには、状態ベースのテストに加えて、相互作用ベースのテストを実行する必要があります。 つまり、適切な条件が満たされた場合にのみ、ライトをオンまたはオフにするメソッドが呼び出されるようにする必要があります。 残念ながら、現在の設計ではそれを行うことができませんBackyardLightSwitcher
のTurnOn()
()メソッドとTurnOff()
メソッドは、システムの状態変化をトリガーします。つまり、副作用を引き起こします。 これらのメソッドが呼び出されたことを確認する唯一の方法は、対応する副作用が実際に発生したかどうかを確認することです。これは苦痛を伴う可能性があります。
実際、モーションセンサー、裏庭のランタン、スマートホームマイクロコントローラーがモノのインターネットネットワークに接続され、ワイヤレスプロトコルを使用して通信するとします。 この場合、単体テストはそのネットワークトラフィックの受信と分析を試みることができます。 または、ハードウェアコンポーネントがワイヤで接続されている場合、単体テストでは、電圧が適切な電気回路に印加されているかどうかを確認できます。 または、結局のところ、追加の光センサーを使用して、ライトが実際にオンまたはオフになったことを確認できます。
ご覧のとおり、ユニットテストの副作用の方法は、非決定論的な方法のユニットテストと同じくらい難しい場合があり、不可能な場合もあります。 どんな試みでも、私たちがすでに見たのと同様の問題につながります。 結果として得られるテストは、実装が難しく、信頼性が低く、速度が遅くなる可能性があり、実際にはユニットではありません。 そして、その後、テストスイートを実行するたびにライトが点滅することで、最終的には夢中になります。
繰り返しになりますが、これらのテスト容易性の問題はすべて、開発者が単体テストを作成する能力ではなく、APIの不良が原因で発生します。 ライトコントロールがどれほど正確に実装されていても、 SmartHomeController
には次のようなおなじみの問題があります。
これは、具体的な実装と緊密に結合されています。 APIは、
BackyardLightSwitcher
のハードコードされた具体的なインスタンスに依存しています。ActuateLights(bool motionDetected)
メソッドを再利用して、裏庭にあるライト以外のライトを切り替えることはできません。これは、単一責任の原則に違反します。 APIを変更する理由は2つあります。1つは内部ロジックの変更(夜だけでライトをオンにすることを選択するなど)、もう1つはライトスイッチングメカニズムを別のメカニズムに置き換える場合です。
それはその依存関係についてです。 開発者は、ソースコードを掘り下げる以外に、
SmartHomeController
がハードコードされたBackyardLightSwitcher
コンポーネントに依存していることを知る方法はありません。理解して維持するのは難しいです。 条件が整ったときにライトが点灯しない場合はどうなりますか?
SmartHomeController
を役に立たないように修正するために多くの時間を費やすことができましたが、問題の原因はBackyardLightSwitcher
のバグ(または、さらに面白いことに、電球が切れた!)であることがわかりました。
テスト容易性と低品質のAPIの問題の両方の解決策は、当然のことながら、緊密に結合されたコンポーネントを相互に切り離すことです。 前の例と同様に、依存性注入を使用するとこれらの問題が解決されます。 SmartHomeController
にILightSwitcher
依存関係を追加し、照明スイッチを切り替える責任を委任し、適切なメソッドが適切な条件下で呼び出されたかどうかを記録する偽のテスト専用ILightSwitcher
実装を渡すだけです。 ただし、依存性注入を再度使用する代わりに、責任を分離するための興味深い代替アプローチを確認しましょう。
APIの修正:高階関数
このアプローチは、ファーストクラス関数をサポートするオブジェクト指向言語のオプションです。 C#の機能を利用して、 ActuateLights(bool motionDetected)
メソッドがさらに2つの引数を受け入れるようにします。 Action
デリゲートのペアで、ライトのオンとオフを切り替えるために呼び出す必要のあるメソッドを指します。 このソリューションは、メソッドを高階関数に変換します。
public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }
これは、これまでに見た従来のオブジェクト指向の依存性注入アプローチよりも機能的なソリューションです。 ただし、依存性注入よりも少ないコードと表現力で同じ結果を得ることができます。 SmartHomeController
に必要な機能を提供するために、インターフェイスに準拠するクラスを実装する必要はなくなりました。 代わりに、関数定義を渡すことができます。 高階関数は、制御の反転を実装する別の方法と考えることができます。
ここで、結果のメソッドの相互作用ベースの単体テストを実行するために、簡単に検証可能な偽のアクションをメソッドに渡すことができます。
[TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }
最後に、 SmartHomeController
APIを完全にテスト可能にし、状態ベースと相互作用ベースの両方の単体テストを実行できるようにしました。 繰り返しになりますが、テスト容易性の向上に加えて、意思決定とアクションコードの間に継ぎ目を導入することで、密結合の問題を解決し、よりクリーンで再利用可能なAPIにつながることに注意してください。
現在、完全な単体テストカバレッジを実現するために、一連の類似した外観のテストを実装して、考えられるすべてのケースを検証できます。単体テストは非常に簡単に実装できるため、大したことではありません。
不純物とテスト容易性
制御されていない非決定論と副作用は、コードベースに対する破壊的な影響が似ています。 不注意に使用すると、欺瞞的で、理解と維持が難しく、緊密に結合され、再利用できず、テストできないコードになります。
一方、決定論的で副作用のない方法は、テスト、推論、再利用がはるかに簡単で、より大きなプログラムを構築できます。 関数型プログラミングの観点から、このようなメソッドは純粋関数と呼ばれます。 純粋関数をテストするユニットで問題が発生することはめったにありません。 私たちがしなければならないのは、いくつかの引数を渡し、結果が正しいかどうかを確認することだけです。 コードを実際にテスト不能にするのは、ハードコーディングされた不純な要素であり、他の方法で置き換えたり、オーバーライドしたり、抽象化したりすることはできません。
不純物は有毒です。メソッドFoo()
が非決定論的または副作用のメソッドBar()
に依存している場合、 Foo()
も非決定論的または副作用になります。 最終的には、コードベース全体を汚染する可能性があります。 これらすべての問題に複雑な実際のアプリケーションのサイズを掛けると、匂い、アンチパターン、秘密の依存関係、およびあらゆる種類の醜く不快なものでいっぱいのコードベースを維持するのが難しいことに気付くでしょう。
ただし、不純物は避けられません。 実際のアプリケーションは、ある時点で、環境、データベース、構成ファイル、Webサービス、またはその他の外部システムと対話して、状態を読み取って操作する必要があります。 したがって、不純物を完全に排除することを目的とするのではなく、これらの要因を制限し、コードベースを汚染させないようにし、ハードコードされた依存関係を可能な限り壊して、物事を個別に分析および単体テストできるようにすることをお勧めします。
テストが難しいコードの一般的な警告サイン
最後に、コードのテストが難しい可能性があることを示すいくつかの一般的な警告サインを確認しましょう。
静的プロパティとフィールド
静的プロパティとフィールド、または簡単に言えばグローバル状態は、メソッドがその仕事を遂行するために必要な情報を隠すことによって、非決定論を導入することによって、または副作用の広範な使用を促進することによって、コードの理解とテスト容易性を複雑にする可能性があります。 可変グローバル状態を読み取ったり変更したりする関数は、本質的に不純です。
たとえば、グローバルにアクセス可能なプロパティに依存する次のコードについて推論するのは困難です。
if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }
HeatWater()
メソッドが呼び出されるべきであると確信しているときに呼び出されない場合はどうなりますか? アプリケーションのいずれかの部分でCostSavingEnabled
値が変更された可能性があるため、何が問題なのかを見つけるために、その値を変更するすべての場所を見つけて分析する必要があります。 また、すでに見てきたように、テスト目的でいくつかの静的プロパティを設定することはできません(たとえば、 DateTime.Now
またはEnvironment.MachineName
。これらは読み取り専用ですが、それでも決定論的ではありません)。
一方、不変で決定論的なグローバル状態は完全にOKです。 実際、これにはもっと馴染みのある名前、つまり定数があります。 Math.PI
のような定数値は非決定論を導入せず、それらの値は変更できないため、副作用を許容しません。
double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!
シングルトン
基本的に、シングルトンパターンはグローバル状態の単なる別の形式です。 シングルトンは、実際の依存関係について嘘をつき、コンポーネント間に不必要に緊密な結合を導入するあいまいなAPIを促進します。 また、主要な職務に加えて、独自の初期化とライフサイクルを制御するため、単一責任の原則にも違反します。
シングルトンは、アプリケーション全体または単体テストスイートの存続期間中、状態を維持するため、単体テストを簡単に順序に依存させることができます。 次の例を見てください。
User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }
In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache
after each unit test run.
Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.
The new
Operator
Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.
For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:
using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }
However, sometimes new
is absolutely harmless: for example, it is OK to create simple entity objects:
var person = new Person("John", "Doe", new DateTime(1970, 12, 31));
It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack
methods were called or not — we just check if the end result is correct:
string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }
静的メソッド
Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.
For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:
void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }
However, pure static functions are OK: any combination of them will still be a pure function. 例えば:
double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }
Benefits of Unit Testing
Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.
As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.