フレームワークのミニマリズムとソフトウェアアーキテクチャにより、テストが容易になりました

公開: 2022-03-10
簡単な要約↬ソフトウェア開発の他の多くのトピックと同様に、テストとテスト駆動開発は、さまざまなテストフレームワークの学習に重点を置きすぎるため、理論と実装が不必要に複雑になることがよくあります。 この記事では、簡単な例えでテストの意味を再検討し、テストフレームワークの必要性を直接減らすソフトウェアアーキテクチャの概念を探り、テストプロセスのミニマリズムの姿勢から利益を得る理由についていくつかの議論を行います。 。

他の多くのAndroid開発者と同様に、プラットフォームでのテストへの最初の取り組みにより、私はすぐに意気消沈した専門用語に直面することになりました。 さらに、当時(2015年頃)に出くわしたいくつかの例では、実際のユースケースが提示されなかったため、 TextView.setText ( …)適切に機能しており、妥当な投資でした。

さらに悪いことに、私は理論や実践におけるソフトウェアアーキテクチャの実用的な理解がありませんでした。つまり、これらのフレームワークをわざわざ学んだとしても、いくつかgodクラスで構成されるモノリシックアプリケーションのテストを書いていたでしょう。スパゲッティコードで。 パンチラインは、そのようなアプリケーションの構築、テスト、および保守は、フレームワークの専門知識に関係なく、自己破壊の演習であるということです。 しかし、この認識は、モジュール式で、ゆるく結合された、非常にまとまりのあるアプリケーションを構築した後で初めて明らかになります。

ここから、この記事の主要な議論のポイントの1つに到達します。ここでは、わかりやすい言葉で要約します。ソフトウェアアーキテクチャの黄金の原則を適用することの主な利点の1つです(心配しないでください。簡単な例で説明します。言語)、それはあなたのコードがテストしやすくなることができるということです。 このような原則を適用することには他にも利点がありますが、ソフトウェアアーキテクチャとテストの関係がこの記事の焦点です。

ただし、コードをテストする理由と方法を理解したい人のために、最初に類推によるテストの概念を探ります。 専門用語を覚える必要はありません。 主要なトピックを深く掘り下げる前に、なぜこれほど多くのテストフレームワークが存在するのかという問題についても見ていきます。これを調べると、それらの利点、制限、さらには代替ソリューションさえもわかり始める可能性があります。

ジャンプした後もっと! 以下を読み続けてください↓

テスト:理由と方法

このセクションは、ベテランのテスターに​​とっては新しい情報ではありませんが、それでもこのアナロジーを楽しむことができます。 もちろん、私はロケットエンジニアではなく、ソフトウェアエンジニアですが、しばらくの間、物理空間とコンピューターのメモリ空間の両方でオブジェクトを設計および構築することに関連するアナロジーを借ります。 媒体は変化しますが、プロセスは原則としてまったく同じであることがわかります。

私たちがロケットエンジニアであり、スペースシャトルの第1段階*のロケットブースターを構築することが私たちの仕事であると仮定します。 同様に、さまざまな条件で構築とテストを開始するための最初の段階で役立つ設計を考え出したとします。

「第1段階」とは、ロケットが最初に打ち上げられたときに発射されるブースターを指します

プロセスに入る前に、私がこのアナロジーを好む理由を指摘したいと思います。人間の生命が危機に瀕している状況に置く前に、なぜ私たちがデザインをテストするのに苦労しているのかという質問に答えるのは難しいことではありません。 起動前にアプリケーションをテストすることで人命を救うことができるとは思いませんが(アプリケーションの性質によっては可能ですが)、評価、レビュー、および仕事を節約することができます。 最も広い意味で、テストとは、単一のパーツ、複数のコンポーネント、およびシステム全体が機能することを確認してから、それらが失敗しないことが非常に重要な状況で使用する方法です。

