Spring Boot + Spring Retry のユニットテストに Spring Test + Mockito + JUnit 4 を使う

## 概要
- Spring Boot + Spring Retry によるサンプルプログラムを Spring Test で自動テストする
- テストには Spring Test + Mockito + JUnit 4 を使う

## 動作確認環境

- macOS Mojave
- OpenJDK 11.0.2
- Spring Boot 2.2.0 M4
- Spring Retry 1.2.4

## サンプルプログラムのソースコード一覧

```
├── pom.xml
└── src
    ├── main
    │   └── java
    │       └── sample
    │           ├── EchoController.java
    │           ├── EchoRepository.java
    │           └── EchoService.java
    └── test
        └── java
            └── sample
                └── EchoServiceTest.java
```

## Maven のビルド用ファイル pom.xml

今回は Maven でビルドを実行する。
pom.xml は [Spring Initializr](https://start.spring.io/) で生成したものをベースとした。
Spring Retry を使用するため dependencies に spring-retry を追加する。また、実行時に AOP クラスが必要なため spring-boot-starter-aop を追加する。
Spring Test を使用するため spring-boot-starter-test を追加する。

```xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.0.M4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>info.maigo.lab</groupId>
    <artifactId>sample</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>sample</name>
    <description>Sample project for Spring Boot</description>

    <properties>
        <java.version>11</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- https://mvnrepository.com/artifact/org.springframework.retry/spring-retry -->
        <dependency>
            <groupId>org.springframework.retry</groupId>
            <artifactId>spring-retry</artifactId>
            <version>1.2.4.RELEASE</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-aop -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

    <repositories>
        <repository>
            <id>spring-snapshots</id>
            <name>Spring Snapshots</name>
            <url>https://repo.spring.io/snapshot</url>
            <snapshots>
                <enabled>true</enabled>
            </snapshots>
        </repository>
        <repository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/milestone</url>
        </repository>
    </repositories>
    <pluginRepositories>
        <pluginRepository>
            <id>spring-snapshots</id>
            <name>Spring Snapshots</name>
            <url>https://repo.spring.io/snapshot</url>
            <snapshots>
                <enabled>true</enabled>
            </snapshots>
        </pluginRepository>
        <pluginRepository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/milestone</url>
        </pluginRepository>
    </pluginRepositories>

</project>
```

## ソースコード

### EchoController.java

Spring Boot による起動エントリクラス (@SpringBootApplication アノテーションを付加) であり、HTTPリクエストを受け取ってレスポンスを返す Controller クラス (@RestController アノテーションを付加) でもある。
Spring Retry を有効にするため @EnableRetry アノテーションを指定している。

```java
package sample;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@SpringBootApplication
@EnableRetry
@RestController
public class EchoController {

    public static void main(String[] args) {
        SpringApplication.run(EchoController.class, args);
    }

    @Autowired
    private EchoService service;

    @RequestMapping("/{message}")
    public Map<String, Object> index(@PathVariable("message") String message) {
        return new HashMap<String, Object>() {
            {
                put("result", service.echo(message));
            }
        };
    }
}
```

### EchoRepository.java

Repository クラス。

```java
package sample;

import org.springframework.stereotype.Repository;

import java.util.Random;

@Repository
public class EchoRepository {

    public String echo(String message) {
        // 二分の一の確率で例外が発生する
        if (new Random().nextBoolean()) {
            return message;
        } else {
            throw new RuntimeException("Failure");
        }
    }
}
```

### EchoService.java

Service クラス。
Spring Retry のアノテーション @Retryable を指定したメソッド内で RuntimeException が発生した際に3回まで同じメソッドを試行する。

```java
package sample;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

@Service
public class EchoService {

    @Autowired
    private EchoRepository repository;

    /**
     * 指定したメッセージを返します。
     * たまに失敗しますが、3回まで試行します。
     * @param message メッセージ
     * @return メッセージ
     */
    @Retryable(
            value = {RuntimeException.class},
            maxAttempts = 3,
            backoff = @Backoff(delay = 1000))
    public String echo(String message) {
        System.out.println("EchoService#echo: " + message);
        return repository.echo(message);
    }

    @Recover
    public String recover(RuntimeException e, String message) {
        System.out.println("EchoService#recover: " + message);
        throw e;
    }
}
```

### EchoServiceTest.java

EchoService クラスのテストクラス。
EchoRepository のモックオブジェクトを生成して、EchoService の挙動をテストする。

```java
package sample;

import org.junit.Test;
import org.junit.runner.RunWith;
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.context.junit4.SpringRunner;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;

@RunWith(SpringRunner.class) // Spring 用の JUnit を使う
@SpringBootTest // Spring Boot を起動してテストする
public class EchoServiceTest {

    @Autowired
    private EchoService service;

    // ApplicationContext にモックを追加する
    // (既存のオブジェクトがあれば置き換える)
    @MockBean
    private EchoRepository mockRepository;

    @Test
    public void testSuccess() {
        // EchoRepository のモックを用意する
        // このモックは正常に値を返す
        String message = "Zero";
        doReturn(message).when(mockRepository).echo(message);
        // テスト
        assertEquals(message, service.echo(message));
    }

    @Test
    public void testFairureSuccess() {
        // EchoRepository のモックを用意する
        // このモックは例外を1回投げたあと、正常に値を返す
        String message = "Uno";
        doThrow(new RuntimeException("Failure"))
            .doReturn(message)
            .when(mockRepository).echo(message);
        // テスト
        assertEquals(message, service.echo(message));
    }

    @Test
    public void testFairureFairureSuccess() {
        // EchoRepository のモックを用意する
        // このモックは例外を2回投げたあと、正常に値を返す
        String message = "Due";
        doThrow(new RuntimeException("Failure"))
            .doThrow(new RuntimeException("Failure"))
            .doReturn(message)
            .when(mockRepository).echo(message);
        // テスト
        assertEquals(message, service.echo(message));
    }

    @Test(expected = RuntimeException.class) // RuntimeException が発生することを想定
    public void testFairureFairureFairure() {
        // EchoRepository のモックを用意する
        // このモックは例外を3回投げる
        String message = "Tre";
        doThrow(new RuntimeException("Failure"))
            .doThrow(new RuntimeException("Failure"))
            .doThrow(new RuntimeException("Failure"))
            .when(mockRepository).echo(message);
        // テスト
        service.echo(message);
    }
}
```

## テストを実行

mvn test コマンドを入力すると、Spring Boot が起動してテストが実行される。

```
$ mvn test
[INFO] Scanning for projects...
[INFO] 
[INFO] -----------------------< info.maigo.lab:sample >------------------------
[INFO] Building sample 0.0.1-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO] 
(中略)
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::             (v2.2.0.M4)

(中略)
2019-07-20 20:12:34.687  INFO 25426 --- [           main] sample.EchoServiceTest                   : Started EchoServiceTest in 3.407 seconds (JVM running for 4.983)
EchoService#echo: Due
EchoService#echo: Due
EchoService#echo: Due
EchoService#echo: Tre
EchoService#echo: Tre
EchoService#echo: Tre
EchoService#recover: Tre
EchoService#echo: Uno
EchoService#echo: Uno
EchoService#echo: Zero
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 8.643 s - in sample.EchoServiceTest
2019-07-20 20:12:39.930  INFO 25426 --- [       Thread-1] o.s.s.concurrent.ThreadPoolTaskExecutor  : Shutting down ExecutorService 'applicationTaskExecutor'
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  14.650 s
[INFO] Finished at: 2019-07-20T20:12:40+09:00
[INFO] ------------------------------------------------------------------------
```

## 参考資料

- [GitHub \- spring\-projects/spring\-retry](https://github.com/spring-projects/spring-retry)
- [Testing improvements in Spring Boot 1\.4](https://spring.io/blog/2016/04/15/testing-improvements-in-spring-boot-1-4)
- [Spring Boot Reference Documentation - 4.25. Testing](https://docs.spring.io/spring-boot/docs/2.2.0.M4/reference/html/#boot-features-testing)
- [SpringBootTest \(Spring Boot Docs 2\.2\.0\.M4 API\)](https://docs.spring.io/spring-boot/docs/2.2.0.M4/api/org/springframework/boot/test/context/SpringBootTest.html)
- [SpringRunner \(Spring Framework 5\.2\.0\.M3 API\)](https://docs.spring.io/spring/docs/5.2.0.M3/javadoc-api/org/springframework/test/context/junit4/SpringRunner.html)
- [org\.junit \(JUnit API\)](https://junit.org/junit4/javadoc/latest/org/junit/package-summary.html)
- [MockBean \(Spring Boot Docs 2\.2\.0\.M4 API\)](https://docs.spring.io/spring-boot/docs/2.2.0.M4/api/org/springframework/boot/test/mock/mockito/MockBean.html)
- [Spring Retry を利用して宣言型のリトライ処理を実装する \- Qiita](https://qiita.com/niwasawa/items/4938f5a79fa225471dd6)
- [@MockBeanと@Mock、Mockito\.mock\(\)の違い \| 技術系のメモ](https://humble-mumble.com/mockbean-vs-mock/)
- [心地良すぎるモックライブラリ Mockito　〜その2〜 \- A Memorandum](http://etc9.hatenablog.com/entry/20101109/1289304795)
- [Mockito～doReturn/whenとwhen/thenReturnの違い \| GWT Center](https://www.gwtcenter.com/difference-between-doreturn-and-when)
- [Spring BootでAutowiredされるクラスをMockitoでモックする \- Qiita](https://qiita.com/Plemling138/items/c48b3805cafa9dec7675)