✏️ [Spring] Spring Security Testing

박상민·2025년 2월 25일

Spring

목록 보기
8/12
post-thumbnail

⭐️ 들어가기 전

포트폴리오와 이력서를 만들면서 테스트 코드의 부재가 뼈 아프게 다가왔습니다.
테스트 코드의 이점은 많습니다. 단순히 개발 속도가 향상하는 것 뿐만 아니라 리팩토링, 유지보수 등에서도 테스트 코드의 존재는 매우 중요합니다.

지금까지는 테스트 코드의 중요성을 깨닫지 못했습니다.
그러나 2개의 프로젝트를 테스트 코드 없이 진행해보니 테스트 코드의 중요성을 깨닫게 되었습니다.

테스트 코드를 공부하려고 하니 또다른 문제가 있었습니다. Spring Security를 적용한 코드의 테스트 코드는 사용자 인증을 통과해야 했습니다.
그래서 이번 글에서는 Spring Security를 적용한 코드에서의 테스트 방법을 공부하려고 합니다.

Spring Security 공식 레퍼런스를 참고하여 작성했습니다.

📌 Security Test Setup

Spring Security Test 기능을 사용하려면 프로젝트 의존성에 spring-security-test-5.3.2.RELEASE.jar를 추가해야 한다.

@RunWith(SpringJUnit4ClassRunner.class) // (1)
@ContextConfiguration // (2)
public class WithMockUserTests{
	...
}

(1): 스프링 테스트 모듈은 @RunWith를 보고 ApplicationContext를 생성한다. 기존 스프링 테스트 지원과 동일하다.
(2): @ContextConfiguration은 스프링 테스트 모듈에 ApplicationContext를 생성할 설정을 알려준다. 설정을 명시하지 않았기 때문에 디폴트 설정 위치를 찾는다. 기존 스프링 테스트 지원과 동일하다.

본격적인 설명에 들어가기 전, 인증된 사용자만 접근할 수 있는 MessageService를 정의하자.

public class HelloMessageService implements MessageService {

    @PreAuthorize("authenticated")
    public String getMessage() {
        Authentication authentication = SecurityContextHolder.getContext()
            .getAuthentication();
        return "Hello " + authentication;
    }
}

📌 @WithMockUser - 일반 사용자 테스트

특정 사용자로 테스트를 실행할 수 있는 가장 쉬운 방법은 @WithMockUser이다.

아래 테스트는

  • 사용자 이름: "user"
  • 비밀번호: "password"
  • role: "ROLE_USER"

를 가진 사용자로 실행된다.

@Test
@WithMockUser
public void getMessageWithMockUser() {
	String message = messageService.getMessage();
	...
}

위 테스트 코드 특징

  • 사용자 이름이 "user"인 사용자를 Mocking하므로, 실제로는 없어도 된다.
    • Mocking: 테스트 대상 코드에서 실제 의존성을 제거하고, 가짜 객체(Mock Object)를 만들어 테스트 하는 기법
  • SecurityContext에 채워지는 Authentication은 UsernamePasswordAuthenticationToken이다.
  • Authentication에 있는 principal은 스프링 시큐리티의 User 객체다.
  • User의 사용자 이름은 "user", 비밀번호는 "password"이며, "ROLE_USER"란 이름의 GrantedAuthority 하나를 사용한다.

이 예제는 사용할 수 있는 디폴트 값이 많다는 장점이 있다. 다른 사용자 이름으로 테스트를 실행하고 싶다면 아래처럼 정의 가능하다.

사용자 이름 커스텀

@Test
@WithMockUser("customUsername")
public void getMessageWithMockUser() {
	String message = messageService.getMessage();
	...
}

role도 쉽게 커스텀할 수 있다.

username, role 커스텀

@Test
@WithMockUser(username = "admin", roles = {"USER", "ADMIN"})
public void getMessageWithMockUser() {
	String message = messageService.getMessage();
	...
}

이렇게 설정하면 자동으로 ROLE_프리픽스가 붙게 되는데 원하지 않는다면 authorities 속성을 사용하면 된다.

@Test
@WithMockUser(username = "admin", authorities = {"USER", "ADMIN"})
public void getMessageWithMockUser() {
	String message = messageService.getMessage();
	...
}

이렇게 설명하면 사용자 이름 "admin"과 "USER", "ADMIN" 권한으로 실행된다.