このアナロジーのどのように側面に戻るか、エンジニアが特定の設計をテストするプロセスを紹介します:冗長性。 冗長性は原則として単純です。テストするコンポーネントのコピーを、起動時に使用したいものと同じ設計仕様で作成します。 前提条件と変数を厳密に制御する隔離された環境でこれらのコピーをテストします。 これは、ロケットブースターがシャトル全体に統合されたときに適切に機能することを保証するものではありませんが、制御された環境で機能しない場合、まったく機能しない可能性が非常に高いことは確かです。

ロケット設計のコピーがテストされた数百、またはおそらく数千の変数のうち、ロケットブースターがテスト発射される周囲温度に下がるとします。 35°Cでテストすると、すべてがエラーなしで機能することがわかります。 この場合も、ロケットはほぼ室温で失敗することなくテストされます。 最終テストは、発射場で記録された最低気温である摂氏-5度で行われます。 この最終テスト中にロケットは発射されますが、しばらくするとロケットが燃え上がり、その後すぐに激しく爆発します。 しかし幸いなことに、管理された安全な環境で。

この時点で、温度の変化が少なくとも失敗したテストに関係しているように見えることがわかっています。これにより、ロケットブースターのどの部分が低温によって悪影響を受ける可能性があるかを検討することになります。 時間の経過とともに、1つの重要なコンポーネントである、あるコンパートメントから別のコンパートメントへの燃料の流れを阻止するのに役立つゴム製のOリングが、氷点下に近い温度または氷点下の温度にさらされると、硬くなり、効果がなくなることがわかりました。

彼のアナロジーは、チャレンジャー号のスペースシャトルの災害の悲劇的な出来事に大まかに基づいていることに気づいたかもしれません。 なじみのない人にとっては、悲しい真実(調査の結論としては不確か)は、エンジニアからの失敗したテストと警告がたくさんあったにもかかわらず、管理上および政治上の懸念が、それにもかかわらず、立ち上げを進めることに拍車をかけたということです。 いずれにせよ、冗長性という用語を覚えているかどうかに関係なく、あらゆる種類のシステムのパーツをテストするための基本的なプロセスを理解していただければ幸いです。

ソフトウェアについて

以前の例えでは、ロケットをテストするための基本的なプロセスを説明していましたが(詳細を十分に自由にしながら)、ここで、あなたと私に関連性の高い方法で要約します。ただし、起動するだけでソフトウェアをテストすることは可能です。何らかの展開可能な状態になったら、デバイスに適用します。代わりに、最初にアプリケーションの個々の部分に冗長性の原則を適用できると思います。

これは、アプリケーション全体の小さな部分(一般にソフトウェアのユニットと呼ばれる)のコピーを作成し、分離されたテスト環境をセットアップし、発生する可能性のある変数、引数、イベント、および応答に基づいてそれらがどのように動作するかを確認することを意味します実行時。 テストは理論と同じくらい簡単ですが、このプロセスに到達するための鍵は、実行可能にテスト可能なアプリケーションを構築することにあります。 これは、次の2つのセクションで検討する2つの懸念事項に帰着します。 最初の懸念はテスト環境に関係し、2番目の懸念はアプリケーションの構造化方法に関係します。

なぜフレームワークが必要なのですか?

ソフトウェアの一部をテストするには(以降、ユニットと呼びますが、この定義は意図的に過度に単純化されています)、実行時にソフトウェアと対話できる何らかのテスト環境が必要です。 純粋にJVMJava仮想マシン)環境で実行されるアプリケーションを構築する場合、テストを作成するために必要なのはJREJavaランタイム環境)だけです。 たとえば、この非常に単純な電卓クラスを見てください。

 class Calculator { private int add(int a, int b){ return a + b; } private int subtract(int a, int b){ return a - b; } }

