JUnitを使用した単体テストの基本
はじめに
こんにちは。私は現在新卒入社1年目で、スマホアプリの実装・テストを主に担当しています。
実装を行う中で、一番難しいと感じたのが、JUnitの単体テストだったので、自分の勉強もかねてまとめたいと思います。
単体テストとは
単体テストとは、関数やメソッド単位で行われる小さい単位でのテストです。
小さな単位であらかじめ不具合を見つけることで、早期にバグの修正ができ、プログラム品質が上がるというメリットがあります。
(出典:Udemyメディア 「単体テストとは?メリットや手法、仕様書について詳しく解説!」
https://udemy.benesse.co.jp/development/system/unit-test.html)
JUnitの書き方
ここでは、JUnitでの単体テストの書き方についてご紹介します。
本記事の実行環境ですが、使用しているフレームワークはSpring、Java・JUnitのバージョンはJava17、JUnit5で実行しています。
テストメソッド
テストメソッドとは、その名の通り、実装されたメソッドをテストするためのメソッドです。
例として、割り算を行うクラスと、割り算の結果とゼロ除算の場合のエラーメッセージが正しく出力されているかを確認するテストクラスを作成してみます。
実装クラス
public class Example1 {
/**
* 割り算を行うメソッド
*/
public double divide(int dividend, int divisor) throws ArithmeticException {
try{
double quotient = dividend/divisor;
return quotient;
}catch(ArithmeticException e){
System.out.println("ゼロ除算です");
throw e;
}
}
テストクラス(JUnit)
import org.junit.*;
class Example1Test {
private Example1 example1;
/**
* 各テストメソッドの前に共通の処理
*/
@BeforeEach
void setUp() {
example1 = new Example1();
}
/**
* 割り算の検証
*/
@Test
void testDivide() {
assertEquals(2.5, example1.divide(5, 2));
}
/**
* ゼロ除算の検証
*/
@Test
void testDivideByZero() {
ArithmeticException exception = assertThrows(ArithmeticException.class, () -> {
calculator.divide(5, 0);
});
assertEquals("ゼロ除算です。", exception.getMessage());
}
}
}
テストコードの解説
@Testとテストメソッド
テストメソッドの前には必ず@Testというアノテーションを付けます。
また、テストメソッドは戻り値を持たないため、常にvoid型になります。
@Test
void testDivide(){
}
アサーションメソッド
JUnitでは、値が想定通りかを検証するためにアサーションメソッドというものを使用します。
例えば、assertEqualsであれば想定される値と等しいか、assertThrowsであれば想定される例外をスローしているかを検証することができます。
書き方としては、assertEquals(期待値,実際の値)のような形で書きます。
assertEqualsであれば、(計算結果,計算するメソッド)や("想定されるエラーメッセージ",エラーメッセージを取得するメソッド)などの形で記述されます。
上の例では、5÷2と5÷0のケースをテストしています。
5÷2は期待値が2.5で、計算をするのはdivideメソッドですから、assertEquals(2.5, example1.divide(5, 2))と書きます。
5÷0の場合は、ゼロ除算になります。ゼロ除算の場合はArithmeticExceptionという例外が投げられます。
そのため、例外が正しく投げられているかを検証するためassertThrowsを使います。
assertThrowsは、assertThrows(期待される例外のクラス型,() → テスト対象のメソッド)で記述します。
@Test
void testDivide(){
assertEquals(2.5, example1.divide(5, 2));
}
@Test
void testDivideByZero() {
ArithmeticException exception = assertThrows(ArithmeticException.class, () -> {
calculator.divide(5, 0);
});
アサーションメソッドには、他にも以下のようなものがあります。
- assertTrue(条件がTrueであることを検証)
- assertFalse(条件がFalseであることを検証)
- assertNotNull(オブジェクトがnullでないことを検証)
- assertNull(オブジェクトがnullであることを検証)
- assertThrows(想定通りの例外をスロー出来ていることを検証)
@Mockを使用した単体テスト
先ほど例で作ったテストクラスでは、他クラスとの依存関係がなかったため、アサーションメソッドを作るだけでテストができました。では、実装クラスが他クラスのメソッド呼び出しを行う場合はどうすればよいのでしょうか?
それは@Mockというアノテーションを使用することで作ることができます。
以下では、Idを入力することで、データベースから性別、名前と好きな食べ物を取得し、自己紹介文を作成してくれるコードを作成してみます。
InputService、InputDto、IntroductionServiceの3つのクラスを作成します。
InputServiceでは、入力されたIDを引数としてデータベースから性別、名前と好きな食べ物を取得し、InputDtoというクラスに値を格納します。InputDtoでは、Inputserviceで受け取った値を格納します。IntroductionServiceでInputDtoから値を取得し、取得した性別、名前と好きな食べ物から自己紹介文を出力します。(説明のために簡易化しております。)
実装
InputService
import java.sql.*;
public class InputService {
/**
* データベースから名前と好きな食べ物を取得するメソッド
*/
public InputDto getFindById(int id) {
String query = "SELECT gender, name, favorite_food FROM users WHERE id = ?";
InputDto dto = new InputDto();
Connection connection = DriverManager.getConnection("jdbc:postgresql:URL", "testname", "password");
PreparedStatement preparedStatement = connection.prepareStatement(query);
preparedStatement.setInt(1, id);
ResultSet resultSet = preparedStatement.executeQuery();
if (resultSet.next()) {
dto.setGender(resultSet.getString("gender"));
dto.setName(resultSet.getString("name"));
dto.setFavoriteFood(resultSet.getString("favorite_food"));
}
return dto;
}
}
InputDto
import lombok.*;
@Getter
@Setter
public class InputDto{
private String gender;
private String name;
private String favoriteFood;
}
IntroductionService
public class IntroductionService {
private InputService inputService;
public IntroductionService(InputService inputService) {
this.inputService = inputService;
}
public void introduce(int id) {
InputDto inputDto = inputService.getFindById(id);
String gender = inputDto.getGender();
String name = inputDto.getName();
String favoriteFood = inputDto.getFavoriteFood();
if ("Man".equals(gender)) {
System.out.println("His name is " + name + ". He likes " + favoriteFood);
} else {
System.out.println("Her name is " + name + ". She likes " + favoriteFood);
}
}
}
テストクラスの作成
IntroductionServiceのテストクラスを作成してみます。
このテストクラスで調べたいことは、IntroductionServiceのIntroductionメソッドが条件分岐を踏まえて正しく値を出力できているかです。
最初に男性の場合のテストを作成してみます。
まず、必要なものをインポートします。
以下をインポートすることで、後述の@Mockやwhen/thenReturnメソッド、アサーションメソッドを使用できるようになります。
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import static org.mockito.Mockito.when;
import static org.junit.jupiter.api.Assertions.*;
次に、依存関係があるInputServiceを@Mockでモック化します。
@Mockを使用することで、モック化したオブジェクトの振る舞いを決めることができるようになります。
また、IntroductionServiceのインスタンスを作成します。
@Mock
private InputService inputService;
private IntroductionService introductionService;
introductionService = new IntroductionService(inputService);
次に、テストメソッドの作成ですが、テストメソッドの中で、モック化したInputServiceの振る舞いを決める必要があります。
以下手順で実装します。
①InputServiceのgetFindByIdメソッドで引数となるuserIdを仮で1として設定します。
int userId = 1;
②getFindByIdメソッドで値が返却されるInputDtoにも手動で値を設定する必要があるので、InputDtoのインスタンスを生成し、値を手動でセットします。
InputDto mockDto = new InputDto();
mockDto.setGender("Man");
mockDto.setName("Taro");
mockDto.setFavoriteFood("Udon");
③InputServiceのgetFindByIdメソッドが手動で値をセットしたInputDtoを返却するようにします。
手動でセットした値を返すのは、when/thenReturnメソッドを使用することで可能になります。
when/thenReturnメソッドとは、whenで指定した対象のメソッドを呼び出したときに、thenReturnで設定した戻り値を返すというメソッドです。
書き方としては、when(対象メソッド).thenReturn(対象メソッドで返却される値)と書きます。
以下のように、when/thenReturnメソッドを使うことで、モック化されたオブジェクトの振る舞いを定義することが可能となります。
when(inputService.getFindById(userId)).thenReturn(mockDto);
①~③をつなげると以下のようになります。
@Test
void testMan() {
/**
* InputServiceの振る舞いを設定
*/
int userId = 1;
InputDto mockDto = new InputDto();
mockDto.setGender("Man");
mockDto.setName("Taro");
mockDto.setFavoriteFood("Udon");
when(inputService.getFindById(userId)).thenReturn(mockDto);
そして、テスト対象のメソッドを呼び出し、検証をアサーションメソッドで行えば、テストメソッドの完成です!
/**
* 標準出力をキャプチャするための準備
*/
ByteArrayOutputStream outContent = new ByteArrayOutputStream();
System.setOut(new PrintStream(outContent));
/**
* テスト対象メソッドの呼び出し
*/
introductionService.introduce(userId);
assertEquals("His name is Taro. He likes Udon", outContent.toString());
ここまでのものをすべてつなげると、以下のようになります。
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import static org.mockito.Mockito.when;
import static org.junit.jupiter.api.Assertions.*;
@Mock
private InputService inputService;
private IntroductionService introductionService;
introductionService = new IntroductionService(inputService);
@Test
void testMan() {
/**
* InputServiceの振る舞いを設定
*/
int userId = 1;
InputDto mockDto = new InputDto();
mockDto.setGender("Man");
mockDto.setName("Taro");
mockDto.setFavoriteFood("Udon");
when(inputService.getFindById(userId)).thenReturn(mockDto);
ByteArrayOutputStream outContent = new ByteArrayOutputStream();
System.setOut(new PrintStream(outContent));
introductionService.introduce(userId);
assertEquals("His name is Taro. He likes Udon", outContent.toString());
同様に女性の場合のケースを作ってみます。
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import static org.mockito.Mockito.when;
import static org.junit.jupiter.api.Assertions.*;
@Mock
private InputService inputService;
private IntroductionService introductionService;
introductionService = new IntroductionService(inputService);
@Test
void testWoman() {
/**
* InputServiceの振る舞いを設定
*/
int userId = 2;
InputDto mockDto = new InputDto();
mockDto.setGender("Woman");
mockDto.setName("Hanako");
mockDto.setFavoriteFood("cake");
when(inputService.getFindById(userId)).thenReturn(mockDto);
ByteArrayOutputStream outContent = new ByteArrayOutputStream();
System.setOut(new PrintStream(outContent));
introductionService.introduce(userId);
assertEquals("Her name is Hanako. She likes cake", outContent.toString());
アノテーション
最後にJUnitで使用できるアノテーションについていくつかご紹介します
- @BeforeEach
これを使用することで、各テストメソッド前の共通の処理を定義することができます。
例として、共通で使用しているオブジェクトの初期化などが挙げられます。
以下のような処理をテストクラスの最初に追加することで、各テストメソッドにCommonObjectを初期化する処理を記述しなくてもよくなります。
@BeforeEach
void setUp() {
commonObject = new CommonObject();
}
- @RepeatedTest
このアノテーションを使用することで、同じテストを指定の回数繰り返すことができます。
以下のようなランダム性のあるコードの検証に便利です。
実装
import java.util.*;
public class Game {
private String win = "You win!";
private String lose = "You lose!";
private Random random;
private double randomValue;
public Game(Random random) {
this.random = random;
}
public String judge() {
randomValue = random.nextDouble();
if (randomValue < 0.5) {
return win;
} else {
return lose;
}
}
public double getRandomValue() {
return randomValue;
}
}
Junit
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.*;
import org.mockito.*;
import java.util.*;
public class GameTest {
@Mock
private Random random;
@BeforeEach
public void setUp() {
random = Mockito.mock(Random.class);
}
@Test
void testJudgeWin() {
/**
* 0.5未満の値を返却
**/
when(random.nextDouble()).thenReturn(0.3);
Game game = new Game(random);
String result = game.judge();
assertEquals("You win!", result);
}
@Test
void testJudgeLose() {
/**
* 0.5以上の値を返却
**/
when(random.nextDouble()).thenReturn(0.7);
Game game = new Game(random);
String result = game.judge();
assertEquals("You lose!", result);
}
@RepeatedTest(100)
void testRandomValue() {
Game game = new Game(random);
game.judge();
double value = game.getRandomValue();
assertTrue(value >= 0.0 && value < 1.0, "想定外の値:" + value);
}
}
まとめ
- 単体テストとは、クラスやメソッドを対象とした最小単位のテスト
- JUnitは単体テストに使用されるフレームワーク
- アサーションメソッドは(期待値,実際の値)で記述する
- 依存関係があるオブジェクトがある場合は、@Mockを使用してモック化して使用する
- @BeforeEachなどのアノテーションも使いこなせれば便利
今回、JUnitについてまとめてみて、自分の中で雑然としていた知識を整理することができました。
JUnitはJavaの単体テストをする上で、非常に有用なフレームワークなので、是非皆さんも使ってみてください!
なお、掲載したソースコードはサンプルになります。本ソースコードを使用することで発生するいかなる損害や不利益について、当社は一切の責任を負いませんので自己の責任においてご利用ください。
Discussion