Spring Boot 3.1系で単体テストをやってみた

# はじめに
この記事はアイスタイル [Advent Calendar 2023](https://qiita.com/advent-calendar/2023/istyle) 16日目の記事です。
こんにちは、oniyanagigです。
今年からアイスタイルに入社して、Spring Bootを用いたバックエンドのアプリ開発に携わってきました。
今回はその振り返りもかねて、Spring Boot3.1におけるモック化を含めた単体テストを実装してみたいと思います。
プロジェクトの作成から始めるので、
* これからSpring Bootで開発するよ
* 単体テストを初めて実装するよ

という方のご参考になれば幸いです。

## 環境
### 実行環境
Windows＋WSL2
IntelliJ IDEA 2023.2.5

### バージョン情報
| ソフトウェア | バージョン | 
| --- | --- |
| Java | 21|
| Spring Boot | 3.1.5 |
| Gradle | 8.4 |
| JUnit | 5 |
| mokito | 5.6.0 |

# 事前準備
## プロジェクト作成・ロード
[Spring Initializr](https://start.spring.io/)を使います。  
今回はJava21とSpring Boot 3、Gradleを用いて開発します。
以下の通りに入力してGENERATEをクリックし、DLしたファイルを解凍します。
![spring_initializr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583899/1afe857d-8d86-4ae0-d544-a4ba1b9bba2f.png)
| 項目 | 値 |
| --- | --- |
| Project | Gradle - Groovy |
| Language | Java |
| Spring Boot | 3.1.5 |
| Project Metadata | よしなに |
| Packaging | Jar |
| Java | 21(最新版) |
| Dependencies | Spring Boot DevTools, Lombok |

Intellij上で「ファイルまたはプロジェクトを開く」で解凍したフォルダを選択します。
Gradleツールウィンドウから「Gradleプロジェクトの再ロード」をクリックして、外部ライブラリをDLします。
## Gradleのバージョンについて
Spring Initializrでプロジェクトを作成した場合、Gradleのバージョンが古くなっている可能性があるので注意が必要です。
```
# バージョンは下記ファイルに記載
$ gradle/wrapper/gradle-wrapper.properties
~
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
~

# 想定外の場合は以下を実行
$ ./gradlew wrapper --gradle-version=8.4
```

# テスト対象の実装
今回は単体テストの実装を主目的として、CRUDの実装は省略しService層の実装のみ行います。
また、モックを用いたテストを行うため、外部連携としてS3へ接続することとします。

機能の要件は以下の通りです。
1. 独自で定義するModelを引数として受け取る
1. AWS上のS3にオブジェクトをPushする
    1. この際リクエストボディはStringとする
1. 返り値なし

## ライブラリの追加
以下の通り`build.gradle`に追記します。
```gradle
dependencies {
    // ~ 以下追加 ~
	// S3接続で使用
	implementation platform('software.amazon.awssdk:bom:2.21.6')
	implementation 'software.amazon.awssdk:s3'
 
	// ModelをStringに変換する際に使用
	implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.3'
 
	// Testで使用（spring-boot-starterでも入るがバージョン古い場合があるので明示）
	testImplementation 'org.mockito:mockito-core:5.6.0'
	testImplementation 'org.mockito:mockito-junit-jupiter:5.6.0'
	testImplementation 'org.mockito:mockito-inline:5.2.0'
}
```

## Modelの実装
今回は以下のModelをリクエストとして受け取る想定とします。
```Java
package com.example.SpringBoot3Demo.model;

import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(fluent = true, chain = true)
public class RequestModel {
  private String name;
  private Integer age;
}
```

## S3Clientの実装
Clientはawssdkのものを利用するため、ここではClientのBeanを生成するConfigクラスを定義します。
```Java
package com.example.SpringBoot3Demo.client;

import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

@Data
@Configuration
@RequiredArgsConstructor
public class S3Config {
  private String region;

  @Bean
  public S3Client s3Client(){
    return S3Client.builder()
        .region(Region.of(this.region))
        .build();
  }
}
```

## Serviceの実装
ServiceはS3公式のリファレンスにならう形とします。変更点として、リクエストボディの形式がStringなので、変換処理を加えています。
```Java
package com.example.SpringBoot3Demo.service;

import com.example.SpringBoot3Demo.model.RequestModel;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

@Data
@Component
@RequiredArgsConstructor
@Slf4j
public class DemoService {
  private String bucketName;
  private final S3Client s3Client;

  public void exec(RequestModel request) {
    // 参考：https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/example_s3_PutObject_section.html
    PutObjectRequest putOb = PutObjectRequest.builder()
        .bucket(bucketName)
        .key("putObject.txt")
        .build();

    // リクエストをStringに変換する
    val objectMapper = new ObjectMapper();
    String requestAsString = "";
    try {
      requestAsString = objectMapper.writeValueAsString(request);
    } catch (JsonProcessingException e) {
      log.warn("JsonProcessingException");
    }

    // StringでオブジェクトをPutする
    this.s3Client.putObject(putOb, RequestBody.fromString(requestAsString));
  }
}
```


# テスト
ここから本題です。
今回のテストは以下の要件を満たすことを目指します。
1. S3Clientをモック化すること
    * モック化したメソッドに正しい引数が渡されること
1. 分岐網羅であること
    * 例外処理に関しても試験が行われること

それぞれの要件を満たすようにテストコードを追加していきます。


```Java
package com.example.SpringBoot3Demo.service;

class DemoServiceTest {


}
```


## S3Clientのモック化
まずは、`S3Client`のモックを定義し、Spring BootのDIコンテナを利用してテスト対象のクラスに注入します。
```Java
// Spring BootのDIを利用するため、@SpringBootTestアノテーションを付与
@SpringBootTest
class DemoServiceTest {

  // Mockを定義
  @MockBean
  S3Client s3Client;

  // DemoService内のS3Clientが上記モックで置き換えられ、DIコンテナを通じて呼び出される
  @Autowired
  DemoService demoService;
  
}
```

1. SpringのDIコンテナを利用するため`@SpringBootTest`アノテーションを付与
1. `@MockBean`で`S3Client`をモックとしてアプリケーションコンテキストに追加
1. ②でモック化された`S3Client`が`DemoService`で呼ばれる

といった流れで、`S3Client`がモック化されます。

上記で定義したモックを利用したテストが以下になります。
```Java
// Spring BootのDIを利用するため、@SpringBootTestアノテーションを付与
@SpringBootTest
class DemoServiceTest {

  // Mockを定義
  @MockBean
  S3Client s3Client;

  // DemoService内のS3Clientが上記モックで置き換えられ、DIコンテナを通じて呼び出される
  @Autowired
  DemoService demoService;
  
  @Test
  void test_exec_S3Clientのモック化() {
    
    // サンプルリクエストを作成
    RequestModel request = new RequestModel()
        .age(1)
        .name("test");

    // モックに渡す引数を評価するため、ArgumentCaptorを定義（2つ引数があるため2つ定義）
    ArgumentCaptor<PutObjectRequest> putObjectRequestArgumentCaptor =
        ArgumentCaptor.forClass(PutObjectRequest.class);
    ArgumentCaptor<RequestBody> requestBodyArgumentCaptor =
        ArgumentCaptor.forClass(RequestBody.class);

    // S3ClientのputObjectが呼ばれた際のモックの振る舞いを定義（今回は空のPutObjectResponseを返す）
    doReturn(PutObjectResponse.builder().build()).when(s3Client)
            .putObject(
                putObjectRequestArgumentCaptor.capture(),
                requestBodyArgumentCaptor.capture());

    // テスト対象のメソッドを実行
    demoService.exec(request);

    // モックが呼び出されていることの評価
    verify(s3Client,times(1))
        .putObject(
            putObjectRequestArgumentCaptor.capture(),
            requestBodyArgumentCaptor.capture());
    
    // ArgumentCaptorでキャプチャした引数値の評価
    // Stringに変換して評価する（変換処理は省略）
    String actualRequestString = this.convertRequestBodyToString(requestBodyArgumentCaptor.getValue());
    String expectRequestString = this.writeValueAsString(request);
    assertEquals(expectRequestString, actualRequestString);
    
  }
  
  private String writeValueAsString(RequestModel requestModel) {
    // 変換処理
  }
  private String convertRequestBodyToString(RequestBody requestBody) {
    // 変換処理
  }
}
```

ポイントは以下の二点です。
* `doReturn`によるモック化されたメソッドの振る舞いの定義
    * verifyでメソッドが呼ばれたことを評価
* `ArgumentCaptor`によるモックに渡される引数のキャプチャと評価
    * `doReturn`時のモックが受け取る引数をキャプチャする
    * キャプチャした引数を`assertEquals`で評価

ここまでで一つ目の要件である「S3Clientをモック化」が実装できました。

## 分岐網羅(例外処理のテスト)
続いて、文字列変換の例外処理のテストを実装します。
今回のテスト対象では、以下のように例外処理時にエラーログを出力する形となっていました。
```Java
    // リクエストをStringに変換する
    val objectMapper = new ObjectMapper();
    String requestAsString = "";
    try {
      requestAsString = objectMapper.writeValueAsString(request);
    } catch (JsonProcessingException e) {
      log.warn("JsonProcessingException");
    }
```
今回はObjectMapperをモック化し、意図的に例外をThrowすることでこの箇所を試験します。
テストコードは以下の通りです。
```Java
@SpringBootTest
class DemoServiceTest {

  @MockBean
  private S3Client s3Client;

  @Autowired
  private DemoService demoService;

  // ログ出力先のモックを定義
  @Mock
  private Appender<ILoggingEvent> mockAppender;

  //~省略~

  @Test
  void test_exec_例外処理() {

    // サンプルリクエストを作成
    RequestModel request = new RequestModel()
        .age(1)
        .name("test");

    // ログ出力をキャプチャするためのArgumentCaptor
    ArgumentCaptor<LoggingEvent> loggingEventArgumentCaptor =
        ArgumentCaptor.forClass(LoggingEvent.class);
    // DemoServiceのログ出力先をモック化
    Logger logger = (Logger) LoggerFactory.getLogger(DemoService.class);
    logger.addAppender(mockAppender);

    // ObjectMapperのコンストラクタをモック化。
    // 今回はモックの振る舞いとして、writeValueAsStringが呼ばれたらJsonProcessingExceptionをThrowする形とした
    // mockConstructionの返り値はCloseする必要があるため、try-with-resourceで宣言
    try (MockedConstruction<ObjectMapper> ignored = mockConstruction(
        ObjectMapper.class,
        (mock, ctx) ->
            doThrow(JsonProcessingException.class).when(mock).writeValueAsString(any()))) {

      // テスト対象のメソッドを実行
      demoService.exec(request);

      // ログ出力が実行されていることを評価
      verify(mockAppender, times(1))
          .doAppend(loggingEventArgumentCaptor.capture());
      // ログ内容の評価
      assertEquals(
          Level.WARN,
          loggingEventArgumentCaptor.getValue().getLevel());
      assertEquals(
          "JsonProcessingException",
          loggingEventArgumentCaptor.getValue().getMessage());
    }
  }
  //~省略~
}
```

上記で、例外処理パターンのテストが実装できました。
ポイントは以下の点になります。
* コンストラクタのモック化
    * `mockConstruction`を利用して`ObjectMapper`のコンストラクタをモック化
    * `mockConstruction()`の第二引数で、メソッド呼び出し時に例外を返すFunctionを定義
* ログのキャプチャ
    * ログのAppenderをモック化
    * ログ出力をキャプチャするためのArgumentCaptorを定義
    * 例外時のエラーログを評価⇒正しく例外処理が行われていることを担保

これで、今回のテストの要件を満たすことができました。
最終的に完成したテストコードは以下の通りです。
<details><summary>test_完成</summary>

```Java
package com.example.SpringBoot3Demo.service;

import ...

@SpringBootTest
class DemoServiceTest {

  @MockBean
  private S3Client s3Client;

  @Autowired
  private DemoService demoService;

  @Mock
  private Appender<ILoggingEvent> mockAppender;


  @Test
  void test_exec_S3Clientのモック化() {
  
    RequestModel request = new RequestModel()
        .age(1)
        .name("test");

    ArgumentCaptor<PutObjectRequest> putObjectRequestArgumentCaptor =
        ArgumentCaptor.forClass(PutObjectRequest.class);
    ArgumentCaptor<RequestBody> requestBodyArgumentCaptor =
        ArgumentCaptor.forClass(RequestBody.class);

    doReturn(PutObjectResponse.builder().build()).when(s3Client)
        .putObject(
            putObjectRequestArgumentCaptor.capture(),
            requestBodyArgumentCaptor.capture());

    demoService.exec(request);

    verify(s3Client, times(1))
        .putObject(
            putObjectRequestArgumentCaptor.capture(),
            requestBodyArgumentCaptor.capture());

    String actualRequestString = this.convertRequestBodyToString(
        requestBodyArgumentCaptor.getValue());
    String expectRequestString = this.writeValueAsString(request);
    assertEquals(expectRequestString, actualRequestString);

  }

  @Test
  void test_exec_例外処理() {

    RequestModel request = new RequestModel()
        .age(1)
        .name("test");

    ArgumentCaptor<LoggingEvent> loggingEventArgumentCaptor =
        ArgumentCaptor.forClass(LoggingEvent.class);
    Logger logger = (Logger) LoggerFactory.getLogger(DemoService.class);
    logger.addAppender(mockAppender);

    try (MockedConstruction<ObjectMapper> ignored = mockConstruction(
        ObjectMapper.class,
        (mock, ctx) ->
            doThrow(JsonProcessingException.class).when(mock).writeValueAsString(any()))) {

      demoService.exec(request);

      verify(mockAppender, times(1))
          .doAppend(loggingEventArgumentCaptor.capture());
      assertEquals(
          Level.WARN,
          loggingEventArgumentCaptor.getValue().getLevel());
      assertEquals(
          "JsonProcessingException",
          loggingEventArgumentCaptor.getValue().getMessage());
    }
  }

  private String writeValueAsString(RequestModel requestModel) {
    // ~省略~
  }

  private String convertRequestBodyToString(RequestBody requestBody) {
    // ~省略~
  }
}
```
</details>

# 終わりに
簡単ですが、以上で本稿については終わりとなります。稚拙な内容ではありますが、何かのご参考になれば幸いです。