フレームワークがない場合は、コードを実際に実行するためのmain関数を含むテストクラスがあれば、それをテストできます。 ご存知かもしれませんが、 main関数は、単純なJavaプログラムの実行の開始点を示します。 テスト対象については、いくつかのテストデータを電卓の関数にフィードし、基本的な算術演算が適切に実行されていることを確認します。

 public class Main { public static void main(String[] args){ //create a copy of the Unit to be tested Calculator calc = new Calculator(); //create test conditions to verify behaviour int addTest = calc.add(2, 2); int subtractTest = calc.subtract(2, 2); //verify behaviour by assertion if (addTest == 4) System.out.println("addTest has passed."); else System.out.println("addTest has failed."); if (subtractTest == 0) System.out.println("subtractTest has passed."); else System.out.println("subtractTest has failed."); } }

もちろん、Androidアプリケーションのテストは、まったく異なる手順です。 ZygoteInit.javaファイルのソースの奥深くに埋め込まれたmain関数がありますが(ここでは詳細は重要ではありません)、AndroidアプリケーションがJVMで起動される前に呼び出されますが、後輩のAndroid開発者でもシステム自体がこの関数の呼び出しに責任があることを知っています。 開発者ではありません。 代わりに、Androidアプリケーションのエントリポイントは、たまたまApplicationクラスであり、 AndroidManifest.xmlファイルを介してシステムがポイントできるActivityクラスです。

これはすべて、Androidアプリケーションでユニットをテストすると複雑さが増すという事実につながるだけです。厳密には、テスト環境でAndroidプラットフォームを考慮する必要があるためです。

緊密な結合の問題を抑える

密結合とは、特定のプラットフォーム、フレームワーク、言語、およびライブラリに依存する関数、クラス、またはアプリケーションモジュールを表す用語です。 これは相対的な用語であり、 Calculator.javaの例がJavaプログラミング言語および標準ライブラリと緊密に結合されていることを意味しますが、それはその結合の範囲です。 同じように、Androidプラットフォームと緊密に結合されたクラスをテストする際の問題は、プラットフォームを操作する方法を見つける必要があることです。

Androidプラットフォームに緊密に結合されたクラスの場合、2つのオプションがあります。 1つは、クラスをAndroidデバイス(物理または仮想)にデプロイすることです。 アプリケーションコードを本番環境に出荷する前にテストデプロイすることをお勧めしますが、これは、開発プロセスの初期段階と中期段階では非常に非効率的なアプローチです。

ユニットは、技術的にはどのように定義しても、一般にクラス内の単一の関数と見なされます(ただし、最初の単一関数呼び出しによって内部的に呼び出される後続のヘルパー関数を含むように定義を拡張するものもあります)。 いずれにせよ、ユニットは小さいことを意味します。 単一のユニットをテストするためにアプリケーション全体を構築、コンパイル、およびデプロイすることは、テストのポイントを完全に分離して見逃すことになります。

密結合の問題に対する別の解決策は、テストフレームワークを使用して、プラットフォームの依存関係を操作またはモック(シミュレート)することです。 EspressoRobolectricなどのフレームワークは、開発者に以前のアプローチよりもはるかに効果的なユニットのテスト手段を提供します。 前者はデバイス上で実行されるテストに役立ち(デバイステストと呼ばれることは十分に曖昧ではなかったため、「インストルメント化されたテスト」と呼ばれます)、後者はAndroidフレームワークをJVM上でローカルにモックすることができます。

すぐに説明する代替案ではなく、そのようなフレームワークに対する手すりに進む前に、これらのオプションを決して使用してはならないことを意味するものではないことを明確にしておきたいと思います。 開発者がアプリケーションを構築およびテストするために使用するプロセスは、個人的な好みと効率性の観点の組み合わせから生まれる必要があります。

モジュール式で疎結合のアプリケーションを構築するのが好きでない人にとって、適切なレベルのテストカバレッジが必要な場合は、これらのフレームワークに精通するしかありません。 多くの素晴らしいアプリケーションがこのように構築されており、アプリケーションをモジュール化して抽象化しすぎていると非難されることはめったにありません。 私のアプローチを採用する場合でも、フレームワークに大きく依存することを決定する場合でも、アプリケーションをテストするための時間と労力を費やしてくれたことに敬意を表します。

フレームワークを独立企業間で維持する

この記事のコアレッスンの最後の前文として、フレームワークの使用に関してミニマリズムの態度を取りたいと思う理由を説明する価値があります(これは、フレームワークのテストだけではありません)。 上記のサブタイトルは、ソフトウェアのベストプラクティスの大げさな教師であるRobert“ Uncle Bob” C.Martinからの言い換えです。 私が最初に彼の作品を研究して以来、彼が私にくれた多くの宝石の中で、これは数年の直接的な経験を経て把握しました。

この声明が何であるかを私が理解している限り、フレームワークを使用するコストは、フレームワークを学習して維持するために必要な時間の投資にあります。 それらのいくつかは非常に頻繁に変更され、いくつかは十分に頻繁に変更されません。 機能は非推奨になり、フレームワークは維持されなくなり、6〜24か月ごとに、最後のフレームワークに取って代わる新しいフレームワークが到着します。 したがって、プラットフォームまたは言語機能(はるかに長く続く傾向がある)として実装できるソリューションを見つけることができれば、上記のさまざまなタイプの変更に対してより耐性がある傾向があります。

より技術的な注意点として、 Espressoや程度は低いがRobolectricなどのフレームワークは、単純なJUnitテスト、または以前のフレームワークフリーテストほど効率的に実行することはできません。 JUnitは確かにフレームワークですが、 JVMと緊密に結合されており、Androidプラットフォームよりもはるかに遅い速度で変更される傾向があります。 フレームワークが少ないということは、ほとんどの場合、1つ以上のテストの実行と記述にかかる時間の点でより効率的なコードを意味します。

このことから、Androidプラットフォームを独立企業間で維持できるようにするいくつかの手法を活用するアプローチについて議論することになります。 その間、十分なコードカバレッジ、テスト効率、および必要に応じてあちこちでフレームワークを使用する機会が得られます。

建築の芸術

ばかげたアナロジーを使用すると、フレームワークとプラットフォームは、適切な境界を設定しない限り、開発プロセスを引き継ぐ圧倒的な同僚のようなものと考えるかもしれません。 ソフトウェアアーキテクチャの黄金の原則は、これらの境界を作成および実施するために必要な一般的な概念と特定の手法を提供します。 すぐにわかるように、コードにソフトウェアアーキテクチャの原則を適用することの利点が本当に何であるか疑問に思ったことがある場合は、直接的にも多くも間接的にコードのテストを容易にします。

関心事の分離

関心の分離は、私の推定では、ソフトウェアアーキテクチャ全体で最も普遍的に適用可能で有用な概念です(他の人を無視する必要があると言う意味はありません)。 関心の分離(SOC)は、私が知っているソフトウェア開発のあらゆる観点に適用することも、完全に無視することもできます。 概念を簡単に要約すると、クラスに適用する場合のSOCを見ていきますが、SOCはヘルパー関数を広範囲に使用することで関数に適用でき、アプリケーションのモジュール全体(で使用される「モジュール」)に外挿できることに注意してください。 Android / Gradleのコンテキスト)。

GUIアプリケーションのソフトウェアアーキテクチャパターンの調査に多くの時間を費やした場合は、Model-View-Controller(MVC)、Model-View-Presenter(MVP)、またはModel-View-の少なくとも1つに出くわした可能性があります。 ViewModel(MVVM)。 すべてのスタイルでアプリケーションを構築したので、すべてのプロジェクト(または単一のプロジェクト内の機能)にとって、それらのいずれも単一の最良のオプションであるとは考えていません。 皮肉なことに、Androidチームが推奨するアプローチとして数年前に提示したパターンMVVMは、Android固有のテストフレームワークがない場合はテスト可能性が最も低いようです(AndroidプラットフォームのViewModelクラスを使用したい場合は、私は確かにファンですの)。

いずれにせよ、これらのパターンの詳細は、それらの一般性ほど重要ではありません。 これらのパターンはすべて、SOCの異なるフレーバーであり、データユーザーインターフェイスロジックの3種類のコードの基本的な分離を強調しています。

では、データユーザーインターフェイス、およびロジックを分離することは、アプリケーションのテストにどの程度役立ちますか? 答えは、プラットフォーム/フレームワークの依存関係を処理する必要のあるクラスから、プラットフォーム/フレームワークの依存関係をほとんどまたはまったく持たないクラスにロジックを引き出すことで、テストが簡単になり、フレームワークが最小限になるということです。 明確にするために、私は一般的に、ユーザーインターフェイスをレンダリングするか、SQLテーブルにデータを格納するか、リモートサーバーに接続する必要があるクラスについて話します。 これがどのように機能するかを示すために、架空のAndroidアプリケーションの単純化された3層アーキテクチャを見てみましょう。

最初のクラスは、ユーザーインターフェイスを管理します。 簡単にするために、この目的でアクティビティを使用しましたが、通常、ユーザーインターフェイスクラスとして代わりにフラグメントを選択します。 いずれの場合も、どちらのクラスもAndroidプラットフォームと同様の緊密な結合を示します。

 public class CalculatorUserInterface extends Activity implements CalculatorContract.IUserInterface { private TextView display; private CalculatorContract.IControlLogic controlLogic; private final String INVALID_MESSAGE = "Invalid Expression."; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); controlLogic = new DependencyProvider().provideControlLogic(this); display = findViewById(R.id.textViewDisplay); Button evaluate = findViewById(R.id.buttonEvaluate); evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } }); //..bindings for the rest of the calculator buttons } @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } }

