[Test Code] @WithMockUser / @WithUserDetails / @WithSecurityContext

2023. 1. 27. 11:55Web/Spring

@WithSecurityContext - 커스텀 어노테이션 만들기

 

시큐리티가 적용이 된 프로젝트에서 단위 테스트 코드 - 컨트롤러를 작성하던 도중에 여러가지 오류가 지속적으로 발생하였다. 그래서 그때의 과정을 되짚고 작성하면서 내용을 정리하려고 한다. 작성하면서 내용을 채우려고 확인을 하다가 알게 되었는데 만약 Principle / UserDetailsService 객체를 커스텀해서 사용을 하게 된다면 해당 WithMockUser를 사용할 수 없고 직접 SecurityContext를 넣어줘야한다.

 

순서는 다음과 같다.

  • @WithMockUser
  • @WithUserDetatils
  • @WithSecurityContext

 

@WithMockUser

 

처음에 시큐리티를 통과하기 위해 사용했던 어노테이션이다. 

When used with WithSecurityContextTestExecutionListener 
this annotation can be added to a test method to emulate 
running with a mocked user. In order to work with MockMvc 
The SecurityContext that is used will have the following properties

우리가 특정 유저를 대상으로 실행이 되는 메서드를 테스트 코드로 작성하고 실행하고 싶을 때 사용한다. 해당 어노테이션을 사용하면 시큐리티를 통과할 수 있는 UserDetails의 Mock객체를 생성하여 사용할 수 있게 된다.

 

이때 WithMockUser의 경우 해당 유저에 대한 값을 지정할 수 있으며, 지정을 하지 않으면 따로 기본값이 존재한다.

  • value : "user"
  • username : ""
  • role : "USER"
  • authorities : {}
  • password : "password"

그래서 따로 해당 어노테이션을 사용하게 되면 어떤 MockUser를 생성해서 사용할건지, 해당 메서드의 대상이 되는 가짜객체를 지정하여 시큐리티 필터를 통과할 수 있게 된다. 다만 해당 객체의 경우 생성되기만하여 해당 유저가 이미 인증을 통과한것처럼 보이게 하는 역할만 할 뿐이다. 만약 해당 userdetails를 조회하여 사용하는 경우에는 조회의 기능 사용하는 경우, 아래와 같은 오류문이 나오게 된다.


Request processing failed: java.lang.NullPointerException: Cannot invoke "com.sparta.morningworkout.security.UserDetailsImpl.getUser()" because "userDetails" is null


 

@WithUserDetails

 

WithUserDetails의 경우 WithMockUser에서 한 단계 더 나아간 역할이다. 통과만 되면 상관이 없는 경우에는 @WithMockUser를 사용해도 상관이 없다. 그러나 생성된 UserDetails를 조회하고 사용하게 되는 순간에는 UserDetails가 존재해야 조회를 해야 정상적으로 통과가 된다. 그럴때 사용하는 것이 바로 WithUserDetails이다.

When used with WithSecurityContextTestExecutionListener this annotation can be added to a test
method to emulate running with a UserDetails returned from the UserDetailsService. In order to work 
with MockMvc The SecurityContext that is used will have the following properties

실제 어노테이션 설명을 읽으면 해당 어노테이션을 사용시 UserDetailsService에서 반환된 UserDetails를 사용할 수 있다고 한다. 해당 어노테이션의 기본값은 다음과 같다.

  • value : "user"
  • userDetailsServiceBeanName : ""
  • setupBefore() : TestExecutionEvent.TEST_METHOD
    • 해당 경우는 @Before의 시기를 결정하는 부분이다.
    • TEST_METHOD 
      • Before의 전에 해당 WithUserDetails가 작동 : 생성된 유저 객체를 가져오는 작업을 수행하지 못함
    • TEST_EXECUTION
      • Before을 먼저 실행. 작성이 된 유저를 찾아올 수 있게 됨.

 

