テストコードのリファクタリング

 ユニットテストの再利用や継続的利用を行おうとすると、テストコードにも保守性等に優れた良い設計が求められるようになります。そこで出番が増えてくるのがテストコードのリファクタリングです。
 ただ現状、テストコードのリファクタリングはいくつか課題を抱えています。今回はその課題の1つである「リファクタリング前後でテストコードの振る舞いが変わっていないかチェックするテスト」(以下リファクタリング回帰テスト)の実現方法についてまとめます。

テストの回帰テスト

 まずリファクタリング回帰テストを真っ当に考えていきます。テストコードをテスト対象としてみると、一般的に以下の特徴が見えてきます。

  • SetupメソッドやMockオブジェクト等を通して、テスティングフレームワークから間接入力を受けます。
  • Assertionメソッド等を通して、テスティングフレームワークに対して間接出力を行っています。またMockオブジェクトを使う場合はMockオブジェクトなどに対して間接出力を行っています。

 こうした間接入力/間接出力を持つテスト対象には、一般的にMockオブジェクトやTest Spyを使用したテスト、あるいはロギングを活用した動的解析が有効です。特にシンプルなテストコードなら、Assertionメソッドに入力される全ての実行値と期待値の組が変化していないことをチェックするのが妥当なテストとなるでしょう。
 昔からたまにやっている方法ですが、google testのEXPECT_EQ()なら、以下のような感じになります。


1. ロギング機能を加えたAssertionメソッドを定義する。

#define EXPECT_EQ_WITH_LOGGING(expect, actual) \
EXPECT_EQ((expect), (actual));do{pLog->push((expect), (actual));} while(0)

#define LOGGING_SET(globalObject) do{pLog = &(globalObject);} while(0)


2. 変更前のテストコード、変更後のテストコードそれぞれで、テストで比較する実行値と期待値のロギングを行う

//変更前のTestMethod
TEST_F(TestHoge, test_Before)
{
	LOGGING_SET(LogBefore);

	//変更前の複雑なassertion
	...
	EXPECT_EQ_WITH_LOGGING(expect1, actual1);
	EXPECT_EQ_WITH_LOGGING(expect2, actual2);
	EXPECT_EQ_WITH_LOGGING(expect3, actual3);
	...

	LOGGING_SET(LogNull);
}

//変更後のTestMethod
TEST_F(TestHoge, test_After)
{
	LOGGING_SET(LogAfter);

	//変更後の複雑なassertion
	...
	EXPECT_EQ_WITH_LOGGING(expect1, actual1);
	EXPECT_EQ_WITH_LOGGING(expect2, actual2);
	EXPECT_EQ_WITH_LOGGING(expect3, actual3);
	...


	LOGGING_SET(LogNull);
}


3. ロギングした前後の実行値、期待値がすべて同じであるか検証する。両者に変化がないならリファクタリング成功とする。


 なおこの方法は当然ながらいくつか問題を抱えます。まずEXPECT_EQ_WITH_LOGGINGの置換等で、テストのない変更を行うことになります。あと手間が少しかかるので、対象も規模が大きいor複雑なテストケースに限ります。
 そうした事情があるので、この方法は活用の機会が結構限られています。私もテストコードをシンプルに書くように徹底してからはあまり使っていません。ただ手間の問題が大きいので、上記のような作業を今後テスティングフレームワークが正式にサポートしてくれると、状況は変わるかもしれません。

代替手段

 では代替手段はあるかという点ですが、製品コードのリファクタリングのテストと同じぐらい、軽快かつ柔軟性に優れた方法はない状態です。難しくしているのはやはり間接入力・間接出力が存在するのと、実行方法が特殊な点です。
 とりあえず「テストコードを対象としたリファクタリング回帰テスト」の手段として、ありがちなものをまとめていきます。

コードレビューによるチェック
  • コードレビューにより、テストコードの振る舞いが変わっていないかチェックします。
  • メリット
    • 手間の調整が容易で、非常に軽快にチェックできます。
  • デメリット
    • 効果が属人的かつ注意力依存で、信頼性が不安定です。そもそも常識的に考えて「目視レビューでリファクタリングをやりました」なんてのはかなり不吉な状態です。ただ楽な上、他のチェック手段との併用も容易なので、一般的にとられている方法だと思います。
グリーン状態を維持していることをチェック
  • リファクタリング前後で、テストがパスしている状態が維持されていることをもって、リファクタリング成功と判断します。
  • メリット
    • 非常に軽快に実施できます。軽快なテストコードの編集が要求されるTDD等では、この手段と前述のコードレビューの組み合わせが妥協策として用いられがちです。
  • デメリット
    • 「バグがあるコードでレッド状態になる」ことを検証していません。例えば、テストコード内のAssertionをすべて削除するような変更を見逃してしまいます。
自動リファクタリング機能まかせ
  • IDEやその他ツールのリファクタリング機能でテストコードをリファクタリングします。
  • メリット
    • それなりに安全かつ軽快に変更ができます。有望な手法の1つだと思います。
  • デメリット
    • この方法は、リファクタリングのやり方の信頼性をあげているだけであり、リファクタリング回帰テストを実施している訳ではありません。動的なふるまい起因のバグを生み出すリスクを持っています。また適用対象が限られるのもデメリットです。
テスティングフレームワーク&言語機能まかせ
  • ふるまいが等価であると保障された開発言語やフレームワークの構文を使ってリファクタリングします。例えばParameterized Testの構文を活用して、テストケースの入れ替え等を行います。
  • メリット・デメリット
    • IDEまかせと同じです。
コンパイラまかせ
  • リビルド後のバイナリデータに変化がないことをチェックします。なおその他等価性を厳格に保証する手段(例えば証明やモデルベースドなど)も、これと似たようなメリット・デメリットを持ちます。
  • メリット
    • ふるまいの等価性を確実にチェックできます。単なる改名やマクロ関数による置換等で有効です。
  • デメリット
テストカバレッジによるチェック
  • テストカバレッジが変化していないかチェックすることで、テストコードの変化を検出します。例えばコードカバレッジの変化をチェックします。
  • メリット
    • 軽快に実施できます。意図しないテストの欠落を検出できる場合が少なくありません。また単純に特定のテストカバレッジ達成のみを目的に設計されたテスト相手なら、(そもそも目的がおかしいという点はおいといて)テストカバレッジが変化していないことを見るだけで、単体で有効なテストになりえます。
  • デメリット
ミューテーション分析とミューテーションテスト
  • テスト対象にバグを混入させ、テストがRED状態になるかチェックします。それにより変更後もテストが欠陥を検出できることを確認します。
  • メリット
    • 前述の「グリーン状態を維持していることをチェック」と合わせて実施すると、テストのふるまいの等価性をチェックできるようになります(ただし、テストコードのインプットとなるテストケース仕様が、十分にミューテーションテストに展開されていなければなりません)。これはテストコードのテストとして正統な手段です。
  • デメリット
    • ミューテーションテストのテスト設計が必要ですが、その難易度がしばしば高いです。また、テストでは全網羅が困難とよく言われますが、ミューテーションテストでもそれは例外ではありません。テストケース仕様に対して網羅的にテストを用意することは大抵現実的でないでしょう。それを厳格に実施しようとすると、モデル駆動や証明の世界に踏み入れることになります。

現実的な妥協策

 では現実問題どうすればよいか、についてですが、長くなってしまったので次回に回したいと思います。

続き http://goyoki.hatenablog.com/entry/20110705/1309882074