BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル レガシーコードのユニットテスティングにロギングの継ぎ目を利用する

レガシーコードのユニットテスティングにロギングの継ぎ目を利用する

レガシーコードに取り組むのは、最良の時でさえ難しいことがあります。しかし、あなたが熟知していないコードのリファクタリングや保守を行い、新しい機能を追加することになると、それは抗えないものになりえます。もしあなたが数年間、アジャイル手法で仕事をしていて、突然どこにもテストケースの無い大量のコードに直面したなら、それは特に難しいでしょう。

単純な答えはユニットテストを作成することです。では何ができるでしょうか?しかし、あなたが依存性を断ちテストをしやすくするためにコードの変更を開始すると、これはあなたを更なるトラブルに導くかもしれません。 あなたにとって本当に必要なものは、式を評価し、値を見付けて、現在、コードのどの分岐を実行しているかを判定する、控えめな方法なのです。

彼の本「Working Effectively with Legacy Code」で、Michael Feathers氏は、テストをしやすくするためにレガシーコードで継ぎ目を見付けることについて述べています。彼が規定する継ぎ目の定義は「あなたのプログラムにおいて編集せずに振る舞いを変えられる場所」です。継ぎ目の種類は言語によって異なりますが、彼はJavaのための関連する2つのカテゴリとして、クラスパスを変えることでテスティングのためのクラスに置換できる「リンクの継ぎ目」と、本来のクラスではなくサブクラスやモック実装によるコンストラクタやメソッドを呼び出す「オブジェクトの継ぎ目」を定義します。(更なる情報はMichael Feathers氏の「Working Effectively with Legacy Code」の4章を見てください)

私が見付けた別の有益な継ぎ目はロギングの継ぎ目です。この継ぎ目を利用して、あなたは振る舞いの変更を避けるだけでなく、クラスロジックの編集も必要とせずに、クラス全体の控えめなユニットテストを簡単に作成することができます。

継ぎ目を見付ける

上記のテストされていないコードのシナリオは、レガシーコードやテストのしやすさを考慮していないコードにおいては、かなりありふれたものです。例えば巨大なメソッドやクラス、複雑な分岐(大抵、同一メソッド内で多重にネストしている)や、コード内で直接、インスタンス化されるオブジェクトです。このような状況では(他と同様に)、普通のテスティングツールキットは役に立たないのです。1つの利点は多くのコードでロギングフレームワークを使用していることです。

テスティングの観点から、ロギングコードの本来の目的が何であったかは重要ではありません。それがインフォメーション、デバッギング、またはトレーシングのために利用されていたとしてもです。重要なのはテレメトリ情報を提供することです。このテレメトリ情報はユニットテストで再利用でき、自信を持ってコードをリファクタリングし、新しい機能を追加するために必要なセーフティネットを提供します。

さらに、新たなロギングコードを既存のコードに追加しても良いのです。それは既存の機能や振る舞いに影響しないでしょう。これは私たちが(式の結果、オブジェクト、インスタンス、値、および分岐の情報などの)ユニットテストで検証したい情報を報告するための新たなステートメントによってロギングを強化できることを意味します。このステップの更なる詳細は、また後で本記事の中で述べます。

テストケース

真のテスティングの流行は、ユニットテストファーストな開発です。

しかしその前に、私たちが取り組むレガシーコードがJavaで書かれていて、Apache foundationのlog4jロギングフレームワークを使用していると仮定しましょう。

テストケースはモックテスティングのパラダイムに従わなければなりません。

  1. テストされるクラスから使用されるモックオブジェクトを作成する。今後は「ClassUnderTest」と呼ぶ。
  2. テスティングを予想してモックオブジェクトを設定する
  3. テストされるクラスにモックオブジェクトを適用またはインジェクトする
  4. テストされるメソッドを実行する
  5. 予想どうりであるかを検証する

このケースで私たちは、モックオブジェクトをロギングアペンダの代わりにします。テストを実行する前の予想どうり、アペンダはモックオブジェクトと同様に機能するでしょう。それはテスティング中にクラスからの呼び出しを捕捉し、結果が予想どうりであるか、そして追加の呼び出しがあるかを報告するでしょう。

テストケースのコードを以下に示します。

	// create the testing appender
