5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「Develop fun!」を体現する! Works Human IntelligenceAdvent Calendar 2023

Day 25

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

Last updated at Posted at 2023-12-24

はじめに

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. 各階層の記述(省略します)
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'
    ...
}
...
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()))を入れる
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をモック化する
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など)

参考

おわりに

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

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?