Controller の単体テストを整理する（Spring Boot）

# はじめに
前回、SpringBoot ＋ MyBatisの整理をしましたが、今回はControllerの単体テストを整理します。

Controllerからは、Serviceが呼び出されますが、そのServiceをモック化してテストをしていきます。
私は、JMockitを長く使ってましたが、Spring Boot Testでは、Mockito が使われるようなので、今回はSpringに合わせて実装し見ます。

図にするとこんな感じです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132118/d56746d9-750a-20fe-8aed-9d072c4d587d.png)

# 環境
Windows 11
Eclipse
Java21
Gradle
Spring Boot 3.4.0
Spring Boot Test 3.4.0
Lombok
Mockito 5.14.2

# 単体テストの方針
以下のような方針でテストの実行方法を検討することにします。
ここでは具体的なテストケース、検証内容には触れません

・モックを利用する
・メソッドの呼び出し回数、引数をチェックする
・実行結果及び応答値を検証する

# やってみる
## テスト対象のクラス作る
Entityクラス
```java:ExampleEntity.java
import lombok.Data;

@Data
public class ExampleEntity {

	long id;
	String message;
}
```

Serviceクラス
```java:ExampleService.java
import org.springframework.stereotype.Service;

import sample.entity.ExampleEntity;

@Service
public class ExampleService {

	public ExampleEntity getExampleById(Long id) {
		ExampleEntity entity = new ExampleEntity();
		entity.setId(id);
		entity.setMessage("Example " + id);
		return entity;
	}
}
```

Controllerクラス
```java:ExampleController.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import sample.entity.ExampleEntity;
import sample.service.ExampleService;

@RestController
@RequestMapping("/api/example")
public class ExampleController {

	@Autowired
	private ExampleService exampleService;

	@GetMapping("/{id}")
	public ResponseEntity<ExampleEntity> getExample(@PathVariable Long id) {
		ExampleEntity response = exampleService.getExampleById(id);
		return ResponseEntity.ok(response);
	}
}
```


## テストクラスを作る
```java:ExampleContoller.java
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

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.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import sample.entity.ExampleEntity;
import sample.service.ExampleService;

// WebMvcTestアノテーションで、ExampleControllerのみをテスト対象に限定してWeb層のテストを設定。
@WebMvcTest(controllers = ExampleController.class)
class ExampleControllerTest {

    // MockMvcをDIで取得し、HTTPリクエスト/レスポンスのテストを行う。
	@Autowired
	private MockMvc mockMvc;

    // ExampleServiceのモックを作成してDI。モックの動作を設定してテストに利用。
	@MockitoBean
	private ExampleService exampleService;

    
	@Test
	void getExample_shouldReturnExampleResponse() throws Exception {

		// モックの設定
		Long testId = 1L;
		ExampleEntity mockResponse = new ExampleEntity();
		mockResponse.setId(testId);
		mockResponse.setMessage("Mocked Example " + testId);
  
        // モックされたexampleServiceのgetExampleByIdメソッドが呼ばれたとき、mockResponseを返すよう設定。
		when(exampleService.getExampleById(testId)).thenReturn(mockResponse);

		// テスト実行
		mockMvc.perform(get("/api/example/{id}", testId)
				.contentType(MediaType.APPLICATION_JSON))
				.andExpect(status().isOk())            // HTTPステータスコード200（OK）を期待。
				.andExpect(jsonPath("$.id").value(1))  // JSONの"id"フィールドを検証
				.andExpect(jsonPath("$.message").value("Mocked Example 1")); // レスポンスJSONの"message"フィールドが期待通りであることを検証。

		// Serviceが1回だけ呼ばれたことを検証
		verify(exampleService, times(1)).getExampleById(testId);

		// Service2回以上呼ばれたことを検証
		//verify(exampleService, atLeast(2)).getExampleById(testId);
		
		//一度も呼び出されていない場合の検証：
		//verifyNoInteractions(exampleService);
		
        // サービスの特定メソッドが呼び出されていないことを検証
        //verify(exampleService, times(0)).getExampleById(any());

	}
}
```

andExceptで使用できるメソッドの主な例を記載しておきます。
| **カテゴリー**           | **メソッド**                                                           | **説明**                                                   |
|--------------------------|-----------------------------------------------------------------------|------------------------------------------------------------|
| **レスポンスステータス**   | `status().isOk()`                                                     | ステータスコード200（OK）を検証                            |
|                          | `status().isCreated()`                                                | ステータスコード201（Created）を検証                       |
|                          | `status().isNotFound()`                                               | ステータスコード404（Not Found）を検証                     |
|                          | `status().isBadRequest()`                                             | ステータスコード400（Bad Request）を検証                   |
| **レスポンスヘッダー**     | `header().exists("Header-Name")`                                      | 指定したヘッダーが存在することを検証                        |
|                          | `header().string("Header-Name", "Header-Value")`                      | 指定したヘッダーの値が一致することを検証                    |
|                          | `header().doesNotExist("Header-Name")`                                | 指定したヘッダーが存在しないことを検証                      |
| **レスポンス本文**         | `content().string("Expected Response Body")`                          | レスポンス本文が完全一致することを検証                      |
|                          | `content().json("{\"key\":\"value\"}")`                               | レスポンスが指定したJSONと一致することを検証                |
|                          | `content().xml("<tag>value</tag>")`                                   | レスポンスが指定したXMLと一致することを検証                 |
|                          | `content().contentType(MediaType.APPLICATION_JSON)`                  | Content-TypeがJSONであることを検証                         |
|                          | `content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)`     | Content-TypeがJSONと互換性があることを検証                 |
|                          | `content().bytes(byte[])`                                             | レスポンスのバイナリデータが一致することを検証              |
| **JSONフィールド**         | `jsonPath("$.id").value(1)`                                           | JSONの"id"フィールドが1であることを検証                    |
|                          | `jsonPath("$.name").exists()`                                         | JSONの"name"フィールドが存在することを検証                  |
|                          | `jsonPath("$.details").isEmpty()`                                     | JSONの"details"フィールドが空であることを検証               |
|                          | `jsonPath("$.items").isArray()`                                       | JSONの"items"フィールドが配列であることを検証               |
| **レスポンスCookie**       | `cookie().exists("SESSION")`                                          | 特定のCookieが存在することを検証                           |
|                          | `cookie().value("SESSION", "expectedValue")`                         | 特定のCookieの値が一致することを検証                        |
|                          | `cookie().maxAge("SESSION", 3600)`                                   | Cookieの有効期限が一致することを検証                       |
| **その他の検証**           | `request().attribute("attributeName", "expectedValue")`               | リクエストの属性が特定の値であることを検証                  |
|                          | `flash().attribute("flashAttributeName", "expectedValue")`            | Flashスコープの属性が特定の値であることを検証               |
|                          | `redirectedUrl("/expected/url")`                                      | リダイレクト先のURLが一致することを検証                     |
|                          | `redirectedUrlPattern("/expected/*")`                                 | リダイレクトURLが指定したパターンに一致することを検証       |


## 実行する
JUnitなので、Eclipse上で実行してみてください。
以下のような実行結果が表示されればOKです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132118/015470fd-50a2-cbb7-0d8f-743b38c4dc06.png)


# さいごに

今回は、Contollerクラスを対象にしました。次回はServiceクラスを予定していますが、
基本的にはMock化するだけなので実施方法は同じです（呼び出し部分が若干異なる）