이때 UserDetails의 경우 지정한 사용자 이름으로 객체를 조회하는 방법이다. 이때 테스트를 실행하기전 @BeforeEach / @BeforeAll등을 통해 미리 해당 user객체를 생성해야 오류없이 사용이 가능하다. 

다만 @BeforeEach / All을 사용하기 전 WithUserDetails가 사용이 되어 오류가 나는 경우 위에서 말한 바와 같이 TEST_EXECUTION으로 setupBefore을 지정해야한다.

 


Request processing failed: java.lang.NullPointerException: Cannot invoke "com.sparta.morningworkout.security.UserDetailsImpl.getUser()" because "userDetails" is null


다만 해당 WIthUserDetails를 사용해도 위와 같은 오류가 발생하였다.

 

@WithSecurityContext

 

- 오류 

더보기

java.lang.IllegalStateException: Unable to create SecurityContext using @org.springframework.security.test.context.support.WithUserDetails(setupBefore=TEST_EXECUTION, userDetailsServiceBeanName="userDetailsServiceImpl", value="username1")
at org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener.lambda$createTestSecurityContext$1(WithSecurityContextTestExecutionListener.java:143) ~[spring-security-test-6.0.1.jar:6.0.1]
at org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener.beforeTestExecution(WithSecurityContextTestExecutionListener.java:106) ~[spring-security-test-6.0.1.jar:6.0.1]
at org.springframework.test.context.TestContextManager.beforeTestExecution(TestContextManager.java:327) ~[spring-test-6.0.3.jar:6.0.3]
at org.springframework.test.context.junit.jupiter.SpringExtension.beforeTestExecution(SpringExtension.java:184) ~[spring-test-6.0.3.jar:6.0.3]
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeBeforeTestExecutionCallbacks$5(TestMethodTestDescriptor.java:191) ~[junit-jupiter-engine-5.9.1.jar:5.9.1]
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeBeforeMethodsOrCallbacksUntilExceptionOccurs$6(TestMethodTestDescriptor.java:202) ~[junit-jupiter-engine-5.9.1.jar:5.9.1]
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeBeforeMethodsOrCallbacksUntilExceptionOccurs(TestMethodTestDescriptor.java:202) ~[junit-jupiter-engine-5.9.1.jar:5.9.1]
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeBeforeTestExecutionCallbacks(TestMethodTestDescriptor.java:190) ~[junit-jupiter-engine-5.9.1.jar:5.9.1]
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:136) ~[junit-jupiter-engine-5.9.1.jar:5.9.1]
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:68) ~[junit-jupiter-engine-5.9.1.jar:5.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:151) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) ~[na:na]
at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) ~[na:na]
at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:35) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:54) ~[junit-platform-engine-1.9.1.jar:1.9.1]
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:107) ~[na:na]
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:88) ~[na:na]
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:54) ~[na:na]
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:67) ~[na:na]
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:52) ~[na:na]
at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114) ~[na:na]
at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:86) ~[na:na]
at org.junit.platform.launcher.core.DefaultLauncherSession$DelegatingLauncher.execute(DefaultLauncherSession.java:86) ~[na:na]
at org.junit.platform.launcher.core.SessionPerRequestLauncher.execute(SessionPerRequestLauncher.java:53) ~[na:na]
at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor$CollectAllTestClassesExecutor.processAllTestClasses(JUnitPlatformTestClassProcessor.java:99) ~[na:na]
at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor$CollectAllTestClassesExecutor.access$000(JUnitPlatformTestClassProcessor.java:79) ~[na:na]
at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor.stop(JUnitPlatformTestClassProcessor.java:75) ~[na:na]
at org.gradle.api.internal.tasks.testing.SuiteTestClassProcessor.stop(SuiteTestClassProcessor.java:62) ~[na:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:36) ~[na:na]
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:24) ~[na:na]
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[na:na]
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:94) ~[na:na]
at jdk.proxy1/jdk.proxy1.$Proxy2.stop(Unknown Source) ~[na:na]
at org.gradle.api.internal.tasks.testing.worker.TestWorker$3.run(TestWorker.java:193) ~[na:na]
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:129) ~[na:na]
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:100) ~[na:na]
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:60) ~[na:na]
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[na:na]
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:113) ~[na:na]
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:65) ~[na:na]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) ~[gradle-worker.jar:na]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) ~[gradle-worker.jar:na]
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No bean named 'userDetailsServiceImpl' available

 


