はじめに
JUnit4に慣れ過ぎて、いざJUnit5で書こうと思った時に結構調べる必要がありました。
今更ながら、よく使う実装サンプルを自分用にまとめておきます。
なお、テスト対象のWebアプリはRestAPIを想定しておりView層のテストは含みません。
この記事で扱う実装サンプル
-
Controllerのテスト
MockMVCで擬似リクエストを行い Filter + Controller + ExceptionHandler をテストする -
Mockを使ったテスト
- BeanをMock化してテストする
- 片方をDI、もう片方をMock化してテストする
-
パラメータ化テスト
テストデータだけを変えて同じケースを繰り返しテストする
開発環境
- OS : macOS Catalina
- IDE : IntelliJ Ultimate
- Java : 11
- SpringBoot : 2.3.4
- Gradle : 6.6.1
1. テスト対象のアプリケーションの準備
まずはテスト対象となるアプリを作成します。
例によってサクっと spring initializr で GradleProject に lombok, Spring Web, Validation をdependenciesに追加して作成 します。
plugins {
id 'org.springframework.boot' version '2.3.4.RELEASE'
id 'io.spring.dependency-management' version '1.0.10.RELEASE'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testCompileOnly 'org.projectlombok:lombok' // 追加
testAnnotationProcessor 'org.projectlombok:lombok' // 追加
}
test {
useJUnitPlatform()
}
生成された build.gradle
に対し、テストコード内でも lombok
を使えるように dependency
に2行追加しています。(上記コメント参照)
このプロジェクトに対し、次のクラスを作成していきます。
1-1.ServiceとConfigurationの作成
Controller
から呼ばれる Service
を2つ作ります。
まずはシンプルな内部処理を行うもの。
@Service
public class DemoService {
// あいさつするよ
public String hello() {
return "hello";
}
// 割り算するよ
public BigDecimal divide(BigDecimal a, BigDecimal b) {
return a.divide(b, 2, RoundingMode.HALF_UP);
}
}
もう一つは外部リソース(Qiita API)を取得するもの。
@Service
@RequiredArgsConstructor
public class ExternalService {
private static final String EXTERNAL_RESOURCE_URL = "https://qiita.com/api/v2/schema";
private final RestTemplate restTemplate;
// Qiita APIのSchemaの結果を返すよ
public String getExternalResource() {
ResponseEntity<String> response =
restTemplate.exchange(EXTERNAL_RESOURCE_URL, HttpMethod.GET, null, String.class);
return response.getBody();
}
}
上記の restTemplate
をDIするため Configuration
クラスも作成しておきます。
@Configuration
public class AppConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
1-2. Controllerの作成
先ほどの2つのServiceを使うControllerを作ります。
@RestController
@RequiredArgsConstructor
@Validated
public class DemoController {
private final DemoService demoService;
private final ExternalService externalService;
// あいさつするよ
@GetMapping("/")
public CommonResponse hello() {
String data = demoService.hello();
return CommonResponse.builder().data(data).build();
}
// 割り算するよ
@GetMapping("/divide/{num1}/{num2}")
public CommonResponse divide(
@PathVariable @Pattern(regexp = "[0-9]*") String num1,
@PathVariable @Pattern(regexp = "[0-9]*") String num2) {
BigDecimal data = demoService.divide(new BigDecimal(num1), new BigDecimal(num2));
return CommonResponse.builder().data(data).build();
}
// Qiita APIのSchemaの結果を返すよ
@GetMapping("/external")
public CommonResponse external() {
String data = externalService.getExternalResource();
return CommonResponse.builder().data(data).build();
}
}
割り算の方は入力チェックをしているので、この観点で後ほどテストケースを実装します。
レスポンスクラスも以下のように作成します。
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CommonResponse<T> {
@Builder.Default
private String status = "success";
@Builder.Default
private String message = "request succeeded.";
private T data;
}
1-3. FilterとExceptionHandlerの作成
より実践的なサンプルにするためFilterとExceptionHandlerも作成します。
このフィルタはリクエスト処理前後にログを出力するだけのシンプルなものです。
@Component
@Slf4j
public class LogFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
log.info("[IN]{}:{}", req.getMethod(), req.getRequestURI());
try {
chain.doFilter(request, response);
} finally {
log.info("[OUT]{}:{}", req.getMethod(), req.getRequestURI());
}
}
}
ExceptionHandlerも作成します。
各種エラーを共通で処理するために使います。
@RestControllerAdvice
@Slf4j
public class CommonExceptionHandler extends ResponseEntityExceptionHandler {
// 404: Resource Not Foundエラーを処理するよ
// ※ これをハンドリングするには application.properties の設定も必要だよ
@Override
protected ResponseEntity handleNoHandlerFoundException(NoHandlerFoundException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
ServletWebRequest req = (ServletWebRequest)request;
log.warn("resource not found. {}", req.getRequest().getRequestURI());
return new ResponseEntity(
CommonResponse.builder().status("failure").message("resource not found.").build(),
HttpStatus.NOT_FOUND);
}
// 400: 入力チェックエラーを処理するよ
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<CommonResponse> handleValidationError(ConstraintViolationException e) {
// 入力エラー項目とメッセージをカンマ区切り(,)に追加。
String validationErrorMessages =
e.getConstraintViolations().stream()
.map(cv -> cv.getPropertyPath().toString() + ":" + cv.getMessage())
.collect(Collectors.joining(", "));
log.info("Bad request. {}", validationErrorMessages);
return new ResponseEntity<>(
CommonResponse.builder().status("failure").message(validationErrorMessages).build(),
HttpStatus.BAD_REQUEST);
}
// 500: それ以外の不明なエラーを処理するよ
@ExceptionHandler
public ResponseEntity<CommonResponse> handleException(Exception e) {
log.error("Request failed.", e);
return new ResponseEntity<>(
CommonResponse.builder().status("failure").message("error has occurred.").build(),
HttpStatus.INTERNAL_SERVER_ERROR);
}
}
上記のExceptionHandlerで 404 : Resouce Not Found
をハンドリングするために application.properties
に以下の設定を入れます。
spring.mvc.throw-exception-if-no-handler-found=true
spring.resources.add-mappings=false
1-4. 作成したファイルの構成
以上でテストに使うアプリケーションの準備は完了です。
ここまでで編集・作成したファイルは以下の構成になっています。
├── build.gradle
├── src
│ ├── main
│ │ ├── java
│ │ │ └── com.example.demo
│ │ │ ├── AppConfig.java
│ │ │ ├── DemoApplication.java
│ │ │ ├── controller
│ │ │ │ ├── CommonExceptionHandler.java
│ │ │ │ ├── CommonResponse.java
│ │ │ │ └── DemoController.java
│ │ │ ├── filter
│ │ │ │ └── LogFilter.java
│ │ │ └── service
│ │ │ ├── DemoService.java
│ │ │ └── ExternalService.java
│ │ └── resources
│ │ └── application.properties
│ └── test
└── web
#2. Controllerのテスト
Controllerの主な役割は以下の通りです。
- リクエストのマッピング
- パラメータの取得
- 入力チェック
- ビジネスロジック(Service)の呼び出し
- レスポンスの返却
これらの動作はSpringMVCの機能に依存しているため、Controllerクラス単体のテストコードを書いてもあまり意味がありません。
そこで MockMVC
を使ってSpringMVCの動作を再現したテストを行います。
2-1. MockMVCを使ったテストケースのサンプル
Controllerが受け付けるリクエストについて正常ケースだけでなく、入力チェックエラーなど想定される異常ケースのレスポンスももれなく検証を行います。
package com.example.demo.controller;
import com.example.demo.filter.LogFilter;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@EnableWebMvc
@Slf4j
public class DemoControllerTest {
MockMvc mockMvc;
@Autowired WebApplicationContext webApplicationContext;
@Autowired LogFilter logFilter;
@BeforeEach
void beforeEach() {
mockMvc =
MockMvcBuilders.webAppContextSetup(webApplicationContext) // MockMVCをセットアップ
.addFilter(logFilter, "/*") // ただしfilterは手動で追加が必要
.build();
}
// ルート「/」のリクエストをテストするよ
@Test
void hello() throws Exception {
mockMvc.perform(get("/")) // ルート「/」に擬似リクエスト送信
.andExpect(status().isOk()) // HttpStatus が 200:OK であること
.andExpect(jsonPath("$.status").value("success")) // jsonの値が期待値通りであること
.andExpect(jsonPath("$.message").value("request succeeded.")) // 〃
.andExpect(jsonPath("$.data").value("hello"));
}
// 除算(10 ÷ 3)のリクエストをテストするよ
@Test
void divideSuccess() throws Exception {
mockMvc
.perform(get("/divide/10/3")) // 「/divide/10/3」に擬似リクエスト送信
.andExpect(status().isOk()) // HttpStatus が 200:OK であること
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("3.33")); // 10 ÷ 3 = 3.33 であること
}
// 不正なリクエスト(10 ÷ aaa)をテストするよ
@Test
void divideInvalidParameter() throws Exception {
mockMvc
.perform(get("/divide/10/aaa")) // 「/divide/10/aaa」に擬似リクエスト送信
.andExpect(status().isBadRequest()) // HttpStatus が 400:BadRequest であること
.andExpect(jsonPath("$.status").value("failure"))
.andExpect(jsonPath("$.message").value("divide.num2:must match \"[0-9]*\"")); // エラーメッセージがあること
}
// ゼロ除算(10 ÷ 0)のリクエストをテストするよ
@Test
void divideZeroError() throws Exception {
mockMvc
.perform(get("/divide/10/0")) // 「/divide/10/0」に擬似リクエスト送信
.andExpect(status().is5xxServerError()) // HttpStatus が 500:ServerError
.andExpect(jsonPath("$.status").value("failure"))
.andExpect(jsonPath("$.message").value("error has occurred."));
}
// 外部リソース(Qiita schema API)の取得をテストするよ
@Test
void getExternalResource() throws Exception {
MvcResult mvcResult =
mockMvc
.perform(get("/external"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").isNotEmpty()) // 空じゃないこと
.andReturn();
// 取得したレスポンスをログに出力しておくよ
log.info("external response : {}", mvcResult.getResponse().getContentAsString());
}
}
ポイントは MockMvc
を WebAppicationContext
を使ってセットアップすることです。1
こうすることでアプリケーションサーバーにデプロイしたのとほぼ同等な状態を再現します。
ただし Filter
だけは自前で addFilter(Filter, "path")
で指定してあげる必要がある点に注意。
#3. Mockを使ったテスト
先ほどのテストはDIされたControllerやServiceを利用していました。
しかし ExternalService
のように外部リソースを利用する場合、その状態(相手のサーバーがダウンしてアクセスできない、Wi-FiがOFFになっている、など)によってControllerのテストが失敗するのはよくありません。
そんなときにMockを使います。
##3-1. BeanをMock化してテストする
もう一度、Controllerの役割を確認すると、
- リクエストのマッピング
- パラメータの取得
- 入力チェック
- ビジネスロジック(Service)の呼び出し
- レスポンスの返却
ですが、このうち 4. ビジネスロジック(Service)の呼び出し
は、 期待値を返すようモック化することでControllerのテストに集中できる ようになります。
先ほどのテストのServiceクラスをMock化してみます。
package com.example.demo.controller;
import com.example.demo.filter.LogFilter;
import com.example.demo.service.DemoService;
import com.example.demo.service.ExternalService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import java.math.BigDecimal;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@EnableWebMvc
@Slf4j
class DemoControllerWithMockTest {
MockMvc mockMvc;
@Autowired WebApplicationContext webApplicationContext;
@Autowired LogFilter logFilter;
@MockBean DemoService demoService; // Mock化してDIコンテナに登録する
@MockBean ExternalService externalService; // 〃
@BeforeEach
void beforeEach() {
MockitoAnnotations.initMocks(this);
mockMvc =
MockMvcBuilders.webAppContextSetup(webApplicationContext)
.addFilter(logFilter, "/*")
.build();
}
@AfterEach
void afterEach() {}
@Test
void hello() throws Exception {
// mock
when(demoService.hello()).thenReturn("こんにちは"); // 最初にモックの戻り値をセット
// request execute
mockMvc
.perform(get("/"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("こんにちは")); // 期待値も変更して検証
// verify
verify(demoService, times(1)).hello(); // モックの呼び出し回数を検証
}
@Test
void divideSuccess() throws Exception {
// mock
when(demoService.divide(any(), any())).thenReturn(new BigDecimal("3.33")); // 引数関係なく"3.33"を返す
// request execute
mockMvc
.perform(get("/divide/10/3"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("3.33"));
// verify
verify(demoService, times(1)).divide(any(), any());
}
@Test
void divideInvalidParameter() throws Exception {
// request execute
mockMvc
.perform(get("/divide/10/aaa"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value("failure"))
.andExpect(jsonPath("$.message").value("divide.num2:must match \"[0-9]*\""));
// verify
verify(demoService, times(0)).divide(any(), any()); // 入力エラーのためモックの呼び出しは0回を検証
}
@Test
void divideZeroError() throws Exception {
// mock
when(demoService.divide(any(), eq(BigDecimal.ZERO)))
.thenThrow(new ArithmeticException("/ by zero")); // ゼロ除算を想定してエラーを再現
// request execute
mockMvc
.perform(get("/divide/10/0"))
.andExpect(status().is5xxServerError())
.andExpect(jsonPath("$.status").value("failure"))
.andExpect(jsonPath("$.message").value("error has occurred."));
// verify
verify(demoService, times(1)).divide(any(), eq(BigDecimal.ZERO)); // モックの呼び出しは1回を検証
}
@Test
void getExternalResource() throws Exception {
// mock
when(externalService.getExternalResource())
.thenReturn("this is mock data for internal test."); // 外部リソースアクセスせずに文言返す
// request execute
MvcResult mvcResult =
mockMvc
.perform(get("/external"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("this is mock data for internal test."))
.andReturn();
// 取得したレスポンスをログに出力しておくよ
log.info("external response : {}", mvcResult.getResponse().getContentAsString());
// verify
verify(externalService, times(1)).getExternalResource(); // モックの呼び出しは1回を検証
}
}
この変更で特に大事なのは ExternalService
が外部リソースにアクセスせずモックデータを返すようになったことです。
これで外部リソースに依存せずにControllerのテストが実行できるようになりました。
##3-2. 片方をDI、もう片方をMock化してテストする
先ほどのテストでは DemoService
と ExternalService
の両方をMock化しましたが 片方だけMock化する こともできます。
例えば ExternalService
のみをMock化したい場合は以下のようになります。
package com.example.demo.controller;
import com.example.demo.filter.LogFilter;
import com.example.demo.service.ExternalService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@EnableWebMvc
@Slf4j
class DemoControllerWithOneSideMockTest {
MockMvc mockMvc;
@Autowired WebApplicationContext webApplicationContext;
@Autowired LogFilter logFilter;
@MockBean ExternalService externalService; // ExternalServiceは外部アクセスがあるのでMock化する
@BeforeEach
void beforeEach() {
MockitoAnnotations.initMocks(this);
mockMvc =
MockMvcBuilders.webAppContextSetup(webApplicationContext)
.addFilter(logFilter, "/*")
.build();
}
@AfterEach
void afterEach() {}
@Test
void hello() throws Exception {
mockMvc
.perform(get("/"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("hello"));
}
@Test
void divideSuccess() throws Exception {
mockMvc
.perform(get("/divide/10/3"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("3.33"));
}
@Test
void divideInvalidParameter() throws Exception {
mockMvc
.perform(get("/divide/10/aaa"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value("failure"))
.andExpect(jsonPath("$.message").value("divide.num2:must match \"[0-9]*\""));
}
@Test
void divideZeroError() throws Exception {
mockMvc
.perform(get("/divide/10/0"))
.andExpect(status().is5xxServerError())
.andExpect(jsonPath("$.status").value("failure"))
.andExpect(jsonPath("$.message").value("error has occurred."));
}
// 外部リソースの取得だけMockを使って検証するよ
@Test
void getExternalResource() throws Exception {
// mock
when(externalService.getExternalResource()).thenReturn("this is mock data for internal test.");
// request
MvcResult mvcResult =
mockMvc
.perform(get("/external"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("this is mock data for internal test."))
.andReturn();
//
log.info("external response : {}", mvcResult.getResponse().getContentAsString());
// verify
verify(externalService, times(1)).getExternalResource();
}
}
これでテスト時にBeanをDIするも、Mock化するも自由自在です。とても便利!!
#4. パラメータ化テスト
最後に Service
の単体テストを例にJUnit5で追加された便利機能 パラメータ化テスト
もサンプルを残しておきます。
テストデータだけを変えて同じケースを繰り返しテストすることが簡単にできるようになります。
package com.example.demo.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest
class DemoServiceTest {
@Autowired DemoService demoService;
@Test
void hello() {
assertEquals("hello", demoService.hello());
}
// @ParameterizedTest で割り算メソッドをいろんな入力パターンでテストするよ
@ParameterizedTest
@MethodSource("divideTestArgs") // "devideTestArgs"という名前のstaticメソッドを引数のソースに使うよ
void divide(String b1, String b2, String strExpect, boolean hasError) {
BigDecimal expect = Optional.ofNullable(strExpect).map(BigDecimal::new).orElse(null);
BigDecimal actual = null;
Exception error = null;
// 割り算メソッド実行
try {
actual = demoService.divide(new BigDecimal(b1), new BigDecimal(b2));
} catch (Exception e) {
error = e;
}
// 期待値と検証
assertEquals(expect, actual);
// エラーが発生していないか検証
assertEquals(hasError, error != null);
}
// divideテストのパラメータリスト
static List<Object[]> divideTestArgs() {
return List.of(
new Object[] {"1", "1", "1.00", false},
new Object[] {"0", "1", "0.00", false},
new Object[] {"5", "2", "2.50", false},
new Object[] {"10", "3", "3.33", false}, // 四捨五入(小数点第三桁切り捨て)
new Object[] {"11", "3", "3.67", false}, // 四捨五入(小数点第三桁切り上げ)
new Object[] {"1", "0", null, true}); // ゼロ除算
}
}
他にも @ ~ Source
は
-
@EnumSource
... Enumの全定数や特定の定数リスト -
@MethodSource
... Methodの戻り値リスト(上記例) -
@CsvSource
... CSVテキスト -
@CsvFileSource
... CSVファイル
などをソースとして利用できるようです。
JUnit5の機能については こちらのページ が非常にわかりやすかったです!
とても参考にさせていただきましたmm
あとがき
JUnitのテストに関する記事はたくさんありますが、古いバージョンの記事もかなり多いので、SpringBootとJUnit5からチャレンジするビギナーの方々の参考になれば嬉しいです。
-
MockMvcにはもう一つ
standalone
セットアップもあり、テスト対象のControllerやControllerAdvice、Configなど細かくカスタマイズできる単体テスト向きなモードもあります。 ↩