ご覧のとおり、アクティビティには2つのジョブがあります。1つは、 Androidアプリケーションの特定の機能のエントリポイントであるため、機能の他のコンポーネントの一種のコンテナとして機能します。 簡単に言うと、コンテナは、他のコンポーネントが最終的に参照(またはこの場合はプライベートメンバーフィールド)を介してテザーされる一種のルートクラスと考えることができます。 また、インフレーション、参照のバインド、およびXMLレイアウト(ユーザーインターフェイス)へのリスナーの追加も行います。

制御ロジックのテスト

Activityにバックエンドの具象クラスへの参照を持たせるのではなく、 CalculatorContract.IControlLogic. これがインターフェイスである理由については、次のセクションで説明します。 今のところ、そのインターフェイスの反対側にあるものはすべて、プレゼンターコントローラーのようなものであると想定されていることを理解してください。 このクラスはフロントエンドのアクティビティバックエンドの電卓の間の相互作用を制御するので、私はそれをCalculatorControlLogicと呼ぶことにしました。

 public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } } public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } }

このクラスの設計方法には、テストを容易にする微妙なことがたくさんあります。 まず、そのすべての参照は、Java標準ライブラリ、またはアプリケーション内で定義されているインターフェイスからのものです。 これは、フレームワークなしでこのクラスをテストするのは非常に簡単であり、 JVM上でローカルに実行できることを意味します。 もう1つの小さいが便利なヒントは、このクラスのさまざまな相互作用のすべてを単一の汎用handleInput(...)関数を介して呼び出すことができるということです。 これにより、このクラスのすべての動作をテストするための単一のエントリポイントが提供されます。

