이번 시간에는, 지난 시간에 작성했던 코드들을 테스트하는 시간을 가져보도록 하겠습니다.
이를 테스트하기 위해 MemberController에 작성했던 두 개의 API를 이용할 것입니다.
먼저, 전체 코드를 살펴보고 각각에 대해서 세부적으로 살펴보겠습니다.
test 디렉토리에서 MemberController와 동일한 패키지 경로에 MemberControllerIntegrationTest를 작성해줍니다.
package kukekyakya.kukemarket.controller.member;
import kukekyakya.kukemarket.dto.sign.SignInRequest;
import kukekyakya.kukemarket.dto.sign.SignInResponse;
import kukekyakya.kukemarket.entity.member.Member;
import kukekyakya.kukemarket.exception.MemberNotFoundException;
import kukekyakya.kukemarket.init.TestInitDB;
import kukekyakya.kukemarket.repository.member.MemberRepository;
import kukekyakya.kukemarket.service.sign.SignService;
import org.junit.jupiter.api.BeforeEach;
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.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles(value = "test")
@Transactional
class MemberControllerIntegrationTest {
@Autowired WebApplicationContext context;
@Autowired MockMvc mockMvc;
@Autowired TestInitDB initDB;
@Autowired SignService signService;
@Autowired MemberRepository memberRepository;
@BeforeEach
void beforeEach() {
mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
initDB.initDB();
}
@Test
void readTest() throws Exception {
// given
Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
// when, then
mockMvc.perform(
get("/api/members/{id}", member.getId()))
.andExpect(status().isOk());
}
@Test
void deleteTest() throws Exception {
// given
Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
SignInResponse signInRes = signService.signIn(new SignInRequest(initDB.getMember1Email(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/members/{id}", member.getId()).header("Authorization", signInRes.getAccessToken()))
.andExpect(status().isOk());
}
@Test
void deleteByAdminTest() throws Exception {
// given
Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
SignInResponse adminSignInRes = signService.signIn(new SignInRequest(initDB.getAdminEmail(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/members/{id}", member.getId()).header("Authorization", adminSignInRes.getAccessToken()))
.andExpect(status().isOk());
}
@Test
void deleteUnauthorizedByNoneTokenTest() throws Exception {
// given
Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
// when, then
mockMvc.perform(
delete("/api/members/{id}", member.getId()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/entry-point"));
}
@Test
void deleteAccessDeniedByNotResourceOwnerTest() throws Exception {
// given
Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
SignInResponse attackerSignInRes = signService.signIn(new SignInRequest(initDB.getMember2Email(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/members/{id}", member.getId()).header("Authorization", attackerSignInRes.getAccessToken()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/access-denied"));
}
@Test
void deleteAccessDeniedByRefreshTokenTest() throws Exception {
// given
Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
SignInResponse signInRes = signService.signIn(new SignInRequest(initDB.getMember1Email(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/members/{id}", member.getId()).header("Authorization", signInRes.getRefreshToken()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/access-denied"));
}
}
이제 각각의 부분에 대해서 살펴보겠습니다.
@SpringBootTest // 1
@AutoConfigureMockMvc // 2
@ActiveProfiles("test") // 3
@Transactional // 4
class MemberControllerIntegrationTest {
@Autowired WebApplicationContext context; // 5
@Autowired MockMvc mockMvc; // 6
@Autowired TestInitDB initDB; // 7
@Autowired SignService signService; // 8
@Autowired MemberRepository memberRepository; // 9
1. Spring Security를 이용해서 검증하려면, 여러가지 빈들이 등록되고 협력해야합니다.
예를 들어, 삭제 요청을 수행할 수 있는지 검증하려면, 토큰의 유효성을 검증하면서 사용자의 정보를 데이터베이스에서 조회해야하고, 접근 제어 정책을 검증할 때도 MemberGuard나 AuthHelper 등의 여러 객체들이 협력을 수행합니다.
Spring Security가 정상적으로 동작하기 위해서는, 여러 종류의 빈들이 요구되고, 그것을 일일이 구분짓기엔 어려움(또는 귀찮음)이 있으므로, Spring Security에 관한 테스트는 @SpringBootTest를 선언하여 통합 테스트로 진행하도록 하겠습니다.
Spring Security의 테스트를 도와주는 어노테이션(@WithMockUser 등)이 많이 있지만, 결국 앱을 테스트해보기 위해서는 통합 테스트의 과정도 필요하므로, API에 대한 테스트만큼은 이러한 방법을 택하게 되었습니다.
2. @SpringBootTest의 기본 웹 관련 설정은 WebEnvironment.MOCK 입니다. 내장 톰캣으로 실제로 서버를 띄우는 것이 아니라, 가짜로 웹 환경을 만들어서 테스트를 수행하게 됩니다. 그렇다 해도, MockMvc는 자동으로 스프링 빈에 등록되지 않습니다. 이를 주입하기 위해 @AutoConfigureMockMvc를 선언해주었습니다.
만약 내장 톰캣을 실제로 띄우고싶다면, @SpringBootTest의 webEnvironment 설정을 RANDOM_PORT로 설정해주면 됩니다.
(통합 테스트가 실제 프로덕션 환경과 유사하도록 RANDOM_PORT를 사용하는 것을 권장드립니다.)
3. 지금 스프링 부트 앱을 실행할 때는, 설정 파일을 통해 기본적으로 local profile이 설정되게 해두었습니다.
하지만 이렇게 될 경우, 테스트를 위해 @Profile("local")로 지정해두었던 InitDB가 빈으로 등록되면서, 테스트와 무관한 데이터들이 데이터베이스에 초기화됩니다. 하지만 우리는 통합 테스트를 진행하기 위한 데이터를 별도로 삽입해줄 것입니다.
이로 인한 충돌을 방지하기 위해 @ActiveProfiles("test")로 설정하여, InitDB가 빈으로 등록되지 않도록 하겠습니다.
4. 테스트 데이터를 초기화하거나 로그인하여 토큰을 발급시키는 과정 등에서 데이터베이스를 이용해야하므로, @Transactional을 선언해주었습니다. 테스트에서는 자동으로 롤백이 수행됩니다.
5. MockMvc를 빌드하기 위해 WebApplicationContext를 주입받습니다.
6. API 요청을 보내고 테스트하기 위해 주입받습니다.
7. 통합 테스트에서 사용될 데이터 초기화를 위한 빈입니다. 테스트에 필요한 데이터들을 삽입하여 데이터베이스를 초기화해줍니다.
8~9. MemberController 테스트를 위해 필요한 의존성입니다.
@BeforeEach
void beforeEach() {
mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
initDB.initDB();
}
MockMvcBuilders를 이용해서 MockMcv를 초기화해줍니다.
Spring Security를 활성화하기 위해, apply(springSecurity())를 호출해줍니다.
테스트를 위한 데이터베이스를 초기화합니다.
각각의 테스트를 살펴 보기전에, test 디렉토리의 init 패키지에서 데이터베이스 초기화를 위한 TestInitDB를 살펴보도록 하겠습니다.
package kukekyakya.kukemarket.init;
import ...
@Component
public class TestInitDB {
@Autowired RoleRepository roleRepository;
@Autowired MemberRepository memberRepository;
@Autowired PasswordEncoder passwordEncoder;
private String adminEmail = "admin@admin.com";
private String member1Email = "member1@member.com";
private String member2Email = "member2@member.com";
private String password = "123456a!";
@Transactional
public void initDB() {
initRole();
initTestAdmin();
initTestMember();
}
private void initRole() {
roleRepository.saveAll(
List.of(RoleType.values()).stream().map(roleType -> new Role(roleType)).collect(Collectors.toList())
);
}
private void initTestAdmin() {
memberRepository.save(
new Member(adminEmail, passwordEncoder.encode(password), "admin", "admin",
List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new),
roleRepository.findByRoleType(RoleType.ROLE_ADMIN).orElseThrow(RoleNotFoundException::new)))
);
}
private void initTestMember() {
memberRepository.saveAll(
List.of(
new Member(member1Email, passwordEncoder.encode(password), "member1", "member1",
List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new))),
new Member(member2Email, passwordEncoder.encode(password), "member2", "member2",
List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new))))
);
}
public String getAdminEmail() {
return adminEmail;
}
public String getMember1Email() {
return member1Email;
}
public String getMember2Email() {
return member2Email;
}
public String getPassword() {
return password;
}
}
InitDB 클래스와 크게 다르지 않습니다.
@EventListener를 제거하여 직접 호출하여 수행할 수 있도록 하였고,
테스트 작성의 편리함을 위해 초기화한 데이터 정보들을 getter로 반환해줄 수 있도록 하였습니다.
관리자 계정 1개, 일반 계정 2개를 만들어주었습니다.
이제 각각의 테스트를 살펴보도록 하겠습니다.
단위 테스트는 많이 작성해서 가볍게 넘어갔지만, 통합 테스트는 처음 언급하므로 하나 하나 살펴보겠습니다.
@Test
void readTest() throws Exception {
// given
Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
// when, then
mockMvc.perform(
get("/api/members/{id}", member.getId()))
.andExpect(status().isOk());
}
사용자 정보 조회를 위한 GET /api/members/{id} 요청입니다.
SecurityConfig 설정에서 GET 요청은 모두 permitAll로 설정해주었습니다.
200을 응답하며 성공적으로 테스트를 수행하게 됩니다.
@Test
void deleteTest() throws Exception {
// given
Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
SignInResponse signInRes = signService.signIn(new SignInRequest(initDB.getMember1Email(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/members/{id}", member.getId()).header("Authorization", signInRes.getAccessToken()))
.andExpect(status().isOk());
}
사용자 정보 삭제를 위한 DELETE /api/members/{id} 요청입니다.
로그인하여 발급받은 액세스 토큰을, Authorization 헤더에 포함해서 요청을 보내주었습니다.
정상적으로 처리되어서 200 상태 코드를 응답 받게 됩니다.
@Test
void deleteByAdminTest() throws Exception {
// given
Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
SignInResponse adminSignInRes = signService.signIn(new SignInRequest(initDB.getAdminEmail(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/members/{id}", member.getId()).header("Authorization", adminSignInRes.getAccessToken()))
.andExpect(status().isOk());
}
관리자에 의한 삭제 요청입니다.
관리자가 로그인해서 발급받은 토큰으로, 다른 사용자 정보의 삭제를 요청하고 있습니다.
관리자라면 요청을 수행할 수 있어야하므로, 200 상태 코드를 응답 받게 됩니다.
@Test
void deleteUnauthorizedByNoneTokenTest() throws Exception {
// given
Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
// when, then
mockMvc.perform(
delete("/api/members/{id}", member.getId()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/entry-point"));
}
인증되지 않은 사용자가 요청을 전송하였습니다.
요청자를 인증할 수 있는 액세스 토큰이 Authorization 헤더에 포함되어있지 않습니다.
지정해두었던 CustomAuthenticationEntryPoint가 작동하며,
3xx 상태 코드를 응답받아서 /exception/entry-point로 리다이렉트됩니다.
@Test
void deleteAccessDeniedByNotResourceOwnerTest() throws Exception {
// given
Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
SignInResponse attackerSignInRes = signService.signIn(new SignInRequest(initDB.getMember2Email(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/members/{id}", member.getId()).header("Authorization", attackerSignInRes.getAccessToken()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/access-denied"));
}
인증된 사용자가 자신의 자원이 아닌, 남의 자원에 접근하는 것을 요청하였습니다.
A가 로그인해서, B의 정보를 삭제하고 있는 상황입니다.
관리자가 아닌 일반 사용자이기에, 요청을 수행할 권한이 없어야합니다.
지정해두었던 CustomAccessDeniedHandler가 작동하며,
3xx 상태 코드를 응답받아서 /exception/access-denied로 리다이렉트됩니다.
@Test
void deleteAccessDeniedByRefreshTokenTest() throws Exception {
// given
Member member = memberRepository.findByEmail(initDB.getMember1Email()).orElseThrow(MemberNotFoundException::new);
SignInResponse signInRes = signService.signIn(new SignInRequest(initDB.getMember1Email(), initDB.getPassword()));
// when, then
mockMvc.perform(
delete("/api/members/{id}", member.getId()).header("Authorization", signInRes.getRefreshToken()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/exception/access-denied"));
}
API 요청은 액세스 토큰일 때만 허용되도록 설정해두었습니다.
정상적인 사용자이지만 리프레시 토큰으로 요청하였을 경우, 요청을 제한하며 CustomAccessDeniedHandler가 작동하며,
3xx 상태 코드를 응답받아서 /exception/access-denied로 리다이렉트됩니다.
이번 시간에는, MemberController를 통합테스트하면서 Spring Security를 통한 인증 및 인가 테스트도 함께 끝마칠 수 있었습니다.
이제 이를 이용하여 새로운 API가 추가되더라도, SecurityConfig에 보안 정책만 정의해주면 됩니다.
어느 덧, 프로젝트 생성부터 로그인 기능을 구현하기까지 10번째 게시글을 작성하게 되었습니다.
사실 아직, 로그인 기능이 완벽하게 끝나진 않았습니다.
아직 리프레시 토큰을 이용하여 사용자에게 액세스 토큰을 재발급하는 과정을 작성하지 않았습니다.
이에 대한 내용도 바로 이어서 작성할 예정이었지만, 그 전에 몇 가지 사항을 우선적으로 수행해보도록 하겠습니다.
우리의 테스트 코드에는 어떤 중복이 발생하고 있습니다.
여러 테스트 클래스(단위 테스트든, 통합 테스트든)에서 테스트 데이터를 준비하기 위해 중복된 코드가 빈번하게 작성되고 있습니다.
예를 들어, Member를 생성하기 위한 createMember 메소드는,
SignServceTest, MemberRepositoryTest, MemberServiceTest 등 다양한 클래스에서 중복해서 작성되고 있습니다.
이를 보완하기 위해 테스트 코드를 리팩토링할 필요성이 생기게 되었습니다.
커다란 변화는 만들지 않을 것이고, 단순히 별도의 팩토리 클래스를 작성하여, 테스트에 필요한 객체 생성 책임을 위임해보도록 하겠습니다.
우리가 구현하고 있는 것은 API 서버입니다.
클라이언트(웹 또는 모바일)는 우리의 API를 이용할 수 있어야합니다.
클라이언트가 API를 이용한다고 해서, 반드시 코드를 열어보고 어떤 요청을 허용하는지 검사해야하는 등 사용자에게 책임을 떠넘기면 안됩니다.
이를 위해 우리가 작성한 API를 올바르게 이용할 수 있도록, API 문서를 작성하는 시간을 가져보도록 하겠습니다.
Swagger라는 도구를 이용해서, 어노테이션이나 자바 코드를 통해 손쉽게 API 문서를 작성하고, 브라우저를 이용하여 API를 테스트해볼 수 있도록 하겠습니다.
* 처음 게시글에서도 언급했었지만, 완성된 프로젝트를 가지고 진행하는 내용이 아닙니다.
프로젝트를 완성해나가면서 발생하는 이슈들을 정리하고, 대응해나가는 과정입니다.
물론, 세세하게 모든 과정을 다 담아내진 못하겠지만, 진행하며 생긴 고민과 해결책은 최대한 공유할 예정입니다.
사실 지금도 모든 고민 및 대응 과정을 공유하는 것도 아니고, 정리하고 싶은 코드도 많습니다.
예를 들어, 토큰 종류에 따라 처리되는 유사한 로직들의 반복(앞으로 더 추가될 일은 없겠지만), 각 요청마다 Guard에서 토큰의 종류를 계속 식별해야하는 작업 등 부족한 부분은 끝이 없습니다.
하지만 이 프로젝트의 본질은, 어찌됐든 물품 판매를 목적으로 하는 게시판 기능의 완성이므로,
당장의 개선이 무조건적으로 필요한 부분이 아니라면, 당분간 묵인하고 넘어갈 예정입니다.
지금 해결하지 않으면, 앞으로 어려움이 있을 것이라 생각되는 부분만 즉시 해결하고 넘어가겠습니다.
아직 프로젝트의 규모가 작기에 큰 변화는 없겠지만, 앞으로 점차 늘어날 규모에 미리 대비할 뿐입니다.
물론 지금 생긴 변화는, 커다란 규모에 대응하면서 다시 변화될 수 있습니다.
그저 당장에 할 수 있는 최선책을 마련해둘 뿐입니다.
다음 시간에는 테스트 코드를 간단하게 리팩토링하는 시간을 가져보도록 하겠습니다.
* 질문 및 피드백은 환영입니다.
* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.
https://github.com/SongHeeJae/kuke-market
'Spring > 게시판 만들기' 카테고리의 다른 글
스프링부트 게시판 API 서버 만들기 (12) - 로그인 - 8 - 인증 및 인가 - 4(마무리) (4) | 2021.12.05 |
---|---|
스프링부트 게시판 API 서버 만들기 (11) - 테스트 코드 리팩토링 (0) | 2021.12.04 |
스프링부트 게시판 API 서버 만들기 (9) - 로그인 - 6 - 인증 및 인가 - 2 (0) | 2021.12.02 |
스프링부트 게시판 API 서버 만들기 (8) - 로그인 - 5 - 인증 및 인가 - 1 (1) | 2021.12.02 |
스프링부트 게시판 API 서버 만들기 (7) - detached entity passed to persist 해결하기 (0) | 2021.12.01 |