Intro
mockMvc를 통해 api 테스트를 진행 하는데, 스프링 시큐리티를 연동 하기 전에는 괜찮았는데 연동을 한 후에 테스트가 제법 까다로웠습니다. 일단 단순 로그인 여부만 체크하는 부분은 간단하게 넘어갈 수 있었는데, @AuthenticationPrincipal
어노테이션을 걸고 파라미터로 컨트롤러에서 로그인 정보를 받아오는 부분까지 테스트 하려니 쉽지 않았습니다.
제가 코틀린으로 토이 프로젝트를 하고 있다보니 코드는 코틀린이 대다수지만 사실 자바와 차이가 없기 때문에 자바로 진행하고 계신 분들도 같은 맥락으로 문제 해결을 하실 수 있으며, 마지막에는 자바 코드도 조금 첨부 해 두었습니다.
MockMvc
NoSecurity
일단 처음으로, 따로 시큐리티 인증 과정이 없는 api의 mockMvc 테스트 입니다.
package kr.quidev.quiz.controller_api
import org.hamcrest.Matchers
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
@AutoConfigureMockMvc
@SpringBootTest
internal class QuizApiControllerTest {
@Autowired
lateinit var mockMvc: MockMvc
@Test
fun createQuiz() {
mockMvc.perform(
MockMvcRequestBuilders.post("/api/quiz/new")
.with(SecurityMockMvcRequestPostProcessors.user("shane"))
.param("desc", "desc")
.param("answer", "answer")
.param("explanation", "explanation")
.param("examples", "example1")
.param("examples", "example2")
.param("examples", "example3")
)
.andExpect(MockMvcResultMatchers.status().isOk)
.andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("\"description\":\"desc\"")))
.andExpect(MockMvcResultMatchers.jsonPath("$.answer").value("answer"))
.andExpect(MockMvcResultMatchers.jsonPath("$.explanation", Matchers.containsString("explanation")))
}
}
이때는, 시큐리티와 따로 연동되는 부분이 없기 때문에 단순하게 mockMvc를 주입 받아서 테스트를 수행 할 수 있습니다.
이제 후에 인증과정이 점점 추가되면서 계속 피곤해집니다.
Security
이번에는 spring security 에서 인증을 요구하는 페이지를 테스트 해 보도록 하겠습니다.
SpringSecurity.kt
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http.authorizeRequests()
.antMatchers("/adm")
.access("hasRole('ADMIN')")
.antMatchers("/", "/join", "/login")
.permitAll()
.anyRequest()
.authenticated()
.and()
.cors().and().csrf().disable()
...
}
위에 보이는 것 처럼, 루트 페이지와 /join
및 /login
을 제외하면 모두 인증이 필요 합니다. 위에서 post 요청을 보냈던 주소는 /api/...
이기 때문에 인증을 요구합니다. 그냥 post 요청을 보내면
스프링 시큐리티가 로그인 페이지로 보내버리기 때문에, 기대하고 있는 200 응답 대신 302 응답을 받게 됩니다.
이제 이 문제를 해결 해야 하는데요. MockMvcRequestBuilders의 메서드를 보면 RequestPostProcessor를 파라미터로 받는 with 메서드가 있습니다.
RequestPostProcessor를 받습니다.
SecurityMockMvcRequestPostProcessors 를 보면 user 라는 public static 메서드가 보이는데요. Authentication.getPrincipal()
을 가능하게 해줄 UsernamePasswordAuthenticationToken와 UsernamePasswordAuthenticationToken.getPrincipal()
을 가능하게 해줄 User를 포함하는 SecurityContext를 만들어 준다고 합니다. user 정적 메서드를 호출 하며 원하는 username만 전달 해 주면 해결이 됩니다.
반환타입인 UserDetailsRequestPostProcessor의 다이어그램은 아래와 같습니다.
RequestPostProcessor를 구현했기 때문에 MockMvcRequestBuilders.with()
의 파라미터로 사용이 가능합니다.
이제 테스트 코드에 with 메서드를 활용 해서 로그인 유저도 Mocking을 해 줍니다.
@Test
@DisplayName("create quiz test: expected situation")
fun createQuiz() {
val quizCreateDto = QuizCreateDto(
description = "desc",
answer = "answer",
explanation = "explanation",
examples = arrayOf("example1", "example2", "example3")
)
mockMvc.perform(
MockMvcRequestBuilders.post("/api/quiz/new")
.contentType(MediaType.APPLICATION_JSON)
.with(SecurityMockMvcRequestPostProcessors.user("shane"))
.param("desc", "desc")
.param("answer", "answer")
.param("explanation", "explanation")
.param("examples", "example1")
.param("examples", "example2")
.param("examples", "example3")
.content(jacksonObjectMapper().writeValueAsString(quizCreateDto))
).andExpect(MockMvcResultMatchers.status().isOk)
.andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("\"description\":\"desc\"")))
.andExpect(MockMvcResultMatchers.jsonPath("$.answer").value("answer"))
.andExpect(MockMvcResultMatchers.jsonPath("$.explanation", Matchers.containsString("explanation")))
}
post 요청을 parameter 전달에서 body에 내용을 채우는 방식으로 변경 했지만 큰 틀은 비슷합니다.
이렇게 하면, 시큐리티가 요구하는 인증부분을 무사히 통과해 API 테스트에 성공 할 수 있습니다. 단순하게 유저명만 지정하면 되기 때문에 정말 간편합니다.
@AuthenticationPrincipal
이번에는 컨트롤러에서 로그인 한 사용자의 정보가 필요하기 때문에 @AuthenticationPrincipal
어노테이션을 달았습니다.
그러고 나서 로그인 한 사용자 정보를 좀 더 편하게 사용 하기 위해서 org.springframework.security.core.userdetails.User 를 상속한 MemberContext 클래스를 만들었습니다.
MemberContext.kt
package kr.quidev.security.domain
import kr.quidev.member.domain.entity.Member
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.userdetails.User
class MemberContext(
val member: Member, authorities: MutableCollection<out GrantedAuthority>?) :
User(member.email, member.password, authorities) {
}
그러고 나서는 UserDetailsService를 구현한 CustomUserDetailsService를 만들어서, loaduserByUsername을 오버라이드 해, 위에서 만들었던 MemberContext를 반환하도록 했습니다.
CustomUserDetailsService.kt
package kr.quidev.security.service
import kr.quidev.member.repository.MemberRepository
import kr.quidev.security.domain.MemberContext
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
@Service("UserDetailsService")
class CustomUserDetailsService(val memberRepository: MemberRepository) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
val member = memberRepository.findMemberByEmail(username).orElse(null)
?: throw UsernameNotFoundException("invalid email address")
val roles = mutableListOf<GrantedAuthority>()
roles.add(SimpleGrantedAuthority(member.role))
return MemberContext(member, roles)
}
}
이렇게 하면, 간단하게 memberContext.member로 꺼내서 멤버를 사용 할 수 있습니다.
제가 작성한 APi Controller는 아래와 같습니다.
@PostMapping("new")
fun createQuiz(
@RequestBody @Valid createDto: QuizCreateDto,
@AuthenticationPrincipal memberContext: MemberContext,
): ApiResponse {
val quiz = quizService.createQuiz(memberContext.member, createDto)
return ApiResponse.ok(mapOf(Pair("id", quiz.id)))
}
이렇게 함으로서, createQuiz에 회원 객체를 파라미터로 넘길 수 있게 되었는데요, 문제는 @AuthenticationPrincipal 입니다..
아까 작성한 테스트 코드를 실행 하면 이번에는
Request processing failed; nested exception is java.lang.NullPointerException: Parameter specified as non-null is null: method kr.quidev.quiz.controller_api.QuizApiController.createQuiz, parameter memberContext
분명 컨트롤러가 파라미터로 받기로 한 MemberContext가 넘어오질 못해서 에러가 발생합니다.
SecurityMockMvcRequestPostProcessors.user(String username)
이 모킹을 열심히 해 주었지만, 사실 실제 회원의 데이터를 이용하는 비즈니스 로직까지 해결을 해 주기에는 무리가 있었습니다.
그래서 이제는, 스프링 시큐리티의 인증도 통과 하면서 실제 비즈니스 로직도 통과 할 수 있는 방법이 필요 합니다.
스프링 시큐리티와 MockMvc를 연동하는 방법에 대해 스프링 공식 문서를 이리 저리 뒤지다 보니 다행히도 비슷한 내용을 찾았습니다.
https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html
그런데 Kotlin에서 같은 내용을 적용 하려 하니 계속 에러가 발생했습니다.
Not enough information to infer type variable T
어쩐지 스프링 문서에서도 Kotlin을 선택하면 아래와 같이 나와있었는데요..
Kotlin은 이슈가 있음.
어쩔 수 없이 테스트 코드를 자바로 작성 해 보았습니다.
QuizApiControllerTestJava.java
package kr.quidev.quiz.controller_api;
import com.fasterxml.jackson.databind.ObjectMapper;
import kr.quidev.member.domain.entity.Member;
import kr.quidev.member.service.MemberService;
import kr.quidev.quiz.domain.entity.QuizCreateDto;
import kr.quidev.quiz.domain.entity.Skill;
import kr.quidev.quiz.service.QuizService;
import kr.quidev.quiz.service.SkillService;
import kr.quidev.security.service.CustomUserDetailsService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import javax.transaction.Transactional;
import java.util.Collections;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
/**
* Use java test code instead
* until https://youtrack.jetbrains.com/issue/KT-22208 is fixed
* <p>
* ref: https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public class QuizApiControllerTestJava implements UserDetailsService {
@Autowired
QuizService quizService;
@Autowired
SkillService skillService;
@Autowired
MemberService memberService;
@Autowired
CustomUserDetailsService customUserDetailsService;
@Autowired
WebApplicationContext context;
@Autowired
ObjectMapper mapper;
@Test
public void createQuiz() throws Exception {
memberService.createMember(new Member(null, "pw", "name", "email", "role"));
UserDetails user = customUserDetailsService.loadUserByUsername("email");
Skill skill = skillService.save(new Skill(null, null, "java"));
QuizCreateDto quizCreateDto = new QuizCreateDto("desc", "answ", "expl", skill.getId(), new String[]{"ex1", "ex2", "ex3"});
ResultActions result = MockMvcBuilders.webAppContextSetup(context)
.apply(springSecurity())
.build().perform(
MockMvcRequestBuilders.post("/api/quiz/new")
.contentType(MediaType.APPLICATION_JSON)
.with(SecurityMockMvcRequestPostProcessors.user(user))
.content(mapper.writeValueAsString(quizCreateDto))
);
result.andExpect(MockMvcResultMatchers.status().isOk());
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return new User("username", "pw", Collections.EMPTY_LIST);
}
}
그리고 자바로 작성한 테스트 코드가 성공 하였습니다!!
그런데 코틀린 프로젝트에서 자바로 테스트를 작성해놓고 이슈가 해결되길 마냥 기다릴 수는 없어서 코틀린 테스트를 또 기웃 거리다보니
방금 자바 코드에서 적용한 것처럼, user 스태틱 메서드의 파라미터로 String 타입의 username 대신 UserDetails 타입의 user를 생성해서 보내면 만들어둔 MemberContext 까지도 충분히 전달이 될 수 있을거란 생각이 들었습니다.
그래서 코틀린에서도 같은 내용으로 코드를 적용 시켜 보았습니다.
package kr.quidev.quiz.controller_api
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import kr.quidev.common.ApiResponse
import kr.quidev.member.domain.entity.Member
import kr.quidev.member.service.MemberService
import kr.quidev.quiz.domain.entity.QuizCreateDto
import kr.quidev.quiz.domain.entity.Skill
import kr.quidev.quiz.service.QuizService
import kr.quidev.quiz.service.SkillService
import kr.quidev.security.service.CustomUserDetailsService
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import org.springframework.web.context.WebApplicationContext
import javax.transaction.Transactional
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
internal class QuizApiControllerTest {
@Autowired
lateinit var mockMvc: MockMvc
private val log = LoggerFactory.getLogger(javaClass)
private val mapper = jacksonObjectMapper()
@Autowired
lateinit var context: WebApplicationContext
@Autowired
lateinit var quizService: QuizService
@Autowired
lateinit var skillService: SkillService
@Autowired
lateinit var userDetailService: CustomUserDetailsService
@Autowired
lateinit var memberService: MemberService
val email = "shane@park.dev"
@BeforeEach
fun beforeEach() {
memberService.createMember(Member(name = "name", password = "pass", email = email))
}
@Test
@DisplayName("create quiz test: expected situation")
fun createQuiz() {
val user = userDetailService.loadUserByUsername(email)
val skill = skillService.save(Skill(id = null, parent = null, name = "java"))
val description = "desc"
val answer = "answer"
val explanation = "explanation"
val quizCreateDto = QuizCreateDto(
description = description,
answer = answer,
explanation = explanation,
examples = arrayOf("example1", "example2", "example3"),
skillId = skill.id
)
val result = mockMvc.perform(
MockMvcRequestBuilders.post("/api/quiz/new")
.contentType(MediaType.APPLICATION_JSON)
.with(SecurityMockMvcRequestPostProcessors.user(user))
.content(mapper.writeValueAsString(quizCreateDto))
)
result.andExpect(MockMvcResultMatchers.status().isOk)
val content = result.andReturn().response.contentAsString
log.info("content : {}", content)
val response: ApiResponse = mapper.readValue(content)
val body = response.body as Map<*, *>
val id = body["id"].toString().toLong()
val findById = quizService.findById(id).orElseThrow()
assertThat(findById.answer).isEqualTo(answer)
assertThat(findById.description).isEqualTo(description)
assertThat(findById.explanation).isEqualTo(explanation)
assertThat(findById.examples).hasSize(3)
assertThat(findById.skill).isEqualTo(skill)
assertThat(findById.skill?.name).isEqualTo("java")
}
@Test
@DisplayName("create quiz test: Description is not provided")
fun createQuizNoDesc() {
val user = userDetailService.loadUserByUsername(email)
for (description in arrayOf("", " ", null)) {
val quizCreateDto = QuizCreateDto(
description = description,
answer = "answer",
explanation = "explanation",
examples = arrayOf("example1", "example2", "example3"),
skillId = null
)
val result = mockMvc.perform(
MockMvcRequestBuilders.post("/api/quiz/new")
.contentType(MediaType.APPLICATION_JSON)
.with(SecurityMockMvcRequestPostProcessors.user(user))
.content(mapper.writeValueAsString(quizCreateDto))
)
result.andExpect(MockMvcResultMatchers.jsonPath("$.body").isEmpty)
.andExpect(MockMvcResultMatchers.jsonPath("$.error").isNotEmpty)
.andExpect(MockMvcResultMatchers.jsonPath("$.status").value("400"))
log.info(result.andReturn().response.contentAsString)
}
}
}
테스트를 실행 하면
성공
테스트 하기가 까다로울 거라고 생각했는데 @AuthenticationPrincipal
로 인증 정보를 전달 하는 것도 성공 했습니다.
회원 정보는 컨트롤러에서 id만 파라미터로 받고, 그걸로 회원 조회를 해서 비즈니스 로직을 돌려야 하나 고민도 했었는데 그렇게 되면 API 요청한 쪽의 인증 정보와 전달된 회원 정보가 일치하는지 확인하기가 쉽지 않을거라고 생각했고, 다행히 테스트 하는 방법을 찾아 낼 수 있었습니다.
스프링 시큐리티 인증이 필요한 테스트에서 고생하고 있는 다른 분들에게 조금이나마 도움이 되었으면 합니다.
이상입니다. 감사합니다.
References
'Programming > JPA ⁄ Spring' 카테고리의 다른 글
BCryptPasswordEncoder 사용시 인코딩 할때마다 결과가 달라져요 (0) | 2022.10.15 |
---|---|
[Spring] @PageableDefault 혹은 @SortDefault 여러 컬럼으로 정렬하기 (0) | 2022.09.17 |
[Spring Boot JPA] P6Spy 활용해 쿼리 로그 확인하기 (0) | 2022.09.03 |
[Spring Boot] git 정보 확인하는 endpoint 작성하기 (0) | 2022.06.24 |
Spring) 스프링의 "redirect:" 리다이렉트 처리 (1) | 2022.05.13 |