TestLoggingAppender appender = new TestLoggingAppender();

// add expectations
appender.addMessageToVerify("count=3");
appender.addMessageToVerify("finished processing");

// add the appender to those associated with the class under test
Logger log = LogManager.getLogger(ClassUnderTest.class);
log.setLevel(Level.INFO);
log.addAppender(appender);

// do usual test setup and configure the class under test
ClassUnderTest testme = new ClassUnderTest();
...

// run the method under test and verify the results
testme.myMethod();

// ensure all the expectations are met
assertTrue( appender.verify() );

ここでの唯一のトリックは、すべてのロギングレベルで忘れずにlog4jを使用することです。ロギング情報を受け取るためのアペンダを設定するときは、ロギングレベルを正確に設定しなければなりません。上記のコードで「log.setLevel(Level.INFO)」はこの設定を行ってます。

テストロギングアペンダ

最後に、テストロギングアペンダを開発します。私たちはすでにテストケースがアペンダのメソッドを呼び出すことを知っているので、クラスの構築を始められます。それらのメソッドは「addMessageToVerify」と「verify」です。

最初のメソッドを以下に示します。

	public void addMessageToVerify( String msg  ) {
called.put( msg, new State( msg, true, false ) );
}

呼び出されると、アペンダが管理しているメッセージのリストに、新しいエントリが追加されます。メッセージは同時に、格納したステートオブジェクトに、簡単にルックアップするためのキーとしても利用されます。ステートオブジェクトはメッセージを格納し、メッセージが予想どうりであるか、そしてそれが呼び出されたかを意味する2つのフラグを持つ単純なオブジェクトです。

次に「verify」です。このメソッドは内部ですでに作成されているメッセージのリストを使用します。「true」を返却すれば、予想したすべてのメッセージが実際に呼び出され、追加のメッセージがアペンダに記録されなかったことを意味します。そうでなければ「false」が返却されます。それは以下のようになります。

	public boolean verify() {
boolean result = true;
for( Iterator i=called.keySet().iterator(); i.hasNext(); ) {
State state = (State)called.get(i.next());
if( (!state.shouldCall() && state.wasCalled()) ||
(state.shouldCall() && !state.wasCalled()) )
result = false;
}
return result;
}

私たちはロギングフレームワークのメッセージについて話していますが、これはどのようになっているのでしょうか?私たちのテストロギングアペンダクラスでロギングメッセージを受け取るには、2つのことが必要です。

 1つ目に私たちのテストロギングアペンダをテストケースで有効にする必要があります。ロギングは「LogManager.getLogger(ClassUnderTest.class)」で有効になり、私たちのロギングアペンダは「log.addAppender(appender)」を呼び出すことで、ロギングメッセージを受信するアペンダのリストに追加されます。

2つ目に、私たちのテストロギングアペンダはConsoleAppenderクラスを継承する必要があります。これは私たちが今、実装しなければならない新しいメソッド「doAppend(LoggingEvent event)」を取り込みます。

	public synchronized void doAppend(LoggingEvent loggingEvent) {
String msg = loggingEvent.getMessage().toString();
if( called.containsKey(msg) )
((State)called.get(msg)).setCalled(true);
else
called.put( msg, new State( msg, false, true ) );
}

addMessageToVerifyメソッドと同じように、doAppendメソッドは内部でメッセージのリストを操作します。記録されるメッセージが存在すれば、doAppendメソッドはそれが呼び出されたことを意味するステータスフラグを更新します。メッセージが存在しなければ、予想しないメッセージが呼び出されたこと意味するフラグをセットし、新しいエントリがリストに追加され、これでテストロギングアペンダクラスの実装は完了します。

完全な実装はリスト1で見ることができます。

継ぎ目を活用する

最後のステップはClassUnderTestにロギングステートメントを追加することです。

上記の私たちのテストケースには以下の2行のコードがあり、それは何かが3回発生して、(おそらく例外なしで)処理が終了することを検証します。

	appender.addMessageToVerify("count=3");
appender.addMessageToVerify("finished processing");

