JUnitでモックを使ったテストについてです。
職場でテストコードを普及させるための記事2回目になります。
EasyMockについてが中心となるので、あまり最近の話ではありません。
実行環境
- Java 1.8
- JUnit 4.4
- EasyMock 2.4
- Seasar 2.4.46
モックとは
モックオブジェクト(Mock Object)とは、ソフトウェアテスト時、特にテスト駆動開発、ビヘイビア駆動開発における代用の下位モジュールスタブの一種。スタブと比較して、検査対象のモジュール
がその下位モジュールを正しく利用しているかどうかを検証するのに使われる。
モックオブジェクト 出典: フリー百科事典『ウィキペディア(Wikipedia)』
使い方としては、例えば以下のようにHelloJunitというクラスを作成しているとします。
このクラスをテストしようとした際に、もしGreetDaoクラスができていない(あとはDaoで参照しているDBが用意されていないとか)とした場合に、普通にテストしてしまうとエラーになってしまうと思います。
public class HelloJunit {
@Binding
GreetDao greetDao;
public String sayGreeting() {
return greetDao.getGreet();
}
本来はHelloJunitクラスをテストしたいだけなので、依存先(GreetDao)のコードができていなくてもテストできるべきだよねということで、ひとまず動くようにハリボテのクラスを用意して動かせるようにしようよ。というのがモックの役割になります。
どうやってモックを作るのか
以下のような方法があります。
- モックライブラリを利用
- モックを作成したいクラスを継承してテスト用に作成する
モックライブラリとしては、Mockito
、JMockit
、そしてEasyMock
、MockInterceptor
などがあります。
最近ではMockito
がよく使われている印象です。
Seasar2ではEasyMock
、MockInterceptor
がデフォルトで入っています。
いまだにSeasar2を利用しているような環境で新しいライブラリを入れるのは難しいと思いますので、今回はEasyMockで進めたいと思います。
EasyMockを利用したテストコードサンプル
とりあえずコード
プロダクトコードについて上で紹介した内容から少し変更しています。
理由については後ほど説明します。
public class HelloJunit {
GreetDao greetDao;
public HelloJunit(GreetDao greetDao) {
this.greetDao = greetDao;
}
public String sayGreeting() {
return greetDao.getGreet();
}
}
テストコードはこちらです。
import static org.easymock.EasyMock.*;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import org.junit.Before;
import org.junit.Test;
import org.seasar.framework.unit.annotation.EasyMock;
import org.seasar.sastruts.example.dao.GreetDao;
public class HelloJunitTest {
HelloJunit helloJunit;
@EasyMock
GreetDao greetDao;
@Before
public void setUp() {
greetDao = createMock(GreetDao.class);
helloJunit = new HelloJunit(greetDao);
}
@Test
public void 挨拶を言う() throws Exception {
// setup(事前準備)
expect(greetDao.getGreet()).andReturn("Hello");
replay(greetDao);
// exercise(実行)
String actual = helloJunit.sayGreeting();
// verify(検証)
String expected = "Hello";
assertThat(actual, is(expected));
verify(greetDao);
// tear down(後処理)
// 何かあれば・・・
}
}
なんとなくわかると思いますが解説します。
EasyMockアノテーション
メンバー変数にGreetDaoを用意しているのですが、EasyMockアノテーション
がついています。
これをつけることでEasyMockを利用可能になります。
createMock
setUpメソッドではcreateMock
メソッドを利用して、greetDaoを作成しています。
ここで作成したgreetDaoをテスト対象メソッドであるhelloJunitのインスタンス生成時に渡してあげることで、モックがセットされることになります。
テストメソッド内のsetup
setup(事前準備)というコメントの中では、expect
メソッドを利用しています。
ここがモックの挙動を決める一番重要な部分です。
greetDao.getGreet()がこういう呼び出し方をされた場合、〜を返すよというのを設定しています。
expected()
でメソッドの呼び出し方を指定。その後のandReturn()
で戻り値を設定します。
今回の場合は、引数なしのgreetDao.getGreet()を呼び出した場合に、"Hello"を返すという設定をしています。
replay
というメソッドは直前で設定したモックの挙動を記憶するというイメージです。
expected()を実行したらreplayをするというようにしてください。
exercise
ここではテストをしたいメソッドを実行して、変数に格納しています。
格納する変数はどのような型であってもactual
に統一するとわかりやすいと言われています。
verify
ここでは、helloJunit.sayGreeting();
の戻り値と期待値を比較しています。
また、verify(greetDao);
というのは、setup内で指定したモックの挙動が想定通り呼び出されたかを検証します。
もしhelloJunit.sayGreeting();
が1回も呼び出されていなければエラーが返却されます。
tear down
ここはテスト後の後処理を行います。
例えばテストデータをDBに投入していたとした場合、そのデータを削除したりを行います。
EasyMockの利用方法
引数をチェックする
今回はgreetDaoのfindGreetメソッドが想定通りの呼び出され方をしているか確認します。
public String sayGreeting() {
return greetDao.findGreet(1);
}
expect()
内に期待しているメソッドの呼び出し方をそのまま書きます。
今回の場合は、findGreet
の引数に1が入ることを想定しているので、そのままfindGreet(1)
を記載します。
@Test
public void 引数テスト() throws Exception {
// setup(事前準備)
expect(greetDao.findGreet(1)).andReturn("Hello");
replay(greetDao);
// exercise(実行)
String actual = helloJunit.sayGreeting();
// verify(検証)
verify(greetDao);
}
ちなみに、もしfindGreet(2);
で呼び出されていた場合には以下のようなエラーが出力されます。
java.lang.AssertionError:
Unexpected method call findGreet(2):
findGreet(1): expected: 1, actual: 0
at org.easymock.internal.MockInvocationHandler.invoke(MockInvocationHandler.java:32)
at org.easymock.internal.ObjectMethodsFilter.invoke(ObjectMethodsFilter.java:61)
at com.sun.proxy.$Proxy4.findGreet(Unknown Source)
at org.seasar.sastruts.example.service.HelloJunit.sayGreeting(HelloJunit.java:17)
at org.seasar.sastruts.example.service.HelloJunitTest.引数テスト(HelloJunitTest.java:52)
--以降略--
戻り値をセットして後続処理をテストする
findGreet
の戻り値によって分岐を入れてみました。
今回のテストでは、パスタが返却されるパターンをテストしたいと思います。
public String sayGreeting() {
String greet = greetDao.findGreet(1);
if (greet.contentEquals("Hello")) {
return "パスタ";
} else {
return "ラーメン";
}
}
戻り値を設定するには、andReturn
に戻り値を指定します。
今回の場合は、"Hello"が返却されるようになります。
@Test
public void 引数テスト() throws Exception {
// setup(事前準備)
expect(greetDao.findGreet(1)).andReturn("Hello");
replay(greetDao);
// exercise(実行)
String actual = helloJunit.sayGreeting();
// verify(検証)
String expected = "パスタ";
assertThat(actual, is(expected));
verify(greetDao);
}
Void型メソッドをテストする
void型のメソッドをテストする方法です。
public interface GreetDao {
public void insertGreet(String greet);
}
public String addGreet() {
greetDao.insertGreet("Good Night");
return "OK";
}
void型をテストする場合は、テストコードの中で実際にvoidメソッドを実行します。
その後にexpectLastCall()
を呼び出します。
これによって、直前のメソッド実行を記憶して検証することができます。
ちなみに同一のvoidメソッドを二回実行している場合には、expectLastCall().times(2);
というようにtimes()を付与することで、回数分実行されたかを検証します。
@Test
public void voidメソッドをテスト() throws Exception {
// setup(事前準備)
greetDao.insertGreet("Good Night");
expectLastCall();
replay(greetDao);
// exercise(実行)
String actual = helloJunit.sayGreeting();
// verify(検証)
verify(greetDao);
}
EasyMockの注意点
EasyMockでは、インターフェースのクラスしかモックすることができません。
もし具象クラスをモックしたい場合は、次に紹介する継承する方法を試してください。
補足:org.easymock.classextension.EasyMock
を利用することで具象クラスもテスト可能ですが、古いバージョンのSeasarを利用している場合は入っていない可能性あります。
継承してチェックする
テスト対象のメソッド内で利用しているクラスがインターフェースでなかったり、テストするには不都合があるような場合には、テストのために継承してモッククラスを自分で用意することもあります。
例えば、メソッドで返却されるBeanにequalsメソッドがオーバーライドされておらず、一つ一つのフィールドをチェックするのがめんどくさいような場合は、以下のようにequalsを継承してテストします。
public TransferResultDto transfer(String payerAccountId,
String payeeAccountId,
String payerName,
long transferAmount) {
Balance payerBalance = balanceDao.findByAccountId(payerAccountId);
if (payerBalance == null)
throw new BusinessLogicException("残高が取得できません");
if (transferAmount > payerBalance.amount)
throw new BusinessLogicException("残高が足りません");
Balance payeeBalance = balanceDao.findByAccountId(payeeAccountId);
if (payeeBalance == null)
throw new BusinessLogicException("振込先口座が存在しません");
LocalDateTime transferDate = LocalDateTime.now();
Transfer peyerTransaction = new Transfer();
peyerTransaction.accountId = payerAccountId;
peyerTransaction.name = payerBalance.name;
peyerTransaction.transferAmount = -transferAmount;
peyerTransaction.transferDate = transferDate;
Transfer payeeTransaction = new Transfer();
payeeTransaction.accountId = payeeAccountId;
payeeTransaction.name = payeeBalance.name;
payeeTransaction.transferAmount = transferAmount;
payeeTransaction.transferDate = transferDate;
transferDao.insertTransfer(peyerTransaction);
transferDao.insertTransfer(payeeTransaction);
balanceDao.updateAmount(payerAccountId, -transferAmount);
balanceDao.updateAmount(payeeAccountId, transferAmount);
Balance updatedPayerBalance = balanceDao.findByAccountId(payerAccountId);
return new TransferResultDto(payerAccountId, payeeAccountId,
payerBalance.name, payeeBalance.name,
transferAmount,
updatedPayerBalance.amount);
}
}
public class TransferResultDto {
private final String payerAccountId;
private final String payeeAccountId;
private final String payerName;
private final String payeeName;
private final long transferAmount;
private final long amount;
public TransferResultDto(String payerAccountId,
String payeeAccountId,
String payerName,
String payeeName,
long transferAmount,
long amount) {
this.payerAccountId = payerAccountId;
this.payeeAccountId = payeeAccountId;
this.payerName = payerName;
this.payeeName = payeeName;
this.transferAmount = transferAmount;
this.amount = amount;
}
public String getPayerAccountId() {
return payerAccountId;
}
public String getPayeeAccountId() {
return payeeAccountId;
}
public String getPayerName() {
return payerName;
}
public String getPayeeName() {
return payeeName;
}
public long getTransferAmount() {
return transferAmount;
}
public long getAmount() {
return amount;
}
}
これをテストする場合には、テストクラス内でTransferResultDto
を継承してTransferResultDtoMock
クラスを作成します。
このTransferResultDtoMock
クラスの中で全てのフィールドを比較するequalsメソッドを用意して、テストする際にはassertThat(actual, is(expected));
というように isで比較できるようになります。
今回は、equalsメソッドを書くのがめんどくさいので、lombokを利用して自動生成しました
public class TransferServiceTest {
@Test
public void 継承モックを試す() {
// setup
TransferResultDtoMock expected =
new TransferResultDtoMock("1", "2",
ACCOUNT1_BEFORE_BALANCE.name, ACCOUNT2_BALANCE.name,
2000, ACCOUNT1_AFTER_BALANCE.amount);
TransferResultDto transferResultDto = transferService.transfer("1", "2", "田中太郎", 1000);
TransferResultDtoMock actual = new TransferResultDtoMock(transferResultDto);
assertThat(actual, is(expected));
}
@EqualsAndHashCode(callSuper = true)
private class TransferResultDtoMock extends TransferResultDto {
public TransferResultDtoMock(String payerAccountId,
String payeeAccountId,
String payerName,
String payeeName,
long transferAmount,
long amount) {
super(payerAccountId, payeeAccountId, payerName,
payeeName, transferAmount, amount);
}
public TransferResultDtoMock(TransferResultDto transferResultDto) {
super(transferResultDto.getPayerAccountId(),
transferResultDto.getPayeeAccountId(),
transferResultDto.getPayerName(),
transferResultDto.getPayeeName(),
transferResultDto.getTransferAmount(),
transferResultDto.getAmount());
}
}
}
テストコードを書くにあたって意識すべきこと
ビジネスロジックに関する部分は独立した実装にする
最初にHelloJunit内でGreetDaoをDIするロジックを、アノテーションからコンストラクタ型に変更したと思います。
その理由としては、アノテーションで表現する場合、HelloJunitがフレームワーク(Seasar2)に**依存する**形になってしまうからです。
public class HelloJunit {
GreetDao greetDao;
public HelloJunit(GreetDao greetDao) {
this.greetDao = greetDao;
}
public String sayGreeting() {
return greetDao.getGreet();
}
}
例えば今後Springに移行することになった場合、アノテーションでDIする場合は @ Autowiredを利用することになります。
なので、コンストラクタでDIすることでフレームワークへの変更も用意になります。
また、テストコードという観点からもアノテーションで実現している場合には、クラスの宣言時に@RunWith(Seasar2.class)
を指定する必要がありますが、コンストラクタでDIしている場合には、new してあげればテストが簡単にできます。
参考記事
テストが書きやすいコードを書く
テストを行うにあたり、テストが書きやすいコードというのは重要になります。
例えば以下のようなコードは、TimeJunit.getTime()
staticメソッドの結果に応じて条件分岐をしているのですが、staticメソッドはモックで置き換えることが難しいです。
public class ExampleJunit {
public String sayGreeting() {
int term = TimeJunit.getTime();
switch(term) {
case 1:
return "パスタ";
case 2:
return "ラーメン";
default:
return "ご飯";
}
}
}
あとは継承していてスーパークラスのメソッドを利用している場合もテストしにくいコードになります。
例えば古いシステムだとよく使われているようなこんなロジック。
public abstract class AbstractJunit {
public String sayGreeting() {
return this.getMessage();
}
abstract protected String getMessage();
}
public class ExampleJunit extends AbstractJunit {
protected String getMessage() {
int term = 1;
switch(term) {
case 1:
return "パスタ";
case 2:
return "ラーメン";
default:
return "ご飯";
}
}
}
今回の例であればテスト可能ですが、実際に業務で継承を利用しているようなケースだと、スーパークラスが神様クラス(なんでもロジックを入れる便利クラス)になっていて、スパゲッティソースになっていたりするので、テストでリフレクションとかしなきゃいけなくなってしまうので扱い辛いです。
本当に継承を使うべきか考えて実装しましょう。
参考記事
その他参考