最新モッキングツールと黒魔術
An example of power corrupting(力が腐敗する例)
最新のモッキングツールがレガシーコードの作業に及ぼすプラスの影響と、それらのツールを使用することによる潜在的なマイナスの影響。
2012年9月10日
Michael Feathers氏は著書「レガシーコード改善ガイド」(Working Effectively with Legacy Code)の中で、レガシーコードを自動テストのないコードと定義しています。2010年頃、私はJMockItに出会い、Javaのセマンティクスに反するように見える自動テストを記述できることを知りました。たとえば、テストの実行中に静的メソッドを置き換えることができます。Michael Feathers氏が提案する「古い」スタイルを使用する場合、インスタンスデリゲーターを導入して、記述したいテストを記述できるようにします。最新のモッキングツールを使用すれば、この手順をスキップできます。当初の反応は驚きでした。テストが難しい既存のコードを開き、何かをより速く、既存のコードをあまり変更せずに記述する方法として見たからです。
2011年後半にベルリンでクラスを教えていて、ユーザーグループでプレゼンテーションを行うように依頼されたときのことです(その講演はこちらで見ることができます)。そのビデオでは、JMockItを使用してレガシーコードをテストしようとして苦労している私を見ることができます。最終的には、基礎となるコードを実際に変更することなく、テストを行うことができました。ビデオでは見ることができませんが、翌日の授業では、結果のコードを取得し、より従来のレガシーリファクタリング手法を適用してから、JMockItベースのテストを書き直しました。結果は驚くべきもので、それ自体が物語っています。以下は、そのストーリーを完全に再現したものです。苦労した部分は、 hopefully、削除されています。
The Usual Suspects(よくある例)
まず、検討のためのコードを以下に示します。
public static BigDecimal convertFromTo(String fromCurrency, String toCurrency) { Map<String, String> symbolToName = currencySymbols(); if (!symbolToName.containsKey(fromCurrency)) throw new IllegalArgumentException(String.format( "Invalid from currency: %s", fromCurrency)); if (!symbolToName.containsKey(toCurrency)) throw new IllegalArgumentException(String.format( "Invalid to currency: %s", toCurrency)); String url = String.format("http://www.gocurrency.com/v2/dorate.php?inV=1&from=%s&to=%s&Calculate=Convert", toCurrency, fromCurrency); try { HttpClient httpclient = new DefaultHttpClient(); HttpGet httpget = new HttpGet(url); HttpResponse response = httpclient.execute(httpget); HttpEntity entity = response.getEntity(); StringBuffer result = new StringBuffer(); if (entity != null) { InputStream instream = entity.getContent(); InputStreamReader irs = new InputStreamReader(instream); BufferedReader br = new BufferedReader(irs); String l; while ((l = br.readLine()) != null) { result.append(l); } } String theWholeThing = result.toString(); int start = theWholeThing.lastIndexOf("<div id=\"converter_results\"><ul><li>"); String substring = result.substring(start); int startOfInterestingStuff = substring.indexOf("<b>") + 3; int endOfIntererestingStuff = substring.indexOf("</b>", startOfInterestingStuff); String interestingStuff = substring.substring( startOfInterestingStuff, endOfIntererestingStuff); String[] parts = interestingStuff.split("="); String value = parts[1].trim().split(" ")[0]; BigDecimal bottom = new BigDecimal(value); return bottom; } catch (Exception e) { throw new RuntimeException(e); } }
表向きは、このコードは、かなり分かりにくい方法で通貨変換情報をスクレイピングしていますが、このコードの真の目的は、貧弱なコーディングの選択とその選択が、コードの理解、テスト、および保守に及ぼす影響について議論することです。
Challenges to Testing(テストの課題)
メソッド自体を掘り下げる前に、まずこれが静的メソッドであることに注目してください。Java、C#、C ++(および他の多くの言語)では、静的メソッドは常にコンパイル時(またはリンク時)にバインドされます。これは、静的メソッドを呼び出すコードがそれらのメソッドに直接結合されていることを意味します。メソッドの選択と呼び出しメカニズムは、テストが制御する何かに転送できる環境をテストが簡単に設定できるようにするには早すぎます。Michael Feathers氏が「リンクシーム」と呼ぶものを使用して、実行時に別の静的メソッドを呼び出すことができます。Javaの場合、静的メソッドを持つクラスの別のバージョンがクラスパスで先に利用できるようにすることができます。これについては以下で詳しく説明します。
検証
このメソッドが最初に行うことは、基本的な検証を実行することです。メソッド引数として提供されるシンボルが実際に存在することを確認します。これを行うために、同じクラスの別のメソッドを呼び出します。
Map<String, String> symbolToName = currencySymbols(); if (!symbolToName.containsKey(fromCurrency)) throw new IllegalArgumentException(String.format( "Invalid from currency: %s", fromCurrency)); if (!symbolToName.containsKey(toCurrency)) throw new IllegalArgumentException(String.format( "Invalid to currency: %s", toCurrency));
必須
既知のシンボルを取得するメソッドは静的であり、同じクラスにあります。そのため、言語内でシミュレートしたり、検証を実行しないようにしたりすることは不可能です。常に検証を実行するのは良い考えのように思えるかもしれませんが、テストの負担が増加し、時間の経過とともに変化する可能性のある多くのものに依存するため、テストがやや脆弱になります。それは表面を引っ掻いているだけです。他のものをテストするときに常に検証をテストすると、検証のチェックがより徹底的になるように思えるかもしれませんが、実際には、そのようなテストは通常、より深いテストではなく、重複したテストを表しています。価値を高めることなく、バルクを追加します
私がしたいのは、解析が正しいことを確認することだけです。記述されているように、検証と解析は特定の順序で一緒に行う必要があります。その順序は論理的に思えます。入力するには、有効な通貨が必要です。しかし、それは必要な制約のように思えるかもしれませんが、解析について有効な通貨に依存するものはありません。解析するテキストがフォームに従っていることだけを気にしますが、そのフォームの一部に有効な通貨記号が含まれているわけではありません。そのため、検証のビジネスは不必要に次のステップにまで及んでいます。これは、不要な時間的結合の例だと思います。解析は時間的に検証に続きますが、したがって、解析を可能にするために検証が必要でなければならないということにはなりません。特定のテストはそのテスト内容を知っています。たとえば、解析したい入力が有効かどうかを知っています。そのため、解析前に検証を強制することは、コードの記述方法に付随するものです。解析には必須ではありません。
この最後の点は、よくある誤解であるため、強調する価値があります。私の経験では、ほとんどの人は解析前に検証が不可欠であると示唆するでしょう。ある意味ではそうです。有効な入力データを取得するには、実際のシステムに有効な通貨記号が必要です。ただし、テストの目的で、実際のシステムは必要ないため、検証に合格するという要件は実際には付随的です。検証が機能することを確認し、解析が機能することを確認した場合、記述したものが機能しない可能性はありますか?そうは思わないので、完全に統合された機能テストの必要性を感じません。異議を唱える人もいるかもしれません。実際のプロジェクトでは、議論が自動チェックを記述するだけよりも時間がかかるため、おそらく1つ記述します。ただし、物事をより小さな部分に分割することは、不可欠なスキルであり、習得するには何年もかかります。ただし、その点で私に同意しない場合でも、検証とは独立して解析を確認できれば、チェックが容易になることに同意していただければ幸いです。ワインバーグは、「一般システム思考入門」(An Introduction to General Systems Thinking)の中で、計算の二乗法則について説明しています。これは、「分割統治」としてよく知られています。このコードは明らかにそのようなルールに従っておらず、不要な付随的な結合により、物事が比較的困難になります。
currencySymbols()の呼び出し
前述のように、静的メソッドの呼び出しは問題ですが、より問題なのは、問題のメソッドが HttpClientを使用してシステムから呼び出しを行うことです。そのため、呼び出しにはインターネット接続が必要です。この呼び出しは必須であるか、少なくとも検証で使用されます。この静的メソッドの存在は、本質的にこのメソッドの他の部分に関連していますか、それとも付随的に関連していますか?「記述されているとおり」と「必要」を混同しないでください。
従来のオプション
これらの問題に対処するために、いくつかのことができます
- Sprout ClassまたはExtract Class を使用して、検証を独自のクラスに抽出します
- このクラスのすべてのメソッドを非静的で非公開にし、テストサブクラス を使用してテストを容易にします
- インスタンスデリゲーターを使用します。静的メソッドは残りますが、内部的にはオブジェクトのインスタンスメソッドを呼び出します
これらのソリューションは、基礎となる言語設計の問題に対処します(オーバーライドできない静的なものは、それらが設計された方法ですが、基本的に必要ありません。Smalltalk、JavaScript、Selfなどを考えてみてください)。いずれにせよ、どれを選択するかは、開始点、現在クラスに依存している既存のコードの量など、多くの要因によって異なります。
HttpClient
次に、コードはHttpClientを使用してWebページを読み取ります
HttpClient httpclient = new DefaultHttpClient(); HttpGet httpget = new HttpGet(url); HttpResponse response = httpclient.execute(httpget); HttpEntity entity = response.getEntity();
直接使用する
このコードはHttpClientを直接使用し、さらに* ** new ** *を使用して起動します。継承はJavaで最も高い形の結合であり、* ** new ** *の呼び出しが緊密に続きます。このコードが記述されているため、このクラスの使用を回避する言語定義の方法はありません。別のjarファイル(またはクラスパス)でテストすることにより、リンクシームを使用できます。ピンチになったら、そうすることを検討しますが、コードにアクセスして変更できる場合、または以下で説明するように、JMockIt (Java-オープンソース)、powermock(Java-オープンソース)、またはIsolator.Net(商用、.Net) のような第4世代モッキングツールにアクセスできる場合はそうしません。
依存性逆転の原則に違反する
この例では、ビジネスドメインは通貨変換ですが、ビジネスロジックはHttpClientを直接使用します。これは、依存性逆転の原則に違反しています。この場合、誰かが通貨変換を取得したい場合、コードが記述されているため、そうしようとすると、インターネットへの接続が必要なクラスへの直接のコンパイル時結合が導入されます。高レベルのものは低レベルの詳細に依存します。このコードは、この種の依存関係を逆転させる傾向のある代替手段ほどよく vieillissement ません。
この問題の修正
これに対処するためのオプションは、検証の場合と同じです。インスタンスメソッドを導入するか、クラスをスプラウトするなどです。ただし、より深い問題があります。このコードはインターネットへの接続に直接依存するだけでなく、これからわかるように、HTMLを返し、解析する必要があります。私たちは変換レートを気にしますが、HTMLの解析は気にしません。しかし、私たちが望むものに至るには、多くのテクノロジー層を通過する必要があり、そのすべてが終わった後、HTMLを処理する必要があります。
ファイルI/O
HttpClientは、最後まで読み取られる* ** InputStream ** *を利用可能にします
StringBuffer result = new StringBuffer(); if (entity != null) { InputStream instream = entity.getContent(); InputStreamReader irs = new InputStreamReader(instream); BufferedReader br = new BufferedReader(irs); String l; while ((l = br.readLine()) != null) { result.append(l); } } String theWholeThing = result.toString();
埋め込み
前のセクションと同様に、このコードはメソッドに直接埋め込まれているため、無視するのが難しくなります。
複雑さと重複
このコードはそれほど複雑ではありませんが、何をしているのかを知るには、それを読む必要があります。コミュニケーションを改善する1つの方法は、コミュニケーションの必要性を減らすことであり、コードについても同様のことが言えます。このコードが記述されているため、理解するにはコードを読む必要があります。これが独自のメソッドまたはクラスにあり、適切な名前が付けられている場合、パススルーするのが簡単な場合があります。私たちはコードを書くよりも読む傾向があるため、コードを読む必要性を減らすためにできることは、プロジェクトの存続期間にわたって時間をかける価値があります。
メソッドを完了するために必要
このコードは埋め込まれており、記述されているため、コードを実行するたびに実行する必要があります。これは、時間的結合のもう1つの例です。前のセクションで示唆したように、理解しやすいように編成されていれば、チェックしようとしているものがストリームの内容の読み取りに直接関係していない場合は、簡単に削除できる可能性があります。
解析
ストリームが文字列に変換されたので、結果を解析します。
int start = theWholeThing.lastIndexOf("<div id=\"converter_results\"><ul><li>"); String substring = result.substring(start); int startOfInterestingStuff = substring.indexOf("<b>") + 3; int endOfIntererestingStuff = substring.indexOf("</b>", startOfInterestingStuff); String interestingStuff = substring.substring( startOfInterestingStuff, endOfIntererestingStuff); String[] parts = interestingStuff.split("="); String value = parts[1].trim().split(" ")[0];
三番目の節は一番目と同じです...
この時点で、私はレコードをスキップしているように聞こえることに気付くかもしれません。このコードには、上記で述べたすべての問題があります。記述どおりに実行する必要があり、依存関係の逆転に違反し、理解するために読む必要があります。
単一責任原則違反
このメソッドは、単一責任原則に違反する典型的な例です。これは、それぞれ異なる時期に異なる変更理由を持つ、多くの異なることを行います。実際、このメソッドの元の形式では、1つのWebサイトにアクセスしていましたが、利用できなくなったため、必要な情報を取得するために別のサイトに変更する必要がありました。これは、SRPとDIPの違反に伴う問題を示唆するように、いくつかの場所で問題を引き起こしました。異なる場所(HttpStuff)にアクセスする必要があるだけでなく、異なるHTML(解析)が返され、異なるURL(再びHttpStuff)を使用する必要がありました。
Diving In(飛び込む)
最新のモッキングツールは、上記で述べたことのほとんどを、少なくとも表面上は無関係にします。最初に本番コードでこれらの問題を修正しようとするのではなく、自動化された単体テストを介してこのメソッドを実行してみましょう。
Getting setup - exercise the code(セットアップ - コードを実行する)
開始する1つの場所は、単にnull引数または「妥当な」値を使用して、問題のコードを実行してみることです。ドメインは通貨換算であり、メソッドは2つの通貨を受け入れるため、妥当な開始点のようです。
これは、コードを実行するための自動単体テストの始まりです。私の目標は、メソッドを最後まで実行することです。注意点は、このコードはライブインターネット接続で実行されますが、テストの記述中に接続をオフにして、テストの動作にライブインターネット接続が必要ないことを確認しました。
@Test public void returnExpectedConversion_v1() { CurrencyConversion.convertFromTo("USD", "EUR"); }
これはネットワークが有効になっている場合は機能しますが、そうでない場合は、コードはjava.net.UnknownHostExceptionを生成します。ただし、実際に発生する場所は、CurrencyConversion.currencySymbolsメソッドであり、私たちが気にするメソッドではありません。古いツールを使用すると、これは少し手間がかかりますが、この記事で選択したツールであるJMockItではそうではありません。
Getting past validation(検証を乗り越える)
これは、最初の例外を回避するテストの2番目のバージョンです。
@Test public void returnExpectedConversion_v2() { new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } }; CurrencyConversion.convertFromTo("USD", "EUR"); } private Map<String, String> mapFrom(String... keyValuePairs) { Map<String, String> result = new ConcurrentHashMap<String, String>(); for (int i = 0; i < keyValuePairs.length; ++i) result.put(keyValuePairs[i], keyValuePairs[i]); return result; }
これを実行すると、同じ例外UnknownHostExceptionがスローされますが、例外は呼び出されたメソッドではなく、テスト中のメソッドにあります。それは改善です。これにより、コードは検証をパスできますが、どのようにでしょうか?
new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } };
NonStrictExpectationsを使用して匿名内部クラスを作成していることに注意してください。この形式は、JMockItにCurrencyConversionクラスで何かを行うように指示します(この場合は静的メソッドを置き換えます)。内部の{}内のコードは、標準のJavaインスタンス初期化子です。そのインスタンス初期化子で実行されるコードは、実行されるメソッドであるcurrencySymbolsを置き換えるようにJMockItに指示します。これは、クラスの部分的なモッキングの例です。クラスのメソッドの1つを置き換えるため、呼び出されるたびに、継承されたフィールド「result」に割り当てられたものが返されます。
これには、いくつかのブラックマジックが含まれます。JMockItは、Javaバイトコードマジックを実行しており、このコードはJMockIt DSLを使用してこれを実現しています。これを機能させるには、JMockIt jarファイルをクラスパスに追加する必要があります。JUnitのjarファイルの前にリストされていることを確認すれば、それで十分です。JMockItは、MANIFEST.MFファイルに登録されているJavaAgentを使用して、これをすべて自動的に行います。
Dealing with the client(クライアントへの対処)
これで、コードはWebページを読み取ろうとする部分で失敗しています。
HttpClient httpclient = new DefaultHttpClient(); HttpGet httpget = new HttpGet(url); HttpResponse response = httpclient.execute(httpget); HttpEntity entity = response.getEntity();
このコードはメソッドに埋め込まれているため少し複雑ですが、この問題を回避できます。ただし、そのためにはもう少し作業が必要です。
- 最初の2行はnewを呼び出します。new演算子のこれらの使用を制御下のクラスを返すようにする必要があります。
- 次の行は、HttpClientでexecuteメソッドを呼び出すため、それを制御する必要があります。
- 最後の行は、HttpResponseでgetEntityメソッドを呼び出します。これは前の行の戻り値であったため、全体的にかなり複雑です。
これは、これら3つの問題を一度に解決する方法の1つです。
new NonStrictExpectations() { DefaultHttpClient httpclient; HttpResponse response; HttpEntity entity; { httpclient.execute((HttpUriRequest) any); result = response; response.getEntity(); result = entity; entity.getContent(); result = bais; } };
このNonStrictExpectationsの使用では、最初の行にパラメーターが渡されません。つまり、静的なものではなく、何らかの方法でインスタンスを操作しています。この匿名内部クラスには、DefaultHttpClient、HttpResponse、HttpEntityの3つのフィールドがあります。これらのクラスは、このテスト専用のJavaクラスローダーで完全に置き換えられます。これは、たとえば、new DefaultHttpClientを呼び出すと、HttpClient jarにあるバージョンではなく、JMockItによって作成されたクラスのインスタンスが返されることを意味します。
これは、私が動的リンクシームと呼ぶものの例です。これは、Michael Feathersが彼の著書で説明しているリンクシームのようなもので、通常はビルドスクリプト/ファイルマジックで実現されます。ただし、ビルドスクリプトを使用するのと異なり、これはライブラリを使用しているため、言語の外部ではなく、言語「内」のJavaコードで使用できます。
このコードは、これら3つのクラスを置き換えますが、何で置き換えますか?
- HttpClient.executeメソッドは常にresponseを返します。これは、JMockItによって作成されたHttpResponseサブクラスのインスタンスです。
- HttpResponse.getEntityメソッドは常にentityを返します。これは、JMockItによって作成されたHttpEntityサブクラスのインスタンスです。
- HttpEntity.getContentメソッドは常に「bais」を返します。これは次のセクションで説明します。
これは強力なものです。実際、私には個人的な経験則があります。何かがクールだと思うなら、それは実際の開発には適していないかもしれません。JMockItは、私の「クールなスパイダーセンス」を overdriveに刺激します。
Handling File I/O(ファイルI/Oの処理)
基礎となるコードはストリームの内容を必要とします。これを作成するために、テストは標準のJavaマジックを使用してメモリ内ストリームを作成します。
final ByteArrayInputStream bais = new ByteArrayInputStream( "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>" .getBytes());
これは、文字列からメモリ内ByteArrayInputStreamを作成します。文字列に何を入れるべきか、どうやって知りましたか?基礎となるコードをリバースエンジニアリングする必要がありました。それでも、これによりコードが実行されます。
Putting it together(まとめ)
これまでのように分割されておらず、単一のメソッドとしてのテストを以下に示します。
@Test public void returnExpectedConversion_v3() throws Exception { final ByteArrayInputStream bais = new ByteArrayInputStream( "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>" .getBytes()); new NonStrictExpectations() { DefaultHttpClient httpclient; HttpResponse response; HttpEntity entity; { httpclient.execute((HttpUriRequest) any); result = response; response.getEntity(); result = entity; entity.getContent(); result = bais; } }; new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } }; BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); }
一方では、これは非常に印象的です。コードに触れることなく、エンドツーエンドでコードを実行することができました。このクラスを使用する何かを記述したい場合は、その方法がわかりました。多くの場合、コードに触れる前に、変更を加えた後に何かを壊したかどうかを知るために、テストを行う必要があります。これは、特性テストと呼ばれます。このような記述の悪いコードは、通常、非常に困難になります。それでも難しいですが、少なくとも本番コードに触れることなく実行できます。コードの周りに特性テストがあると、リファクタリングがより安全になります。
これで完了ですね?
違います。
Going Old School(昔ながらの方法)
We've addressed the symptom, not the cause(症状に対処したが、原因には対処していない)
JMockItは、ほぼブラックマジックを実現することを可能にしましたが、もっとうまくできますか?試してみると、費やした時間は努力する価値がありますか?このセクションでは、同じメソッドから始めて、コードにいわゆるレガシーリファクタリングをいくつか行い、より従来のツール、この場合は手動で作成されたテストダブルを使用してテストできるようにします。次に、比較、観察を行い、何が可能になるかを見ていきます。
Introduce instance delegator(インスタンスデリゲーターを導入する)
静的メソッドの典型的な問題は、オーバーライドできないことです。これを修正するために、クラスですべてのインスタンスメソッドを使用するようにすることができます。ただし、この例では、下位互換性を維持する必要があるため、静的メソッドをそのままにしておく必要があると仮定しましょう(私の経験では、これは不自然ではありません)。
テストファーストでこれを行おうとするかもしれませんが、実際には既存のJMockItテストがあるため、必要な変更を加えるだけです。これを行うには、CurrencyConversionをv2という名前が追加された新しいパッケージにコピーします(このブログのソースはソースコードから生成されるため、元のバージョンを保持する必要があります)。
インスタンスデリゲーターを導入するには
- クラスの静的インスタンスを導入します。
- 静的メソッドをインスタンスメソッドにコピーします(静的メソッドとインスタンスメソッドの名前とシグネチャが同じにできないため、新しいメソッド名を作成する必要があります)。
- 静的メソッドを変更して、内部静的インスタンスでインスタンスメソッドを呼び出します。
これを行う方法の1つは次のとおりです(内部インスタンスの遅延初期化は意図的なものであり、スレッドの問題が懸念される場合は、二重チェックロックを使用するか、メソッドを同期させることができます)。
private static CurrencyConversion instance; private static CurrencyConversion getInstance() { if (instance == null) { instance = new CurrencyConversion(); } return instance; } public static BigDecimal convertFromTo(String fromCurrency, String toCurrency) { return getInstance().convert(fromCurrency, toCurrency); } public static Map<String, String> currencySymbols() { return getInstance().getAllCurrencySymbols(); }
Extract a few methods(いくつかのメソッドを抽出する)
メソッドを抽出する機会がいくつかあるため、いくつかのメソッド抽出後のv2 / CurrencyConversionのバージョンを以下に示します。
public BigDecimal convert(String fromCurrency, String toCurrency) { validateCurrencies(fromCurrency, toCurrency); try { String result = getPage(fromCurrency, toCurrency); String value = extractToValue(result); return new BigDecimal(value); } catch (Exception e) { throw new RuntimeException(e); } } protected void validateCurrencies(String fromCurrency, String toCurrency) { Map<String, String> symbolToName = currencySymbols(); if (!symbolToName.containsKey(fromCurrency)) throw new IllegalArgumentException(String.format( "Invalid from currency: %s", fromCurrency)); if (!symbolToName.containsKey(toCurrency)) throw new IllegalArgumentException(String.format( "Invalid to currency: %s", toCurrency)); } protected String extractToValue(String result) { String theWholeThing = result; int start = theWholeThing.lastIndexOf("<div id=\"converter_results\"><ul><li>"); String substring = result.substring(start); int startOfInterestingStuff = substring.indexOf("<b>") + 3; int endOfIntererestingStuff = substring.indexOf("</b>", startOfInterestingStuff); String interestingStuff = substring.substring( startOfInterestingStuff, endOfIntererestingStuff); String[] parts = interestingStuff.split("="); return parts[1].trim().split(" ")[0]; } protected String getPage(String fromCurrency, String toCurrency) throws URISyntaxException, IOException, HttpException { String url = String.format("http://www.gocurrency.com/v2/dorate.php?inV=1&from=%s&to=%s&Calculate=Convert", toCurrency, fromCurrency); HttpClient httpclient = new DefaultHttpClient(); HttpGet httpget = new HttpGet(url); HttpResponse response = httpclient.execute(httpget); HttpEntity entity = response.getEntity(); StringBuffer result = new StringBuffer(); if (entity != null) { InputStream instream = entity.getContent(); InputStreamReader irs = new InputStreamReader(instream); BufferedReader br = new BufferedReader(irs); String l; while ((l = br.readLine()) != null) { result.append(l); } } return result.toString(); }
このクラス(v2パッケージにあるクラス)を対象とした最後のテストのバージョンを以下に示します。このテストは合格します。
@Test public void returnExpectedConversion_v4() throws Exception { final ByteArrayInputStream bais = new ByteArrayInputStream( "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>" .getBytes()); new NonStrictExpectations() { DefaultHttpClient httpclient; HttpResponse response; HttpEntity entity; { httpclient.execute((HttpUriRequest) any); result = response; response.getEntity(); result = entity; entity.getContent(); result = bais; } }; new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } }; BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); }
Testing subclass(サブクラスのテスト)
次に、JMockItの代わりに手書きのテストダブルを使用して、このテストを書き直します。すべての中間手順を表示するのではなく、最終結果のみを表示します。
class CurrencyConversion2_testingSubclass extends CurrencyConversion { @Override public void validateCurrencies(String fromCurrency, String toCurrency) { } @Override public Map<String, String> getAllCurrencySymbols() { return mapFrom("USD", "EUR"); } @Override public String getPage(String fromCurrency, String toCurrency) { return "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>"; } } @Test public void returnExpectedConversion_v5() throws Exception { CurrencyConversion original = CurrencyConversion.reset(new CurrencyConversion2_testingSubclass()); BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); CurrencyConversion.reset(original); }
このテストは、多くの作業を手動で行うJMockItテストと同じ結果を生成します。
注意:これを可能にするには、新しく作成されたシングルトンクラスを設定およびリセットできるようにする必要があります。
public static CurrencyConversion reset(CurrencyConversion other) { CurrencyConversion original = instance; instance = other; return original; }
Observations(考察)
Actual amount of time to do this(これを実行する実際の時間)
オーバーライド可能な静的シングルトンと、抽出されたメソッドをいくつか使用してインスタンスデリゲーターを導入するというこの手法は、かなり多くの作業のように思えるかもしれません。実際には、この種の変更は迅速です。どれくらい速いですか?この例では、IntelliJを使用して3分かかりました。Eclipseでも同じ時間がかかっています。viでは、おそらく1分です(まあ、そうではないかもしれませんが、Eclipse、IntelliJ、さらにはVisual Studioでもviプラグインを使用しています)。いずれにせよ、一度練習すれば、すぐにできます。クラスが最初からすべての静的メソッドを使用していなければ、これを much of this を回避できたかもしれませんが、それは一般的な問題であるため、その対処方法を知ることは、知っておくべき一般的なテクニックです。
Yes, but...(しかし...)
提起される一般的な懸念は、抽出されたすべてのメソッドを保護することについてです。テスト可能性は設計よりも重要だと考えているため、それは私を悩ませません。それは実際には誤った二分法ですが、物議を醸すように聞こえるので、とにかく私はそれを言うのが好きです。実際、これらの「保護されたメソッド」の多くは、個々のクラスを保証するのに十分複雑です。次に、依存関係の逆転を導入すると、突然、制御下にある依存オブジェクトを配線し、オーバーライド可能なメソッドを使用して、この全体的なフローの個々の部分をチェックすることがスナップになります。分割して征服する。
First test versus second test(最初のテストと2番目のテスト)
比較のために、2つのテストをもう一度示します。
JMockItを使用する
@Test public void returnExpectedConversion_v4() throws Exception { final ByteArrayInputStream bais = new ByteArrayInputStream( "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>" .getBytes()); new NonStrictExpectations() { DefaultHttpClient httpclient; HttpResponse response; HttpEntity entity; { httpclient.execute((HttpUriRequest) any); result = response; response.getEntity(); result = entity; entity.getContent(); result = bais; } }; new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } }; BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); }
手動で作成されたモックとリファクタリングを使用する
class CurrencyConversion2_testingSubclass extends CurrencyConversion { @Override public void validateCurrencies(String fromCurrency, String toCurrency) { } @Override public Map<String, String> getAllCurrencySymbols() { return mapFrom("USD", "EUR"); } @Override public String getPage(String fromCurrency, String toCurrency) { return "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>"; } } @Test public void returnExpectedConversion_v5() throws Exception { CurrencyConversion original = CurrencyConversion.reset(new CurrencyConversion2_testingSubclass()); BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); CurrencyConversion.reset(original); }
テストコードをサポートする必要がある場合、どちらのバージョンを選択しますか?ちょっと待って、まだ答えないでください。
First test still passes(最初のテストはまだ合格する)
プロダクションコードに何度か変更を加えたにもかかわらず、JMockItテストが依然としてパスすることに改めて注目したいと思います。これは私にとって非常に興味深いことです。実際、JMockItがクラスローダーをどのように操作するかという観点から考えると、理にかなっています。それでも、やはり非常にクールです。
What if we try with JMockIt again(JMockItでもう一度試してみるとどうなるか)
ここで立ち止まる必要はありません。プロダクションコードに加えられた変更を考慮して、JMockItテストを書き直してみるとどうなるでしょうか?
@Test public void returnExpectedConversion_final() throws Exception { new NonStrictExpectations(CurrencyConversion.class) { CurrencyConversion c; { c.validateCurrencies(anyString, anyString); c.getPage(anyString, anyString); result = "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>"; } }; BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); }
さて、あなたはどちらをメンテナンスしたいですか?
少しのリファクタリングを行うことで、最終的なJMockItテストが大幅に改善されることに注目してください。実際、このバージョンのテストを作成するために必要なリファクタリングの量は、私が実際に行ったリファクタリングの量よりも少なくなっています。メソッドを抽出するだけで、JMockItテストを大幅に改善できたはずです。ただし、JMockItはこれを強制しなかったことに注意してください。モッキングツールを使用しない場合、またはMockitoのようなツールを使用する場合は、このクラスをテスト対象にするために、ある程度のリファクタリングを行う必要がありました。
Closing the Feedback Loop(フィードバックループを閉じる)
人々がオープンループで作業するとどうなるでしょうか?つまり、コードを記述してから、JMockItのようなツールを使用することで、後からテストを記述することができ、不適切な決定による苦しみを味わう必要がなくなります。私がこう尋ねるのは、この例がまさにそのような状況を示しているからです。元のコードは乱雑ですが、JMockItのおかげで、コードを実行するための特性テストを記述することができました。それは素晴らしいことです。しかし、それは元の問題に対処することを強制するものではありませんでした。ある意味では、混乱を修正しなければならないことに伴う苦痛を回避することもできました。強力なツールを取り除くと、テストを記述するために混乱を解消する必要があります。コードをクリーンアップしたとき、それはまだ乱雑ですが、以前よりも優れており、リファクタリングされたコードは他の改善も示唆しています。また、次回誰かがコードを記述するときに何かを学び、もしかしたら、同じ間違いを繰り返さないかもしれません、少なくともそれほど頻繁には繰り返さないかもしれません。
直接関係はありませんが、このフィードバックの概念に関連する素晴らしい記事があります:ジョシュア・フォアによるマインドゲーマーの秘密。この記事で議論されていることの1つは、スキルを向上させるために必要なものです:失敗。つまり、私たちは間違いを犯し、それからその間違いから学ぶ必要があります。ツールが私たちに間違いから学ばせないようにするとどうなるでしょうか?私は、強力なツールが失敗に伴う苦痛を軽減または解消し、停滞につながるのではないかと懸念しています。常に新しい愚かな間違いを犯そうと努力していなければ、私たちは実際には学んでいないのではないでしょうか?
Conclusions(結論)
JMockItのようなツールを使うべきではないと考えているのでしょうか?いいえ。JMockItは素晴らしいツールであり、そのパワーが歓迎され、必要とされる状況に私は遭遇してきました。しかし、新しい開発中の使用についてはどうでしょうか?私は2つの考えを持っています。一方で、作業中は最も強力なツールを使用したいと考えています。私のお気に入りのモッキングライブラリはMockitoですが、JMockItほど強力ではありません。一方、JMockItは、少しばかりの混乱を残したままでも目的を達成できるため、効果的に使用するにはより多くの規律が必要です。
おそらくこれは見当違いでしょう。私は一般的にテスト駆動開発を実践しています。JMockItを使用して実際のプロジェクトでTDDを実践した経験はありませんが、Mockitoでは実践しており、使いすぎることに問題はないと思います。私はまだ、時々あまりにも器用/賢すぎるという問題を抱えていますが、ツールを責めることはできません。他のチームがMockitoとMoq(C#におけるMockitoと同等のもの)を使用し、それらを過剰に使用しているのを見たことがあります。しかし、私がそれを見たとき、それらのチームは多くの開発の後でテストを書いていました - それはツールのせいではありません。誰かが小さな増分で作業し、途中で(できれば最初に)テストを記述する規律を持っているなら、JMockItの力は問題を引き起こさないでしょう。一方、JMockItの力が問題になる場合は、おそらくもっと深刻な問題があるでしょう。
いずれにせよ、この種のツールを試してみて、そのような力を持つことが学習に役立つか、それとも妨げになるかを自分で学んでみることをお勧めします。
主な改訂
2012年9月10日:初版
2012年5月25日:初期草稿