【初心者向け】SpringBootにおける単体テストの基本事項(ログ、例外処理)

前回の記事で書ききれなかった部分について書いていきます。
前回の記事

https://qiita.com/Shukupon/items/373939bc0dea4f25b007

コード
https://github.com/Shukupon/TestTemplate

## 前提条件(前回と同じ)

Java11
SpringBoot 2.5.6
Junit5
H2
MyBatis
REST API

今回は以下の項目についてまとめていきます。

- ログのテスト
- 例外のテスト

## ログのテスト
ログのテストは標準出力の中身を取得してあげることになるのでまた少し特殊な書き方になります。
以下がテスト対象であるログを出力するコントローラークラスです。

```java
package com.example.demo.presentation.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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 {

	private static final Logger logger = LoggerFactory.getLogger(DemoController.class);

	@Autowired
	DemoService demoService;

	@PostMapping("/shopping")
	public ResponseEntity<Boolean> shopping(@RequestBody Goods goods) throws Exception {

		logger.info("処理開始");
		Boolean response = demoService.decide(goods);
		logger.info("処理完了");

		HttpHeaders responseHeaders = new HttpHeaders();
		responseHeaders.setContentType(MediaType.APPLICATION_JSON);
		return ResponseEntity.status(HttpStatus.OK).headers(responseHeaders).body(response);
	}
}
```
以下がこれに対応するテストクラスです。

```java
package com.example.demo.presentation.controller;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
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.junit.jupiter.api.extension.ExtendWith;
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.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
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)
@ExtendWith(OutputCaptureExtension.class)
public class DemoControllerTest {

	@Autowired
	MockMvc mockMvc;

	@MockBean
	DemoService mockService;

	@Test
	public void shoppingTest01(CapturedOutput output) 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);
		assertThat(output).contains("処理開始");
		assertThat(output).contains("処理完了");

	}

	@Test
	public void shoppingTest02(CapturedOutput output) throws Exception {

		// 使用する商品情報の準備
		Goods goods = createGoods();

		// リクエストの準備
		ObjectMapper objectMapper = new ObjectMapper();
		String requestJson = objectMapper.writeValueAsString(goods);

		// モックの設定
		doThrow(Exception.class).when(mockService).decide(any(Goods.class));

		// execute
		String responseJson = this.mockMvc
				.perform(post("/demo/shopping").contentType(MediaType.APPLICATION_JSON).content(requestJson))
				.andExpect(status().is5xxServerError()).andReturn().getResponse().getContentAsString();

		verify(mockService, times(1)).decide(any(Goods.class));
		assertTrue(responseJson.equals("an error has occured."));
		assertThat(output).contains("処理開始");

	}

	private Goods createGoods() {

		// Test用のデータ作成
		Goods goods = new Goods();
		goods.setName("アボガド");
		goods.setPrice(180);
		return goods;
	}
}
```
`@ExtendWith(OutputCaptureExtension.class)`をテストクラスに付け、
テストメソッドの引数に`CapturedOutput`を指定してあげることで、標準出力を取得してあげることができます。
この引数の内容を`assertThat(output).contains("処理完了");`のようにassertしてあげることでログの内容をテストできます。
この方法だとモック化の必要もないので簡単にテストを書くことが可能です。

## 例外のテスト
例外がthrowされる場合のテストは、そのまま処理を実行してしまうとエラーで処理が途中で終了してしまうだけになるため、こちらも少し特殊な書き方になります。
以下がテスト対象の例外処理を実装したクラスです。

```java
package com.example.demo.application.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;

import com.example.demo.application.repository.DemoRepository;
import com.example.demo.bean.Goods;

// 業務処理を行うクラス
@Service
public class DemoService {

	private static final Logger logger = LoggerFactory.getLogger(DemoService.class);

	@Autowired
	DemoRepository demoRepository;

	//
	public boolean decide(Goods goods) throws Exception {
		try {
			Goods ordinaryGoods = demoRepository.findByName(goods.getName());
			if (goods.getPrice() <= ordinaryGoods.getPrice())
				return true;
			return false;
		} catch (DataAccessException e) {
			logger.debug("データベースで問題が発生しました");
			throw new Exception(e);
		}

	}
}
```

以下がこれに対応するテストクラスです。

```java
package com.example.demo.application.service;

import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doThrow;
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.dao.DataAccessException;
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() throws Exception {

		// 渡す引数の準備
		Goods goods = createGoods();

		// どんなString型の引数でも作成したオブジェクトを返すようにmockを定義
		when(mockRepository.findByName(Mockito.anyString())).thenReturn(createOrdinaryGoods());

		// assert
		assertTrue(target.decide(goods));
	}

	// 異常系のテストメソッド
	@Test
	public void decideTest02() throws Exception {

		// 渡す引数の準備
		Goods goods = createGoods();

		// どんなString型の引数でもDataAccessExceptionを返すようにmockを定義
		doThrow(new DataAccessException("") {
		}).when(mockRepository).findByName(Mockito.anyString());

		// throwされるExceptionのクラスをassert
		Throwable e = assertThrows(Exception.class, () -> {
			target.decide(goods);
		});

		// throwされるExceptionの元のクラスをassert
		assertTrue(e.getCause() instanceof DataAccessException);
	}

	// 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;
	}

}
```
まず例外処理を行わせるためにmockが何らかのExceptionを投げるように`doThrow`で設定をしてあげます。
そして`assertThrows`の第一引数にテスト対象が投げるはずの期待値のExceptionのクラス、第二引数にテスト対象を実行する関数を指定してあげることで、実際に投げられたExceptionのクラスと期待値のクラスを比較してassertすることが可能です。
また、
`Throwable e = assertThrows(Exception.class, () -> {target.decide(goods);		});`
のようにThrowableにassertThrowsの結果を格納し、
`assertTrue(e.getCause() instanceof DataAccessException);`
のように`getCause()`してあげることでExceptionの原因をassertすることも可能です。

## まとめ
前回の記事と合わせてとりあえず一通り自分の学んだ単体テストの書き方についてはまとめられたと思います。
今回はテスト対象が分岐もほとんどなく処理も単純だったのでテストクラスが小さく収まりましたが、実際に現場で作られるような処理に対するテストクラスは思っている以上に巨大になりがちです。テストを実施する方法が大体わかっているからまあ大丈夫だろうという考えでは、後々実際にテストを書く時に泣くことになる（冗談じゃなく泣きます）ので、テストを楽に書くことができるような処理を意識して書くことも重要です。（if文やswitch文をネストしないだとかassertしやすい引数を使うだとか色々あります）
とはいえ、この記事の内容が基本事項であることに変わりはないので忘れた時に見返してみるといいかもしれません。