上記で予想するテストが可能なメソッドを以下に示します。

	public void myMethod() {

// lots of code before the section we are interested in

try {
FileReader file = new FileReader("transactions.log");
BufferedReader reader = new BufferedReader(file);

String data;
int count = 0;
while( (data = reader.readLine())!=null ) {
process(data,count++);
}

LOG.info("count="+count);
LOG.info("finished processing");
} catch( Exception e ) {
// error processing
}

// more code afterwards
}

これは単純な例で、普通のレガシーコードで見られるようなものではありません。忘れてはならない重要なことは、このメソッド内でコードのセクションをテストするために、2つのロギングステートメントだけを追加すればよいということです。機能を変更せず、リファクタリングを実施する必要もありません。

ログとは何かについて考えるのに、以下のガイドラインを利用できます。

  • 静的なテキストはテスト範囲の開始と終了の境界を示すために記録できる。
  • 静的なテキストは分岐やループの中で位置を判定するために記録できる。
  • ループのカウンタ値は何回、ループの繰り返しを実行したかを判定するために記録できる。
  •  分岐の(「if」と「else」のどちらを実行するかを判定する)判定ロジックは記録できる。
  • あらゆる(クラススコープ、メソッドスコープまたはブロックスコープの)変数は記録できる。
  • 評価されるあらゆる式と結果は記録できる。

結論

ロギングフレームワークの助けによって、レガシーコードのためのユニットテストの作成を簡単なタスクにすることができます。あなたは既存のロギングを利用して、式を評価し、値を見付けて、現在、コードのどの分岐を実行しているかを判定する、新しいロギングステートメントを控えめに追加することができます。このテレメトリ情報はユニットテストで再利用でき、自信を持ってコードをリファクタリングし、新しい機能を追加するために必要なセーフティネットを提供します。今やあなたは、あなたのツールキットのテストロギングアペンダによって、自信を持って既存のレガシーコードをリファクタリングし、既存の機能を壊す心配をせずに新しい機能を追加することができるのです。

リスト1 - 完全なTestLoggingAppenderのリスト

public class TestLoggingAppender extends ConsoleAppender {

private Map called = new HashMap();

public boolean verify() {
boolean result = true;
for( Iterator i=called.keySet().iterator(); i.hasNext(); ) {
State state = (State)called.get(i.next());
if( (!state.shouldCall() && state.wasCalled()) ||
(state.shouldCall() && !state.wasCalled()) )
result = false;
}
return result;
}

public void printResults() {
for( Iterator i=called.keySet().iterator(); i.hasNext(); ) {
State state = (State)called.get(i.next());
StringBuffer sb = new StringBuffer();
sb.append("Logging message '").append(state.getMsg()).append("' ");
sb.append("was ").append( state.shouldCall()?"expected ":"not expected ");
sb.append( state.wasCalled()?"and called":"and was not called");
System.out.println(sb.toString());
}
}

public void addMessageToVerify( String msg ) {
called.put( msg, new State( msg, true, false ) );
}

public synchronized void doAppend(LoggingEvent loggingEvent) {
String msg = loggingEvent.getMessage().toString();
if( called.containsKey(msg) )
((State)called.get(msg)).setCalled(true);
else
called.put( msg, new State( msg, false, true ) );
}

class State {

private String msg;
private boolean shouldCall;
private boolean called;

public State( String msg, boolean shouldCall, boolean wasCalled ) {
this.msg = msg;
this.shouldCall = shouldCall;
this.called = wasCalled;
}

public String getMsg() {
return msg;
}

public boolean shouldCall() {
return shouldCall;
}

public boolean wasCalled() {
return called;
}

public void setCalled(boolean called) {
this.called = called;
}
}
}

著者について

Ian Roughleyはマサチューセッツ州のボストンを拠点に活動するスピーカー、ライターおよび独立コンサルタントです。彼は10年以上、fortune 10企業から新興企業までさまざまなクライアントに、アーキテクチャ、開発、プロセス改善およびメンタリングのサービスを提供しています。実践的な成果主義アプローチを中心に、彼はアジャイル開発手法によるプロセスと品質の改善だけでなく、オープンソースも提案しています。

原文はこちらです:http://www.infoq.com/articles/Utilizing-Logging
(このArticleは2006年8月1日にリリースされました)

この記事に星をつける

おすすめ度
スタイル

BT