@WithSecurityContext

테스트코드를 작성하면서 지속적으로 해결이 안되서 알게 된 방법이다. 처음에 말한 바와 같이 SecurityContext를 생성해주는 어노테이션을 만드는 것이다.


An annotation to determine what SecurityContext to use. The factory() attribute must be provided with an instance of WithUserDetailsSecurityContextFactory.
Typically this annotation will be used as an meta-annotation as done with WithMockUser and WithUserDetails.
If you would like to create your own implementation of WithSecurityContextFactory you can do so by implementing the interface. You can also use Autowired and other Spring semantics on the WithSecurityContextFactory implementation.


  • 사용할 securityContext를 넣어주기 위한 어노테이션
  • 인터페이스를 통해 자체 구현 가능
  • 해당 어노테이션 내부에도 setBefore 존재

WithSecurityContext를 통해 Securitycontext를 생성하고 테스트를 실행하라는 신호

즉 해당 어노테이션을 통해 하나의 커스텀 어노테이션을 실행하고자 하는 테스트 / 클래스에 넣어주면 통과가 됨

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
    String username() default "test";

    String password() default "name";

    UserRoleEnum role() default UserRoleEnum.CUSTOMER;

}

 

이때 Authentication의 경우 본인이 인증에 사용한 UserDetails에 맞춰서 넣어주면 된다. 

 

public class WithMockCustomUserSecurityContextFactory
        implements WithSecurityContextFactory<WithMockCustomUser> {


    @Override
    public SecurityContext createSecurityContext(WithMockCustomUser annotation) {
        final SecurityContext context = SecurityContextHolder.createEmptyContext();
        // Authentication 은 본인이 인증에 사용하는 클래스를 생성하면 됩니다.
        User user = User.builder().username(annotation.username()).password(annotation.password()).role(annotation.role()).build();
        UserDetailsImpl userDetails = new UserDetailsImpl(user);
        final Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        context.setAuthentication(auth);
        return context;
    }
}
@Test
@WithMockCustomUser
void athorization() throws Exception {

    UserDetailsImpl userDetails = mock(UserDetailsImpl.class);

    given(userDetails.getUser()).willReturn(user);

    SellerRegistRequestDto requestDto = SellerRegistRequestDto.builder()
            .infoContent("상품정보")
            .category(CategoryEnum.IT)
            .build();

    ResultActions resultActions = mockMvc.perform(post("/users/authorization")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsBytes(requestDto))
                    .accept(MediaType.APPLICATION_JSON)
                    .with(csrf()))
            .andExpect(status().isOk());

}

테스트 통과

공식 문서 내의 존재하는 @WithSecurityContext 예시

더보기
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {

    String username() default "rob";

    String name() default "Rob Winch";
}

 

public class WithMockCustomUserSecurityContextFactory
    implements WithSecurityContextFactory<WithMockCustomUser> {
    @Override
    public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();

        CustomUserDetails principal =
            new CustomUserDetails(customUser.name(), customUser.username());
        Authentication auth =
            new UsernamePasswordAuthenticationToken(principal, "password", principal.getAuthorities());
        context.setAuthentication(auth);
        return context;
    }
}

 

출처

 

Testing | 토리맘의 한글라이즈 프로젝트 (godekdls.github.io)

 

Testing

스프링 시큐리티를 테스트하는 방법을 설명합니다. 공식 문서에 있는 “Testing” 챕터를 한글로 번역한 문서입니다.

godekdls.github.io