3
3

Spring の Controllerで認証情報を参照するメソッドをテストするときの準備

Last updated at Posted at 2021-06-12

ここに書かれている情報について最近理解を深めた結果、書き方に不正確な点があったり別の方法で対応した方が良いことがわかったのでいずれ書き直そうと思います

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

3
3
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
3
3