ここに書かれている情報について最近理解を深めた結果、書き方に不正確な点があったり別の方法で対応した方が良いことがわかったのでいずれ書き直そうと思います
https://docs.spring.io/spring-security/reference/servlet/test/mockmvc/authentication.html
認証済みのテストユーザーを使って、テストを行いたい場合
例えば、以下のように Contextの認証情報からユーザー情報を取ってきて利用するメソッドがあり、このメソッドをテストしたい。
@RestController
@RequestMapping("item")
@CrossOrigin
class ItemController(private val service: ItemService) {
@PostMapping("/register")
fun register(@RequestBody request: RegisterItemRequest) {
val user = SecurityContextHolder.getContext().authentication.principal as CustomUserDetails
service.register(request.itemId, account.id)
}
}
このようにテストコードを書いても失敗する。 理由はコンテキストから認証情報を取得できていないから。
internal class ItemControllerTest {
@Test
fun startRental() {
// Given
val accountRepository = mock<accountRepository>()
val itemRepository = mock<ItemRepository>()
val itemService = ItemService(accountRepository, itemRepository)
val itemController = itemController(itemService)
val mockMvc = MockMvcBuilders.standaloneSetup(itemController).build()
val account = Account(1000L, "test@example.com", "pass", Role.USER)
val request = RegisterItemRequest(100L)
val json = ObjectMapper().registerKotlinModule().writeValueAsString(request)
whenever(accountRepository.findById(any())).thenReturn(account)
whenever(itemRepository.findById(any())).thenReturn(Item(request.id))
// When
mockMvc.perform(post("/item/register").contentType(APPLICATION_JSON).content(json))
.andExpect(status().isOk)
// Then
verify(itemRepository).register(any())
}
}
なので、認証済みのテストユーザーを用意することになるので、その方法を幾つか明記する。
MockUser を使用する
テストメソッドに @WithMockUser
をつけてUser情報を定義することで、そのユーザーの認証情報でテストを実行してくれる。
このアノテーションの属性には、Authenticationプリンシパルの "username","password", "roles" などを指定して、ダミーユーザーで認証することができる。
@WithMockUser(username="hogehoge", password="12345", roles="USER")
カスタムモックユーザーを用意する
今回の場合、Authenticationプリンシパルに、ID情報を追加してカスタマイズを行なっている。
このようにAuthenticationプリンシパルをカスタマイズしている場合、@WithSecurityContext
を利用して、@WithCustomMockUser
のようなカスタムアノテーションを作って利用することができる。
@Retention(AnnotationRetention.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory::class)
annotation class WithCustomMockUser(
val id: Long = 1000L,
val email: String = "test@example.com",
val pass: String = "pass",
val roleType: RoleType = RoleType.USER
) {}
class WithMockCustomUserSecurityContextFactory() : WithSecurityContextFactory<WithCustomMockUser> {
override fun createSecurityContext(user: WithCustomMockUser): SecurityContext {
val account = Account(user.id, user.email, user.pass, user.roleType)
val principal = CustomUserDetails(account)
val auth = UsernamePasswordAuthenticationToken(principal, principal.password, principal.authorities)
val context = SecurityContextHolder.createEmptyContext()
context.authentication = auth
return context
}
}
作成した @WithCustomMockUser
をテストメソッドにつける
internal class ItemControllerTest {
@Test
@WithCustomMockUser(id=100L)
fun startRental() {
......
}
}
しかしこれでテストを実行しても、認証情報(principal)が取得できず、まだテストが失敗する
理由は、このテストクラスを単体で実行していたので、 @WithCustomMockUser
で宣言していても、別のContextとなってしまっており、認証情報が取得できていなかった。
回避方法は、 テストクラスに @SpringBootTest
を付ければ良い。
最終的には次のようにテストクラスを書くことで、テストが成功した。
@SpringBootTest
internal class ItemControllerTest {
@Test
@WithCustomMockUser(id=100L)
fun startRental() {
// Given
val accountRepository = mock<accountRepository>()
val itemRepository = mock<ItemRepository>()
val itemService = ItemService(accountRepository, itemRepository)
val itemController = itemController(itemService)
val mockMvc = MockMvcBuilders.standaloneSetup(itemController).build()
val account = Account(1000L, "test@example.com", "pass", Role.USER)
val request = RegisterItemRequest(100L)
val json = ObjectMapper().registerKotlinModule().writeValueAsString(request)
whenever(accountRepository.findById(any())).thenReturn(account)
whenever(itemRepository.findById(any())).thenReturn(Item(request.id))
// When
mockMvc.perform(post("/item/register").contentType(APPLICATION_JSON).content(json))
.andExpect(status().isOk)
// Then
verify(itemRepository).register(any())
}
}
備考① テストクラスのSetup()で認証情報をセットする
MockUserを使う方法は、テスト用のUserDetailsを1箇所にまとめて利用できるので便利だが、 @SpringBootTest
でSpringを起動することになるので、テストが増えると時間が掛かってしまう場合がある。
なので別の方法として、テストクラスの SetUpメソッド (@BeforeEach
)で、認証情報をコンテキストにセットすることでも対応できる。もちろん認証情報をクラスごとに定義するので、特にこちらが良いというわけではないが、記録として残しておく。
internal class ItemControllerTest {
val account = Account(1000L, "test@example.com", "pass", Role.USER)
@BeforeEach
internal fun setUp() {
val principal = CustomeUserDetails(account)
val auth = UsernamePasswordAuthenticationToken(principal, principal.password, principal.authorities)
SecurityContextHolder.getContext().authentication = auth
}
@Test
fun startRental() {
// Given
val accountRepository = mock<accountRepository>()
val itemRepository = mock<ItemRepository>()
val itemService = ItemService(accountRepository, itemRepository)
val itemController = itemController(itemService)
val mockMvc = MockMvcBuilders.standaloneSetup(itemController).build()
val request = RegisterItemRequest(100L)
val json = ObjectMapper().registerKotlinModule().writeValueAsString(request)
whenever(accountRepository.findById(any())).thenReturn(account)
whenever(itemRepository.findById(any())).thenReturn(Item(request.id))
// When
mockMvc.perform(post("/item/register").contentType(APPLICATION_JSON).content(json))
.andExpect(status().isOk)
// Then
verify(itemRepository).register(any())
}
}
備考② @WithUserDetails
を使って認証情報を取得する
@WithSecurityContext
を利用してカスタムアノテーションを作らなくても、 @WithUserDetails
を使って、UserDetailseServiceを指定し、カスタムAuthenticationプリンシパルを取得することもできる。
ただし、UserDetailsServiceを使って、プリンシパルを取得する場合は、テストユーザーを事前にDBなどから登録する必要があるので、先に挙げた、カスタムアノテーションでテストモックユーザーを用意する方が、テストコードでユーザーを柔軟に作成できる。
/* このCustomeUserDetailsServiceクラスは、usernameをキーに、
* authenticationService.findAccount(username) で、アカウントを検索して、カスタムプリンシパルを返している。
* findAccount()メソッドでは、最終的にDBのテーブルを検索している。
*/
class CustomeUserDetailsService(private val authenticationService: AuthenticationService) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails? {
val account = authenticationService.findAccount(username)
return account?.let { CustomeUserDetails(account) }
}
}
@WithUserDetails
で、CustomeUserDetailsService を利用するため、テスト用のConfiguration定義でBean登録を行う。
@Configuration
class SecurityTestConfiguration(private val authenticationService: AuthenticationService) {
@Bean
fun customeUserDetailsService(): CustomeUserDetailsService {
return CustomeUserDetailsService(authenticationService)
}
}
あとは、テストメソッド (またはテストクラス) に @WithUserDetails
を付けて実行する。
このとき、value属性に設定した、usernameを使って、CostomeUserDetailsServiceクラスから、カスタムプリンシパルを取得してテストユーザーとする訳だが、 アカウント情報をもつDBに接続できていなかったり、指定したusernameが存在しないとテストユーザーが取れないので、テストが失敗する。
@SpringBootTest
internal class ItemControllerTest {
@Test
// @WithCustomMockUser(id=100L)
@WithUserDetails(value = "user", userDetailsServiceBeanName = "customeUserDetailsService")
fun startRental() {
...
}
}
MockMvc のセットアップ方法
認証情報を取る方法を試行錯誤しているときに、MockMVCのセットアップ方法の違いについても少し調べたときに下記の公式ドキュメントを見つけたので、リンクだけしておく。
ちなみに今回作成しているテストは、Controller単位のテストだったので、 standaloneSetup
を選択している。
Spring テスト リファレンス - MockMVC - セットアップの選択
参考リンク
Spring Security ユニットテスト
Spring Securityを使っているWebアプリのUnitテスト
Spring Security リファレンス - メソッドのセキュリティテスト
Spring Security for Spring Boot Integration Tests