また、 evaluateExpression()関数では、バックエンドからOptional<String>型のクラスを返していることに注意してください。 通常、私は関数型プログラマーがモナドと呼んでいるもの、または私が好んで呼んでいる結果ラッパーを使用します。 使用する愚かな名前が何であれ、それは1回の関数呼び出しで複数の異なる状態を表すことができるオブジェクトです。 Optionalは、 null 、または提供されたジェネリック型の値のいずれかを表すことができる、より単純な構造です。 いずれにせよ、バックエンドに無効な式が与えられる可能性があるため、 ControlLogicクラスにバックエンド操作の結果を決定するための何らかの手段を提供したいと思います。 成功と失敗の両方を説明します。 この場合、 nullは失敗を表します。

以下は、 JUnitを使用して作成されたテストクラスの例と、専門用語のテストで偽物と呼ばれるクラスです。

 public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } } public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } }

ご覧のとおり、このテストスイートは非常に迅速に実行できるだけでなく、作成にそれほど時間もかかりませんでした。 いずれにせよ、このテストクラスの作成を非常に簡単にしたいくつかの微妙なことを見ていきます。

抽象化と依存性逆転の力

CalculatorControlLogicに適用された他の2つの重要な概念により、テストが簡単になりました。 まず、Javaでインターフェイス抽象クラス(まとめて抽象化と呼ばれる)を使用する利点が何か疑問に思ったことがある場合は、上記のコードは直接のデモンストレーションです。 テストするクラスは具象クラスではなく抽象化を参照するため、テストクラス内からユーザーインターフェイスバックエンドのテストダブルを作成することができました。 これらのテストダブルが適切なインターフェースを実装している限り、 CalculatorControlLogicはそれらが本物ではないことを気にすることはできません。

