はじめに
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
事前準備
- build.gradleに依存関係を追加
- SecurityConfigを追加
- 各階層の記述(省略します)
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など)
参考
- @WebMvcTest needs @Import(SecurityConfig.class) to enable SpringSecurity
- 【備忘録】JavaでUUID.randomUUID()やLocalDateTime.now()を単体テストする方法
- SpringSecurity: CSRF 保護を使用したテスト
- SpringSecurity: メソッドのセキュリティテスト
- JUnit5: ParameterizedTest
おわりに
アノテーションが豊富過ぎてびっくりしました・・
使いこなしたい気持ちと、つけすぎて見づらいとかにならないように注意しないとなと思っています。
見やすくて、コードの変更がしやすいようなテストを書いていきたいです。