こんにちは横部です。
システムの構成変更やバージョンアップによって、今まで正常に動いていたテストコードが全部エラーを吐き出し始めた・・・なんてことはよくあります。
ちょっとした変更であれば原因究明と修正は容易です。
しかし、変更が大規模になるとそういうわけにも行きません。
その結果、今は動いているからとシステムの開発が優先されて、テストコードの修正はおざなりになり、エラーが出ることを許容されたテストコード群は放置され、そのまま技術負債に・・・
という流れを避けるために、全てのエラー内容の原因究明と修正に乗り出すべきです。
概要
例えば、
・システムのバージョンアップを行った
・今までは各開発者の手元で実行する単体テストが共通のデータベース(共有 DB)を参照していた。それを使わず、個別にDBを準備して使うようになった
・テスト環境のディレクトリ構成を開発環境固有の構成から、本番環境準拠の構成に変更した
と言うケースを想定します。
種類 | 前 | 後 |
---|---|---|
システム | 旧バージョン | 新バージョン |
DBの向き先 | 共有DB | 個別DB |
ディレクトリ構成 | 開発環境固有 | 本番環境準拠 |
構成変更が大規模になる程、変更点からエラー原因の究明とテストコードの修正を行うことはほぼ不可能になると思います。
そのため、テストコードの修正には、まず膨大なエラーをリストアップして分類することが重要です。
膨大なエラーも、共通の原因を特定できれば、修正は比較的容易になるからです。
具体的には、エラー内容とスタックトレースから、エラーの発生した箇所のコードを解析して、共通の原因を究明する作業を行います。
エラーの原因
調査の結果、大まかに以下のような共通の原因があることがわかりました。
No | エラー原因 | 関連 |
---|---|---|
1 | ファイルが読み込めない | ディレクトリ構成 |
2 | 必要なデータがない | DBの向き先 |
3 | コードの仕様変更 | システム |
4 | テストの挙動変更 | システム |
1はファイル移動があったりクラスパスの変更で読めなくなっていたケースなので、正しいディレクトリにファイルを移動することやクラスパスの追加で対応できます。
2も大抵はDBの向き先の変更で、あるはずのテーブルやデータがなくなっていたことが問題になっていたケースなので、テストコード内で足りないテーブルを作成したり、データをインサートすることで対応できます。
問題となるのは、3と4です。
3はコードの仕様変更によるエラーなので、場合によってはテストコードだけでなくテスト対象のコード修正も必要になります。
また仕様変更はバージョンアップの内容次第ですので、ここでは割愛します。
4はテストコードの挙動変更によるエラーなので、テストコードの改善が必須となります。
今回はテストの挙動変更によって実行順序が変わりテスト失敗となったケースについて、その原因と対処方法を解説していきたいと思います。
実行順序変更による期待値エラー
実行順序変更によって起こりうるエラーとその対処方法について、以下にあげましたエラーの一例から、その解決方法を説明していきます。
今回、JUnitテストではモックアップフレームワークとしてMockitoを用いています。
これを使うとコードをモック化して、返り値を任意で設定できたり、期待値の検証が出来たりして便利です。
例えば、verifyを用いると、SubSampleクラスのdoSampleが二回呼び出されたのか検証することが出来ます。
1 2 |
private SubSample sub = Mockito.mock(SubSample.class); Mockito.verify(sub, Mockito.times(2)).doSample(); |
verifyの仕様などについてはこちらが参考になります。
http://mockito.googlecode.com/hg-history/1.5/javadoc/org/mockito/Mockito.html
例えば、以下のようなMainSampleクラスをテストするコードを書いてみます。
1 2 3 4 5 6 7 8 9 10 11 |
public class MainSample { private SubSample sub; public static void do1() { sub.doSample(); } public static void do2() { sub.doSample(); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import org.junit.Test; import org.mockito.Mockito; public class MainSampleTest { private MainSample main = new MainSample(); private SubSample sub = Mockito.mock(SubSample.class); @Test public void testDo1() { // テスト main.do1(); // 検証 Mockito.verify(sub, Mockito.times(1)).doSample(); } @Test public void testDo2() { // テスト main.do2(); // 検証 Mockito.verify(sub, Mockito.times(2)).doSample(); } } |
@TestはTestアノテーションと呼ばれ、これをつけることでテストメソッドであると指定できます。
JUnitのアノテーションは他にも存在し、それぞれの仕様については以下のページを参照してください。
http://junit.org/javadoc/latest/org/junit/package-summary.html
今回の場合、testDo1とtestDo2がテストメソッドとして指定されており、MainSampleTestを実行すると、testDo1とtestDo2が実行されます。
ここで注意しなければいけないのは、初期化をしない限り、モックは他テストメソッドでの実行結果も受け継ぐということです。
testDo1では、main.do1実行後に、sub.doSampleが1回呼び出されたか確認をしています。
testDo2では、main.do2実行後に、sub.doSampleが2回呼び出されたか確認をしています。
main.do1もmain.do2もsub.doSampleを呼び出す為、testDo1,testDo2の順番どおりにテストが実施されれば、実行回数は引き継がれるので、テストは成功するでしょう。
しかし構成変更などによって実行順序が逆になった場合、testDo2ではsub.doSampleが1回目の実行となり、testDo1ではsub.doSampleが2回目の実行となる為、エラーと判定されてしまいます。
1 2 3 4 5 6 |
org.mockito.exceptions.verification.TooLittleActualInvocations: subSample.doSample(); Wanted 2 time: -> at MainSampleTest.testDo2(MainSampleTest.java:18) But was 1 times. -> at MainSample.do2(MainSample.java:9) |
それを防ぐ為に、TestSuiteを使うなどして、実行するテストメソッドの順序を固定する方法も一応ありますが、
http://www.techscore.com/tech/Java/Others/JUnit/4-2/
*JUnit4以降では動作が保証できません。
そうすると仕様変更などで、main.do1が検証不要となった場合、testDo1を削除することになった時、testDo2の期待値も書き直す必要があります。
もし、それが100のテストメソッドに影響するものだった場合・・・修正は現実的ではありません。
よって、モックは各テストメソッド間で影響を受けないよう毎回初期化をするべきです。
1 |
Mockito.reset(sub); |
その場合、テストメソッド全てに初期化する処理を加える方法もありますが、先ほど述べたようにテストメソッドの数が膨大な場合、保守的な面から見ても、あまり現実的ではありません。
なのでテストメソッド間で毎回実行したい処理を加える場合は、@Afterや@Beforeのアノテーションを利用しましょう。
例えば、@Afterをつけられたメソッドは、テストメソッド毎に必ず事後実行されます。
そのメソッドに初期化する修正を加えることで、全てのテストメソッドの最後に処理を実行するのと同じ事を実現できます。
1 2 3 4 5 |
@After public void doAfter() { // 初期化 Mockito.reset(sub); } |
こうすることで、呼び出し回数はテストメソッド毎に初期化されますので、各テストメソッドの影響を排除することが可能になり、テストしたいメソッド内で呼ばれるべき正確な期待値を設定することが出来ます。
同様に@Beforeを用いれば、テストメソッド毎に必ず事前実行されます。
この場合は、全てのテストメソッドの最初に処理を実行するのと同じ事を実現できますので、それぞれ実行タイミングを見極めて使い分けるのが望ましいです。
1 2 3 4 5 |
@Before public void doBefore() { // 初期化 Mockito.reset(sub); } |
実行順序変更によるDBエラー
同じ事はメソッドの呼び出し回数の検証だけでなく、DB操作があるクラスのテストでもいえます。
例えば、あるテーブルの処理を行うクラスのテストを行う時、初期状態としてデータが存在しないこと前提で、
・第一引数をキーにデータを登録するaddメソッド
・第一引数をキーにデータを更新するupdateメソッド
・第一引数をキーにデータを削除するdeleteメソッド
のテストを以下のように書いていた場合、
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
@Test public void testAdd() { // データが存在しないことを確認 assertNull(check(1)); // テスト add(1, "add"); // データが登録されていることを確認 assertEquals(check(1), "add"); } @Test public void testUpdate() { // データが登録されていることを確認 assertEquals(check(1), "add"); // テスト update(1, "updated"); // データが更新されていることを確認 assertEquals(check(1), "updated"); } @Test public void testDelete() { // データが存在することを確認 assertNotNull(check(1)); // テスト delete(1); // データが存在しないことを確認 assertNull(check(1)); } |
構成変更等で順序変更が発生し、testUpdateやtestDeleteがtestAddより先に実行されてしまうと、addで登録されるはずの同じキーのデータが存在しないために、正常に処理されずテスト失敗と判断されてしまいます。
1 2 |
java.lang.AssertionError: expected:<add> but was:<> java.lang.AssertionError: expected not null, but was: null |
この場合も実行順序を固定するような変更をするのではなく、各メソッド単体でテストが実行できるようなデータを準備しておくのが望ましいです。
例えば、addで登録する予定のデータとは別に、予めupdateとdeleteで使う予定のデータを事前に用意しておき、それを使用することで、メソッドの挙動を個別に確認できるようにしておく等です。
これを実現する為に、先ほど紹介した@Afterと@Beforeが利用できます。
例えば以下のコードでは、@Beforeを用いてデータを登録するinsertTestDataを呼び出す事で、updateとdelete用のデータをそれぞれ用意して、この問題を解決しています。
また@Afterを用いて、データを削除するdeleteTestDataを呼び出す事で、テストメソッドの変更が他のテストメソッドに影響を及ぼさないようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
@Before public void doBefore() { // テストデータ登録 insertTestData(); } @After public void doAfter() { // テストデータ削除 deleteTestData(); } public void insertTestData() { // テストデータを登録する ... sql="insert into table values(2, 'update')"; ... sql="insert into table values(3, 'delete')"; ... } public void deleteTestData() { // テストデータを削除する ... sql="delete from table"; ... } @Test public void testAdd() { // データが存在しないことを確認 assertNull(check(1)); // テスト add(1, "add"); // データが登録されていることを確認 assertEquals(check(1), "add"); } @Test public void testUpdate() { // データが登録されていることを確認 assertEquals(check(2), "update"); // テスト update(2, "updated"); // データが更新されていることを確認 assertEquals(check(2), "updated"); } @Test public void testDelete() { // データが存在することを確認 assertNotNull(check(3)); // テスト delete(3); // データが存在しないことを確認 assertNull(check(3)); } |
これによってDB操作のテストにおいてもテストメソッド間の影響を排除することが可能になりました。
また初期化はテストメソッド間だけでなくテストクラス間でも実行すべきです。
何故なら、テストメソッドの実行順序でテスト結果が変わるのと同様に、テストクラス間でも、同じテーブルやデータを使っている場合、順序変更の影響を受けて実行結果が変わりうるからです。
テストクラス間の影響を排除する為には、@BeforeClassと@AfterClassが利用できます。
@BeforeClassは、テストクラス開始時に1回だけ事前実行されるアノテーションです。
他テストクラスでも使用するデータの削除やテーブルの構築等、最初に一回だけ行いたい処理はこちらに記載します。
1 2 3 4 5 6 7 |
@BeforeClass public void setUp() { // テーブル構築 createTestTable(); // テストデータ削除 deleteTestData(); } |
@AfterClassは、テストクラス終了前に1回だけ事後実行されるアノテーションです。
他テストクラスでも使用するデータの削除やテーブルの削除等、最後に一回だけ行いたい処理はこちらに記載します。
1 2 3 4 5 |
@AfterClass public void tearDown() { // テーブル削除 dropTestTable(); } |
終わりに
テストメソッド(クラス)間が影響しあうような構成になっていると、仕様変更やバージョンアップによって容易にテストが失敗して、保守コストがかかるようになります。
なのでテストコードを作成する際は、
・@Beforeと@Afterで他テストメソッドへの影響を排除する。
・@BeforeClassと@AfterClassで他テストクラスへの影響を排除する。
・期待値は他テストの影響がない事を前提にする。
事が大事といえるのではないでしょうか。