次に、 CalculatorControlLogicには、独自の依存関係を作成する代わりに、コンストラクターを介して依存関係が与えられています(はい、これは依存性注入の形式です)。 したがって、実稼働環境またはテスト環境で使用するときに書き直す必要はありません。これは、効率の向上につながります。

依存性注入制御の反転の一形態であり、平易な言葉で定義するのは難しい概念です。 依存性注入を使用する場合でも、サービスロケーターパターンを使用する場合でも、どちらもMartin Fowler(このようなトピックに関する私のお気に入りの教師)が「構成を使用から分離する原則」と説明していることを実現します。 これにより、テストが容易になり、相互に分離して構築するのが容易なクラスが作成されます。

計算ロジックのテスト

最後に、 ComputationLogicクラスに到達します。このクラスは、リモートサーバーへのアダプターやローカルデータベースなどのIOデバイスを概算することになっています。 単純な計算機にはどちらも必要ないため、指定した式を検証および評価するために必要なロジックをカプセル化するだけです。

 public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }

通常、特定のバックエンドライブラリへの緊密な結合があり、Androidに緊密に結合されたクラスと同様の問題が発生するため、このクラスについて言うことはあまりありません。 すぐにそのようなクラスをどうするかについて説明しますが、これはテストが非常に簡単なので、試してみるのもよいでしょう。

 public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }

テストするのが最も簡単なクラスは、単に何らかの値またはオブジェクトが与えられ、外部の依存関係を呼び出す必要なしに結果を返すことが期待されるクラスです。 いずれにせよ、ソフトウェアアーキテクチャのウィザードをいくら適用しても、プラットフォームやフレームワークから切り離せないクラスについて心配する必要があるという点があります。 幸いなことに、ソフトウェアアーキテクチャを使用して、次のような方法があります。最悪の場合、これらのクラスのテストが容易になり、せいぜい、一目でテストを実行できるほど簡単になります。

謙虚なオブジェクトとパッシブビュー

上記の2つの名前は、低レベルの依存関係と通信する必要があるオブジェクトが非常に単純化されているため、テストする必要がないというパターンを示しています。 このパターンは、Model-View-Presenterのバリエーションに関するMartinFowlerのブログで最初に紹介されました。 その後、Robert C. Martinの作品を通じて、特定のクラスをHumble Objectsとして扱うというアイデアが紹介されました。これは、このパターンをユーザーインターフェイスクラスに限定する必要がないことを意味します(ただし、Fowlerがこれまでに言ったことはありません)。そのような制限を意味します)。

