【備忘録】SpringBoot + SpringSecurity + JUni5で単体テストを書いてみた

## はじめに
SpringBootとSpringSecurityを使ったアプリケーションを作ったので、その単体テストを作成するにあたって学んだこと備忘録的に書いていきます。
省略可能な部分は`...`などで省略しています。

## 開発環境
EclipseのSpringスターター・プロジェクトから作成しています。
- Windows10
- Eclipse IDE 2023-12 (4.30.0)
- JAVA SE-17
- JUnit5
- SpringBoot 3.2.0
- SpringSecurity 6.2.0
- SpringBootTest 3.2.0
- SpringSecurityTest 6.2.0
- gradle

## 事前準備
1. build.gradleに依存関係を追加
2. SecurityConfigを追加
3. 各階層の記述（省略します）
```gradle:build.gradle
...

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-security'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.3.2'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
    ...
}
...
```
```java:SecurityConfig.java
package com.example.demo;

import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

/**
 * 認証に関する設定
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final String[] ONLY_ADMIN_REQUESTS = new String[] { "/signup", "/contact/add",
            "/contact/create", "/contact/{id}/delete", "/contact/{id}/edit", "/contact/update",
            "/category/list", "/category/add"};

    /**
     * staticリソースは認証なしで見ることができる<br>
     * loginページ以外は認証が必要になる。<br>
     * ログインに成功した場合、indexに飛ぶ。<br>
     * logoutページも認証なしで見ることができる。
     * 
     * @param http HttpSecurity
     * @return SecurityFilterChain
     * @throws Exception
     */
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((requests) -> requests
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                .requestMatchers("/login").permitAll().requestMatchers(ONLY_ADMIN_REQUESTS)
                .hasRole("ADMIN").anyRequest().authenticated())
                .formLogin((form) -> form.loginPage("/login").defaultSuccessUrl("/").permitAll())
                .logout((logout) -> logout.logoutSuccessUrl("/login"));

        return http.build();
    }
   ...
}

```

## やったこと
### SpringSecurityの権限周りテスト
**テストクラスに`@Import(SecurityConfig.class)`アノテーションをつける**
- ロール情報関係無しに認証が必要なページが表示されるか
→`@WithUserDetails`
- ロール情報を含めて表示されるか
→ `@WithMockUser(roles = "HOGE")`
- ログイン済みかつ権限なしユーザーで`ForbiddenError`になるか
→ `@WithMockUser(roles = "権限なしロール")`
- 未ログイン状態でログイン画面にリダイレクトされるか
→ アノテーションなし
- postリクエストはcsrfトークンが必要になる
→ `.with(SecurityMockMvcRequestPostProcessors.csrf()))`を入れる


```java:ContactControllerTest .java
package com.example.demo.controller;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.DisplayName;
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.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.test.context.support.WithUserDetails;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import org.springframework.test.web.servlet.MockMvc;

import com.example.demo.SecurityConfig;

/**
 * {@link ContactController}のテストクラスです。
 */
@WebMvcTest(ContactController.class)
@Import(SecurityConfig.class)
class ContactControllerTest {

    @Autowired
    private MockMvc mockMvc;

    ...

    /**
     * 連絡先登録画面が表示されることをテストする。
     * 
     * @throws Exception
     */
    @Test
    @WithMockUser(roles = "ADMIN")
    @DisplayName("連絡先登録画面の表示：正常系")
    void testDisplayAdd_Success() throws Exception {

        // テスト実行
        mockMvc.perform(get("/contact/add"))
                // 検証
                .andExpect(status().isOk()).andExpect(view().name("contact/add"));

        // 検証
        verifyNoInteractions(contactService);
    }

    /**
     * 未ログイン状態で連絡先登録画面にアクセスすると<br>
     * ログイン画面にリダイレクトするテスト。
     * 
     * @throws Exception
     */
    @Test
    @DisplayName("連絡先登録画面：異常系（UnauthorizedError）")
    void testDisplayAdd_UnauthorizedError() throws Exception {
        mockMvc.perform(get("/contact/add"))
                // 検証
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("http://localhost/login"));
    }

    /**
     * 権限なしユーザーで連絡先追加画面にアクセスすると<br>
     * ForbiddenError が発生するテスト。
     *
     * @throws Exception
     */
    @Test
    @WithMockUser(roles = "TEST")
    @DisplayName("連絡先登録画面：異常系（ForbiddenError）")
    void testDisplayAdd_ForbiddenError() throws Exception {
        mockMvc.perform(get("/contact/add"))
                // 検証
                .andExpect(status().isForbidden());
    }

    /**
     * 連絡先登録で正常系をテストする。
     * 
     * @throws Exception
     */
    @Test
    @WithMockUser(roles = "ADMIN")
    @DisplayName("連絡先新規登録：正常系")
    void testCreateContact_Success() throws Exception {
        ...
        // テスト実行
        mockMvc.perform(post("/contact/create")
                // 引数の設定
                .flashAttr("contactForm", contactForm).flashAttr("categories", categories)
                // SpringSecurity は、デフォルトで POST リクエストにcsrfトークンが必要
                .with(SecurityMockMvcRequestPostProcessors.csrf()))
                .andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/contact/list"));
        ...
    }
    ...
}

```

### 現在時刻(staticメソッド)のモック
- MockedStaticでstaticメソッドの返り値を指定する
- ここではLocalDateTime.nowをモック化する
```java: HogeTest.java
...
import static org.mockito.Mockito.*;

import java.time.LocalDateTime;
import org.mockito.MockedStatic;
...

class HogeTest {
    /**
     * 現在時刻のモック
     */
    @Test
    void testHoge() {
        LocalDateTime date = LocalDateTime.of(2023, 12, 2, 11, 6, 0, 0);
        ...
        // テスト実行
        try (MockedStatic<LocalDateTime> mock = mockStatic(LocalDateTime.class)) {
            mock.when(LocalDateTime::now).thenReturn(date);
            
           ...
            assertEquals(actual.getCreateDate(), date);
        }
    }
}
```

### パラメータ化テスト
Junit5のパラメータ化テストをするには`@ParameterizedTest`アノテーションを付ける
パラメータもアノテーションで与える。（@MethodSource, @ValueSourceなど）

https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-test




## 参考
- [@WebMvcTest needs @Import(SecurityConfig.class) to enable SpringSecurity](https://stackoverflow.com/questions/71068317/webmvctest-needs-importsecurityconfig-class-to-enable-springsecurity)
- [【備忘録】JavaでUUID.randomUUID()やLocalDateTime.now()を単体テストする方法](https://zenn.dev/numacci/articles/mockito-test-static-method)
- [SpringSecurity: CSRF 保護を使用したテスト](https://spring.pleiades.io/spring-security/reference/servlet/test/mockmvc/csrf.html)
- [SpringSecurity: メソッドのセキュリティテスト](https://spring.pleiades.io/spring-security/reference/servlet/test/method.html)
- [JUnit5: ParameterizedTest](https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests)

## おわりに
アノテーションが豊富過ぎてびっくりしました・・
使いこなしたい気持ちと、つけすぎて見づらいとかにならないように注意しないとなと思っています。
見やすくて、コードの変更がしやすいようなテストを書いていきたいです。