그렇다면 모든 테스트 메소드마다 어노테이션을 달아야 할까?
메소드 대신 클래스 레벨에 어노테이션을 달면 모든 테스트에서 지정한 사용자를 사용한다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
@WithMockUser(username = "admin", roles = { "USER", "ADMIN" })
public class WithMockUserTests {

📌 @WithAnonymousUser - 익명 사용자 테스트

@WithAnonymousUser를 사용하면 익명 사용자로 테스트를 실행할 수 있다.
테스트 대부분을 지정한 사용자로 실행하고, 일부만 익명 사용자로 실행하고 싶을 때 특히 편리하다.

@Test
@WithAnonymousUser // 익명 사용자로 테스트
public void anonymous() throws Exception {
}

📌 @WithUserDetails - Custom UserDetailsService

흔히들 Authentication principal을 특정 인스턴스 타입으로 사용한다. 이렇게 하면 어플리케이션에서 principal을 특정 인스턴스 타입으로 사용한다. 이렇게 하면 어플리케이션에선 principal을 커스텀 타입으로 참조할 수 있으며, Spring Security와의 결합도도 줄일 수 있다.

커스텀 principal은 보통 커스텀 UserDetailServiceUserDetails와 커스텀 타입을 모두 구현한 객체를 반환한다. 이런 상황에선 커스텀 UserDetailsService로 테스트 사용자를 만들 수 있어야 한다. 이게 바로 @WithUserDetails가 하는 일이다.

UserDetailsService를 빈으로 등록했다고 가정하면, 아래 테스트는 UsernamePasswordAuthenticationToken 타입 Authentication과 UserDetailsService가 리턴한 principal, 사용자 이름 "user"로 실행된다.

@Test
@WithUserDetails // 마찬가지로 ("customUsername") 을 추가해 사용자 이름 커스텀이 가능하다.
public void getMessageWithUserDetails() {
    String message = messageService.getMessage();
    ...
}

UserDetailsService를 찾을 때 사용할 빈 이름을 명시하는 것도 가능하다. 예를 들어 이 테스트는 "myUserDetailsService"란 이름을 가진 UserDetailsService 빈을 사용해서 사용자 이름 "customUsername"을 찾는다.

빈 이름 명시 가능

@Test
@WithUserDetails(value="customUsername", userDetailsServiceBeanName="myUserDetailsService")
public void getMessageWithUserDetailsServiceBeanName() {
    String message = messageService.getMessage();
    ...
}

@WithMockUser처럼 클래스 레벨에 어노테이션을 달면 모든 테스트에서 같은 사용자를 사용한다. 하지만 @WithUserDetails는 실제로 사용자가 있어야 한다.

📌 @WithSecurityContext - 가장 유연한 옵션

Authentication principal을 커스텀하지 않으면 @WithMockUser를 사용하는 게 더 낫다는 것을 알았다. @WithUserDetails는 커스텀 UserDetailsService를 사용해서 Authentication principal을 만들 수 있지만, 해당 사용자가 실제로 있어야 한다는 것을 알았다.

@WithSecurityContext는 가장 유연하게 사용할 수 있는 옵션이다.
원하는 SecurityContext를 생성해 주는 @WithSecurityContext를 사용해서 자체 어노테ㅔ이션을 만들 수 있다.

@WithSecurityContext를 사용한 자체 어노테이션

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

    String username() default "rob";

    String name() default "Rob Winch";
}

@WithMockCustomUser@WithSecurityContext 어노테이션이 선언돼 있다. Spring Security Test 기능에선 이를 SecurityContext를 생성해서 테스트를 실행하라는 신호로 받아들인다.

@WithSecurityContext 어노테이션엔 @WithMockCustomUser 어노테이션을 선언했을 때 새 SecutiryContext를 만들 SecurityContextFactory를 지정해야 한다.

SecurityContextFactory - WithMockCustomUserSecurityContextFactory

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;
    }
}

이제 테스트 클래스나 메소드에 커스텀 어노테이션을 선언하면 Spring Security의 WithSecurityContextTestExecutionListener가 적절한 SecurityContext를 채워준다.

📌 Test Meta Annotations

여러 테스트에서 같은 사용자를 재사용한다면, 반복해서 속성을 지정하는 건 좋지 않다.
예를 들어

  • 사용자 이름: "admin"과
  • role: ROLE_USER, ROLE_ADMIN

을 가진 관리자 사용자를 사용하는 테스트가 많다면 매번 아래 코드를 작성할 것이다.

@WithMockUser(username = "admin", roles = {"USER", "ADMIN"})

이 코드를 중복 작성하는 대신 메타 어노테이션을 활용할 수 있다.
Meta Annotation - WithMockAdmin

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "admin", roles = "ADMIN")
public @interface WithMockAdmin { }

이제 코드를 반복해서 작성하는 대신 @WithMockAdmin을 작성하면 된다.


출처
Spring Security 공식 레퍼런스

0개의 댓글