このパターンをどのように呼んでも、理解するのはとても簡単です。ある意味では、これは実際には、クラスにSOCを厳密に適用した結果にすぎないと思います。 このパターンはバックエンドクラスにも適用されますが、ユーザーインターフェイスクラスを使用して、この原則を実際に示します。 分離は非常に簡単です。プラットフォームとフレームワークの依存関係と相互作用するクラスは、自分自身で考えません(したがって、モニカはHumblePassiveです)。 イベントが発生すると、彼らが行うのは、このイベントの詳細を、たまたまリッスンしているロジッククラスに転送することだけです。

 //from CalculatorActivity's onCreate() function: evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } });

論理クラスは、簡単にテストできるはずですが、ユーザーインターフェイスを非常にきめ細かく制御する役割を果たします。 user interfaceクラスで単一の汎用updateUserInterface(...)関数を呼び出して、一括更新の作業を任せるのではなく、 user interface (または他のそのようなクラス)は、簡単に実行できる小さな特定の関数を備えています。名前と実装:

 //Interface functions of CalculatorActivity: @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } //…

原則として、これら2つの例は、このパターンの実装方法を理解するのに十分なものになるはずです。 ロジックを持っているオブジェクトは緩く結合されており、厄介な依存関係に緊密に結合されているオブジェクトはほとんどロジックを欠いています。

Now, at the start of this subsection, I made the statement that these classes become arguably unnecessary to test, and it is important we look at both sides of this argument. In an absolute sense, it is impossible to achieve 100% test coverage by employing this pattern, unless you still write tests for such humble / passive classes. It is also worth noting that my decision to use a Calculator as an example App, means that I cannot escape having a gigantic mass of findViewById(...) calls present in the Activity. Giant masses of repetitive code are a common cause of typing errors, and in the absence of some Android UI testing frameworks, my only recourse for testing would be via deploying the feature to a device and manually testing each interaction. 痛い。

It is at this point that I will humbly say that I do not know if 100% code coverage is absolutely necessary. I do not know many developers who strive for absolute test coverage in production code, and I have never done so myself. One day I might, but I will reserve my opinions on this matter until I have the reference experiences to back them up. In any case, I would argue that applying this pattern will still ultimately make it simpler and easier to test tightly coupled classes; if for no reason other than they become simpler to write.

Another objection to this approach, was raised by a fellow programmer when I described this approach in another context. The objection was that the logic class (whether it be a Controller , Presenter , or even a ViewModel depending on how you use it), becomes a God class.

While I do not agree with that sentiment, I do agree that the end result of applying this pattern is that your Logic classes become larger than if you left more decisions up to your user interface class.

This has never been an issue for me as I treat each feature of my applications as self-contained components, as opposed to having one giant controller for managing multiple user interface screens. In any case, I think this argument holds reasonably true if you fail to apply SOC to your front end or back end components. Therefore, my advice is to apply SOC to your front end and back end components quite rigorously.

さらなる考慮事項

After all of this discussion on applying the principles of software architecture to reduce the necessity of using a wide-array of testing frameworks, improve the testability of classes in general, and a pattern which allows classes to be tested indirectly (at least to some degree), I am not actually here to tell you to stop using your preferred frameworks.

For those curious, I often use a library to generate mock classes for my Unit tests (for Java I prefer Mockito , but these days I mostly write Kotlin and prefer Mockk in that language), and JUnit is a framework which I use quite invariably. Since all of these options are coupled to languages as opposed to the Android platform, I can use them quite interchangeably across mobile and web application development. From time to time (if project requirements demand it), I will even use tools like Robolectric , MockWebServer , and in my five years of studying Android, I did begrudgingly use Espresso once.

My hope is that in reading this article, anyone who has experienced a similar degree of aversion to testing due to paralysis by jargon analysis , will come to see that getting started with testing really can be simple and framework minimal .

SmashingMagの詳細

  • Sliding In And Out Of Vue.js
  • Designing And Building A Progressive Web Application Without A Framework
  • CSSフレームワークまたはCSSグリッド:プロジェクトに何を使用する必要がありますか?
  • 真のクロスプラットフォームモバイル開発のためのGoogleのFlutterの使用