【初心者向け】SpringBootにおける単体テストの基本事項
最近になってやっと単体テストの基本的な考え方・書き方がわかってきた(当社比)のでいったん自分なりにまとめようと思います。
以下の項目について簡単にまとめます。これらの項目を知っていれば、もちろん状況によって追加で調べることは必要ですが、特殊なケースを除いて単体テストの作成にはあまり困らないと思います。
以下のリンクにコードを残しておきます。
https://github.com/Shukupon/TestTemplate
- クラスの単位
- モック化
- コントローラークラスのテスト
- リポジトリクラスのテスト
前提条件
- Java11
- SpringBoot 2.5.6
- Junit5
- H2
- MyBatis
- REST API
クラスの単位
単体テストを作成する単位は基本的に1つのクラスに対して1つのテストクラスです。
基本的にというのは、メソッド内の処理が適用されるプロファイルの内容によって分岐するため、プロファイルの数だけテストクラスを作った方が便利な場合などがあり得るためです。
テストクラスはやろうと思えば、複数のクラスをまとめためちゃくちゃ大きなテストクラスだったり、メソッド一つしか入っていない極小のテストクラスだったりというように自由にその粒度を決めることができます。ただし、あるクラスに対応したテストクラスがどれなのかがわかりやすいように、できる限り1クラス1テストクラスとなるように作成する方が無難です。他人が作った粒度がめちゃくちゃなテストクラスなんて触りたくないですからね。
モック化
テスト対象のクラスが他のクラスに依存している場合にはモック化が避けて通れません。
モック化とは、テスト対象が呼び出している他クラスをテスト用にモックで差し替え、モックの動作内容を定義してあげることで、テスト条件を実現することができるようにすることです。
例えば以下のようなテスト対象のクラスがあるとします。
package com.example.demo.application.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.example.demo.application.repository.DemoRepository;
import com.example.demo.bean.Goods;
// 業務処理を行うクラス
@Service
public class DemoService {
@Autowired
DemoRepository demoRepository;
//
public boolean decide(Goods goods) {
Goods ordinaryGoods = demoRepository.findByName(goods.getName());
if (goods.getPrice() <= ordinaryGoods.getPrice())
return true;
return false;
}
}
このクラスはService
クラスに依存しているため、こいつをモック化してあげる必要があります。
そのため以下のような感じでモックを定義してあげます。(やり方は数種類あります)
package com.example.demo.application.service;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import com.example.demo.application.repository.DemoRepository;
import com.example.demo.bean.Goods;
@ExtendWith(SpringExtension.class)
public class DemoServiceTest {
@TestConfiguration
static class DemoUseCaseTestConfiguration {
// テスト対象クラスのBean生成
@Bean
public DemoService demoService() {
return new DemoService();
}
}
// モックの作成
@MockBean
DemoRepository mockRepository;
// テスト対象クラスのインスタンスにモックを注入
@Autowired
private DemoService target;
// テストメソッド
@Test
public void decideTest01() {
// 渡す引数の準備
Goods goods = createGoods();
// どんなGoods型の引数でもtrueを返すようにmockを定義
when(mockRepository.findByName(Mockito.anyString())).thenReturn(createOrdinaryGoods());
// assert
assertTrue(target.decide(goods));
}
// Test用のデータ作成
private Goods createGoods() {
Goods goods = new Goods();
goods.setName("avocado");
goods.setPrice(180);
return goods;
}
// モック用のデータ作成
private Goods createOrdinaryGoods() {
Goods goods = new Goods();
goods.setName("avocado");
goods.setPrice(200);
return goods;
}
}
これでcheckPriceメソッドがtrueの場合はdecideメソッドがtrueを返却することのテストができます。
※anyメソッドはorg.mockito.ArgumentMatchers.anyをimportしてください。
ちなみに
verify(mockService, times(1)).checkPrice(any(Goods.class))
というようにverifyメソッドを使ってあげると対象のモックの関数が呼び出された回数をassertしたり、
@Captor
ArgumentCaptor<Goods> goodsCaptor;
verify(mockService).checkPrice(goodsCaptor.capture());
assertEquals(180, goodsCaptor.getValue().getPrice());
上記のようにArgumentCaptorを利用してメソッドの引数をassertすることもできます。
コントローラークラスのテスト
コントローラークラスは他のクラスと比較して、リクエストを受け付けてレスポンスを返すという、アプリケーションの外部とやりとりを行うという点で少し特殊です。
このクラスのテストをする場合はMockMvcを使い、擬似的にリクエストを飛ばしてあげる必要があります。
以下がテスト対象のコントローラクラスです。
package com.example.demo.presentation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.demo.application.service.DemoService;
import com.example.demo.bean.Goods;
//RESTAPIのコントローラクラス
@RestController
@RequestMapping("/demo")
public class DemoController {
@Autowired
DemoService demoService;
@PostMapping("/shopping")
public ResponseEntity<Boolean> shopping(@RequestBody Goods goods) {
Boolean response = demoService.decide(goods);
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setContentType(MediaType.APPLICATION_JSON);
return ResponseEntity.status(HttpStatus.OK).headers(responseHeaders).body(response);
}
}
これに対応するテストクラスは以下のような感じになります。
package com.example.demo.presentation;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import com.example.demo.application.service.DemoService;
import com.example.demo.bean.Goods;
import com.fasterxml.jackson.databind.ObjectMapper;
@WebMvcTest(DemoController.class)
public class DemoControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
DemoService mockService;
@Test
public void shoppingTest01() throws Exception {
// 使用する商品情報の準備
Goods goods = createGoods();
// リクエストの準備
ObjectMapper objectMapper = new ObjectMapper();
String requestJson = objectMapper.writeValueAsString(goods);
// モックの設定
when(mockService.decide(any(Goods.class))).thenReturn(true);
// execute
String responseJson = this.mockMvc
.perform(post("/demo/shopping").contentType(MediaType.APPLICATION_JSON).content(requestJson))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
Boolean result = objectMapper.readValue(responseJson, Boolean.class);
verify(mockService, times(1)).decide(any(Goods.class));
assertTrue(result);
}
private Goods createGoods() {
// Test用のデータ作成
Goods goods = new Goods();
goods.setName("アボガド");
goods.setPrice(180);
return goods;
}
}
リクエストの準備と実行部分がかなり特徴的ですね。
mockMvcのメソッドチェインでandExpectメソッドを使えば色々とassertすることが可能です。
リポジトリクラスのテスト
リポジトリといっても、今回はインタフェースをJavaで用意しMyBatisでxmlでSQLを実行する想定で書きます。
package com.example.demo.application.repository;
import org.apache.ibatis.annotations.Mapper;
import com.example.demo.bean.Goods;
//リポジトリのインターフェース
@Mapper
public interface DemoRepository {
Goods findByName(String name);
}
リポジトリの実装クラスとしてのxmlファイルは以下です。今回はシンプルなSELECT文だけにしています。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper
namespace="com.example.demo.application.repository.DemoRepository">
<resultMap id="GoodsResultMap"
type="com.example.demo.bean.Goods">
<id property="name" column="name" />
<result property="price" column="price" />
</resultMap>
<select id="findByName" parameterType="String"
resultMap="GoodsResultMap">
<![CDATA[
SELECT
name,
price
FROM
goods
WHERE
name = #{name}
]]>
</select>
</mapper>
こちらのSQLをテストするためのテストクラスが以下になります。
package com.example.demo.application.repository;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.samePropertyValuesAs;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.boot.test.autoconfigure.MybatisTest;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.demo.bean.Goods;
@MybatisTest
public class DemoRepositoryTest {
@Autowired
private DemoRepository demoRepository;
@Test
public void findByNameTest() {
Goods goods = createGoods();
Goods result = demoRepository.findByName(goods.getName());
assertThat(goods, samePropertyValuesAs(result));
}
private Goods createGoods() {
// Test用のデータ作成
Goods goods = new Goods();
goods.setName("avocado");
goods.setPrice(180);
return goods;
}
}
もちろんこのままでは動かないので、テスト用のDBを用意するため、src/test/java/resources配下にdata.sqlとschema.sqlを用意してあげます。
スキーマ定義
CREATE TABLE IF NOT EXISTS goods(
name VARCHAR(30) NOT NULL PRIMARY KEY,
price INT
);
初期データ定義
INSERT INTO goods(
name,
price
)VALUES(
'avocado',
180
);
application.propertiesにも設定しておく必要があります。
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.datasource.password=
spring.sql.init.mode=always
spring.sql.init.schema-locations=classpath:resources/schema.sql
spring.sql.init.data-locations=classpath:resources/data.sql
まとめ
1から全て自分一人でコードを書くとなるとまだまだうろ覚えの部分がたくさんあると実感しました。
特にエラー内容を見ただけで解決できないような致命的なエラーに遭遇した時は発狂しました。何とか解決しましたが、、、
本当はもっと内容を詰めたかったのですが、想像以上にボリュームが大きくなってしまったのでまた別で投稿しようと思います。