Springのテストを実際のアプリケーションでどうやるか考えてみた

# はじめに
## この記事で何をするのか
Spring/Spring Bootはテスト関連の機能がとても充実しています。これらの機能を、実際のアプリケーションにどのようにつかっていくかを考えました。

あくまで僕個人の案なので、「いや、ここはこうしたほうがいいよ！」とか「自分はこうしてるよ！」とかありましたら是非コメントください。コメントは「優しく」お願いします。

## Spring/Spring Bootのテスト関連機能
この記事では、テスト関連機能の基礎は解説しません。代わりに@shindo_ryoさんの資料をご覧ください。とても素晴らしいまとめです。

- [YouTube動画](https://www.youtube.com/watch?v=TSuCehg7J24)
- [スライド](https://speakerdeck.com/rshindo/spring-fest-2020)

spring-security-testについては[@opengl_8080さんのブログ](https://qiita.com/opengl-8080/items/eaa8f4eb9286a3df7986)をご覧ください。

`@MybatisTest` については[@kazuki43zooさんのブログ](https://qiita.com/kazuki43zoo/items/ea79e206d7c2e990e478#mybatistestmybatis-spring-boot-starter-test%E3%81%AE%E5%88%A9%E7%94%A8)をご覧ください。

## サンプルアプリの技術構成
- Spring Boot 2.4.5
- Spring MVC
- Thymeleaf
- Spring Security
- MyBatis

ソースコードは[GitHub](https://github.com/MasatoshiTada/spring-boot-test-sample)に置いてあります。

# MyBatis Mapperインタフェースの単体テスト
`@MybatisTest` を利用します。

このアノテーションは、デフォルトで組み込みDBを利用します。そうではなくMySQLなどのデータベースを利用したい場合は `@AutoConfigureTestDatabase` も付加して `Replace.NONE` を指定する必要があります。

`@MybatisTest` を利用するには、mybatis-spring-boot-starter-testを依存性に含める必要があります。Mavenの場合は👇のように設定します。（バージョン番号はmybatis-spring-boot-starterと同じにしてください）

```xml:pom.xml
	<dependencies>
        ...
		<dependency>
			<groupId>org.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter-test</artifactId>
			<version>2.1.4</version>
			<scope>test</scope>
		</dependency>
        ...
	<dependencies>
```

> CI環境などでデータベースの準備を楽にするためには、[Testcontainers](https://www.testcontainers.org/)を使うといいかもしれません。詳しくは@kazuki43zooさんのブログ（[Testcontainersを利用したSpring Bootアプリのテスト](https://qiita.com/kazuki43zoo/items/588bbfab3db9d6c0199f)）をご確認ください。

```java
@MybatisTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class CustomerMapperTest {

    @Autowired
    CustomerMapper customerMapper;

    @Autowired
    JdbcTemplate jdbcTemplate;

    @Test
    void findAllメソッドで5件取得できる() {
        List<Customer> customerList = customerMapper.findAll();
        assertEquals(5, customerList.size());
    }

    @Test
    void saveメソッドで1件追加できる() {
        Customer newCustomer = new Customer("天", "山﨑", "tyamasaki@sakura.com", LocalDate.of(2005, 9, 28));
        customerMapper.save(newCustomer);
        assertEquals(6, JdbcTestUtils.countRowsInTable(jdbcTemplate, "customer"));
    }
}
```

# Serviceクラスの単体テスト
ServiceクラスはMapperを使っているので、MockitoでMapperをモック化します。特にSpring Testの機能は使いません。

```java
public class CustomerServiceImplTest {

    CustomerService customerService;
    CustomerMapper customerMapper;

    @BeforeEach
    void setUp() {
        // CustomerMapperのモックを作成
        customerMapper = mock(CustomerMapper.class);
        // CustomerMapperのモックを利用してCustomerServiceImplインスタンスを作成
        customerService = new CustomerServiceImpl(customerMapper);
    }

    @Test
    void findAllメソッドで5件取得できる() {
        // CustomerMapperのfindAll()に仮の戻り値を設定
        when(customerMapper.findAll()).thenReturn(List.of(
                new Customer(1, "友香", "菅井", "ysugai@sakura.com", LocalDate.of(1995, 11, 29)),
                new Customer(2, "久美", "佐々木", "ksasaki@hinata.com", LocalDate.of(1996, 1, 22)),
                new Customer(3, "美玖", "金村", "mkanemura@hinata.com", LocalDate.of(2002, 9, 10))
        ));
        // テスト対象のメソッドを実行
        List<Customer> customerList = customerService.findAll();
        // テスト対象の戻り値を検証
        assertEquals(3, customerList.size());
        // CustomerMapperのfindAll()が1回呼ばれていることをチェック
        verify(customerMapper, times(1)).findAll();
    }

    @Test
    void saveメソッドで1件追加できる() {
        // テスト対象メソッドに与える引数
        Customer newCustomer = new Customer("天", "山﨑", "tyamasaki@sakura.com", LocalDate.of(2005, 9, 28));
        // CustomerMapperのsave()が実行されたら何も行わないよう設定
        doNothing().when(customerMapper).save(newCustomer);
        // テスト対象のメソッドを実行
        customerService.save(newCustomer);
        // CustomerMapperのsave()が1回呼ばれていることをチェック
        verify(customerMapper, times(1)).save(newCustomer);
    }
}
```

# UserDetailsServiceのテスト
Spring Securityで利用する `UserDetailsService` 実装クラスのテストは、Serviceクラスのテストと同様です。

```java
public class AccountDetailsServiceTest {

    AccountDetailsService accountDetailsService;
    AccountMapper accountMapper;

    @BeforeEach
    void setUp() {
        accountMapper = mock(AccountMapper.class);
        accountDetailsService = new AccountDetailsService(accountMapper);
    }

    @Test
    void 存在するユーザー名でAccountDetailsが返る() {
        String email = "user@example.com";
        Account account = new Account(1, "user", email, "user");
        List<String> roleList = List.of("ROLE_USER");
        when(accountMapper.findByEmail(email))
                .thenReturn(account);
        when(accountMapper.findAuthoritiesByEmail(email))
                .thenReturn(roleList);
        AccountDetails accountDetails = (AccountDetails) accountDetailsService.loadUserByUsername(email);
        assertAll(
                () -> assertNotNull(accountDetails.getAccount()),
                () -> assertEquals(account.getEmail(), accountDetails.getUsername()),
                () -> assertEquals(account.getPassword(), accountDetails.getPassword()),
                () -> assertEquals(1, accountDetails.getAuthorities().size()),
                () -> assertTrue(accountDetails.isAccountNonExpired()),
                () -> assertTrue(accountDetails.isAccountNonLocked()),
                () -> assertTrue(accountDetails.isCredentialsNonExpired()),
                () -> assertTrue(accountDetails.isEnabled())
                );
    }

    @Test
    void 存在しないユーザー名でUsernameNotFoundExceptionが発生() {
        String email = "user@example.com";
        when(accountMapper.findByEmail(email))
                .thenReturn(new Account(1, "user", email, "user"));
        when(accountMapper.findAuthoritiesByEmail(email))
                .thenReturn(List.of("ROLE_USER"));
        assertThrows(UsernameNotFoundException.class, () -> {
            accountDetailsService.loadUserByUsername("hoge@example.com");
        });
    }
}
```

# コントローラークラスの単体テスト
`@WebMvcTest` を利用します。このアノテーションはSpring Securityも有効化しますので、セキュリティも考慮した上でのテストを行います。

`@WithUserDetails` では `UserDetailsService` 実装クラスを利用します。そのクラスをコンポーネントスキャン対象にするために `@WebMvcTest` に `includeFilter` 要素を追加しています。

さらに、今回のサンプルでは `UserDetailsService` 実装クラス内でMyBatis Mapperを利用しているため、 `@AutoConfigureMybatis` も追加します。

こんな感じで独自アノテーションを作っておくと、同じことを何回も書かなくていいので便利です。

```java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Test
@WithUserDetails(userDetailsServiceBeanName = "accountDetailsService",
        value = "user@example.com")
@interface TestWithUser {}
```

```java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Test
@WithUserDetails(userDetailsServiceBeanName = "accountDetailsService",
        value = "admin@example.com")
@interface TestWithAdmin {}
```

```java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Test
@WithAnonymousUser
@interface TestWithAnonymous {}
```

ServiceはMockitoでモック化します。

## Controllerクラスの場合

```java
@WebMvcTest(includeFilters = @ComponentScan.Filter(
        type = FilterType.ASSIGNABLE_TYPE,
        classes = {AccountDetailsService.class}
))
@AutoConfigureMybatis
public class CustomerControllerTest {

    @Autowired
    MockMvc mvc;

    // CustomerServiceをMockitoでモック化する
    @MockBean
    CustomerService customerService;

    @Nested
    class トップ画面へのアクセス {
        final MockHttpServletRequestBuilder request = get("/")
                .accept(MediaType.TEXT_HTML);
        
        @TestWithUser
        void userはOK() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isOk())
                    .andExpect(view().name("index"));
        }

        @TestWithAdmin
        void adminはOK() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isOk())
                    .andExpect(view().name("index"));
        }

        @TestWithAnonymous
        void 匿名はNG_ログイン画面にリダイレクトされる() throws Exception {
            mvc.perform(request)
                    .andExpect(status().is3xxRedirection())
                    .andExpect(redirectedUrlPattern("**/login"));
        }
    }

    @Nested
    class 追加画面へのアクセス {
        final MockHttpServletRequestBuilder request = get("/insertMain")
                .accept(MediaType.TEXT_HTML);
        
        @TestWithUser
        void userはNG() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isForbidden());
        }

        @TestWithAdmin
        void adminはOK() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isOk())
                    .andExpect(view().name("insertMain"));
        }

        @TestWithAnonymous
        void 匿名はNG_ログイン画面にリダイレクトされる() throws Exception {
            mvc.perform(request)
                    .andExpect(status().is3xxRedirection())
                    .andExpect(redirectedUrlPattern("**/login"));
        }
    }

    @Nested
    class 追加の実行 {
        final MultiValueMap<String, String> validData =
                new LinkedMultiValueMap<>() {{
                    add("firstName", "天");
                    add("lastName", "山﨑");
                    add("email", "tyamasaki@sakura.com");
                    add("birthday", "2005-09-28");
                }};

        MockHttpServletRequestBuilder createRequest(MultiValueMap<String, String> formData) {
            return post("/insertComplete")
                    .params(formData)
                    .with(csrf())
                    .accept(MediaType.TEXT_HTML);
        }

        @TestWithUser
        void userはNG() throws Exception {
            mvc.perform(createRequest(validData))
                    .andExpect(status().isForbidden());
        }

        @TestWithAdmin
        void adminはOK() throws Exception {
            mvc.perform(createRequest(validData))
                    .andExpect(status().is3xxRedirection())
                    .andExpect(redirectedUrl("/"));
        }

        @TestWithAdmin
        void 不正なデータを登録しようとするとバリデーションエラーで入力画面に戻る() throws Exception {
            MultiValueMap<String, String> invalidData =
                    new LinkedMultiValueMap<>() {{
                        add("firstName", "");
                        add("lastName", "");
                        add("email", "");
                        add("birthday", "");
                    }};
            mvc.perform(createRequest(invalidData))
                    .andExpect(status().isOk())
                    .andExpect(view().name("insertMain"));
        }

        @TestWithAnonymous
        void 匿名はNG_ログイン画面にリダイレクトされる() throws Exception {
            mvc.perform(createRequest(validData))
                    .andExpect(status().is3xxRedirection())
                    .andExpect(redirectedUrlPattern("**/login"));
        }
    }
}
```

> `.with(csrf())` が無かった場合、レスポンスが403 Forbiddenになってしまうので忘れないよう注意してください。

## RestControllerクラスの場合
ほぼControllerクラスと同じなのですが、次の点が異なります。

- `andExpect(content().json(expectedJson))` のように返ってきたJSONを比較する
- `with(csrf().asHeader())` としてCSRFトークンをリクエストヘッダーで送信する


```java
@WebMvcTest(includeFilters = @ComponentScan.Filter(
        type = FilterType.ASSIGNABLE_TYPE,
        classes = {AccountDetailsService.class}
))
@AutoConfigureMybatis
public class CustomerRestControllerTest {

    @Autowired
    MockMvc mvc;

    // CustomerServiceをMockitoでモック化する
    @MockBean
    CustomerService customerService;

    @Nested
    class 全顧客の取得 {
        final MockHttpServletRequestBuilder request = get("/api/customers")
                .accept(MediaType.APPLICATION_JSON);
        final String expectedJson = "[{\"id\":1,\"firstName\":\"友香\",\"lastName\":\"菅井\",\"email\":\"ysugai@sakura.com\",\"birthday\":\"1995-11-29\"}]";

        @BeforeEach
        void setUp() {
            when(customerService.findAll()).thenReturn(List.of(
                    new Customer(1, "友香", "菅井", "ysugai@sakura.com", LocalDate.of(1995, 11, 29))
                    )
            );
        }

        @TestWithUser
        void userはOK() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isOk())
                    .andExpect(content().json(expectedJson));
        }

        @TestWithAdmin
        void adminはOK() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isOk())
                    .andExpect(content().json(expectedJson));
        }

        @TestWithAnonymous
        void 匿名はNG() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isUnauthorized());
        }
    }

    @Nested
    class 顧客の登録 {
        final MockHttpServletRequestBuilder request = post("/api/customers")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"firstName\":\"天\",\"lastName\":\"山﨑\",\"email\":\"tyamasaki@sakura.com\",\"birthday\":\"2005-09-28\"}")
                .with(csrf().asHeader());

        @BeforeEach
        void setUp() {
            doAnswer(invocation -> {
                Customer customer = invocation.getArgument(0);
                customer.setId(999);
                return null;
            }).when(customerService).save(any(Customer.class));
        }

        @TestWithUser
        void userはNG() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isForbidden());
        }

        @TestWithAdmin
        void adminはOK() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isCreated())
                    .andExpect(header().string("location", "http://localhost/api/customers/999"));
        }

        @TestWithAnonymous
        void 匿名はNG() throws Exception {
            mvc.perform(request)
                    .andExpect(status().isUnauthorized());
        }
    }
}
```

## セキュリティを無視したい場合
とにかく今はルーティングだけテストがしたいのでSpring Securityは無効化したいという場合は、こんな感じにします。

```java:セキュリティのJava_Configクラス
@Profile("!disable-security")  // プロファイルが"disable-security"でない場合のみこのBeanは有効化される
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    ...
}
```

```java:テストクラス
@ActiveProfiles("disable-security")  // テスト実行時のプロファイルを"disable-security"に指定
@WebMvcTest(excludeAutoConfiguration = {  // Spring Security関連のAuto Configurationクラスを無効化
        SecurityAutoConfiguration.class,
        SecurityFilterAutoConfiguration.class
})
public class CustomerControllerTest {
    ...
}
```

# 結合テスト
`@SpringBootTest` なテストクラスから[Selenium](https://www.selenium.dev/documentation/ja/)を使うことになると思います。Seleniumを使わない場合は手動テストということになるのかな？

[HtmlUnit](https://htmlunit.sourceforge.io/)というのもあるんですが、 `@WebMvcTest` の時のみ `WebClient` がBean定義されるらしく、結合テスト（＝ `@SpringBootTest` ）で使うものではなさそうです。あと、@yusukeさんから[こういうご意見](https://twitter.com/yusuke/status/1385422414767558662)もいただきました。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">HtmlUnitは本物のブラウザと何かと挙動が変わってくるのでテストではお勧めしないです。やっぱりChromeかFirefoxが無難ですね</p>&mdash; ユースケ (山本裕介) (@yusuke) <a href="https://twitter.com/yusuke/status/1385422414767558662?ref_src=twsrc%5Etfw">April 23, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

RESTの場合は`TestRestTemplate`([リファレンス](https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#boot-features-rest-templates-test-utility)) を使うと良いでしょう。