새로운 프로젝트를 진행하면서 가장 큰 목표 중 하나는 테스트 코드에 충분한 시간과 노력을 들여서 안정적인 애플리케이션을 개발하기였다. 이번에 처음 통합 테스트를 구현했던 과정과 배운 내용을 정리해 본다. (피드백 환영!)
통합 테스트 공통 Class
통합 테스트에 공통적으로 사용할 수 있는 공통 클래스를 만들었다.
@Disabled
@Transactional
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@ActiveProfiles("test")
@ExtendWith(RestDocumentationExtension.class)
public class BaseIntegrationTest {
@Autowired
protected MockMvc mockMvc;
}
@SpringBootTest
Test를 위한 Application Context를 로딩하며 여러가지 속성을 제공한다.
@Disabled
해당 애너테이션이 지정된 테스트 클래스 또는 테스트 메서드를 실행하지 않는다. BaseIntegrationTest 클래스는 상속만을 위한 클래스이기 때문에 실행할 필요가 없다.
@Transactional
클래스 내부의 각각의 테스트 메서드가 실행될 때마다, 데이터베이스를 롤백한다. 반복 가능한 테스트 코드를 위해 필요하다. 만약, 테스트 후 데이터를 직접 보고 싶다면 @Rollback(false) 애너테이션을 추가하면 된다.
@AutoConfigureMockMvc
@WebMvcTest가 아닌 @SpringBootTest 애너테이션을 사용하면서 MockMvc를 이용한 테스트를 해야 할 때 필요한 애너테이션이다.
@WebMvcTest
Web 계층(Controller)만을 테스트할 때 사용하는 애너테이션이다. Web 계층 테스트에 필요한 Bean들만 등록한다. Spring Security를 사용 중이라면 Spring Security도 함께 진행된다.
@ActiveProfiles
테스트 수행 사용할 프로파일을 지정할 수 있다. @ActiveProfiles("test") 애너테이션을 사용하면 test 프로파일을 사용한다. test 프로파일은 test 환경에 맞게 설정되어 있다.
@AutoConfigureRestDocs
Mock MVC, REST Assured 또는 WebTestClient로 테스트할 때 Spring REST Docs를 사용할 수 있다. @AutoConfigureRestDocs는 Spring REST Docs를 사용하기 위해 MockMvc 빈을 커스터마이즈한다. 이번 프로젝트에서 REST Docs로 API 명세서를 생성하기로 했다.
- Spring REST Docs : 테스트 코드 기반으로 HTML, Markdown, Assicoctor 등 다양한 형식의 문서를 생성하여 RESTful API를 문서화할 수 있도록 도와주는 라이브러리
@ExtendWith(RestDocumentationExtension.class)
Spring REST Docs를 활성화하는 데 사용되는 JUNit 5 애너테이션.
MockMvc란?
MockMvc는 스프링 Mvc의 통합테스트를 위한 라이브러리이다.
MockMvc.perform()
해당 메서드는 MockMvcRequestBuilders 를 매개 변수로 받아서 ResultActions 를 return한다. MockMvcRequestBuilders를 반환하는 정적 메서드로 post() , get() , patch(), delete() 등이 있다.
이 메서드들은 HttpRequest를 만들어내기 위한 Builder로 header, body 등을 지정하는 메서드들이 존재한다. 이를 통해 간편하게 테스트를 위한 웹 요청을 만들 수 있다.
ResultActions.andDo()
MockMvc 요청을 한 뒤, 행동을 지정하는 메서드이다. 결과를 출력하거나 로그를 출력하는 등의 행동을 지정할 수 있다.
ResultActions.andExpect()
요청의 결과로 예상되는 응답을 지정하여 테스트를 진행할 수 있다. 응답 코드, 본문에 포함되는 데이터, 헤더, 쿠키, 세션 등 응답에 포함되는 전반적인 데이터들을 테스트할 수 있다.
통합 테스트 Class
아래는 회원 관련 통합 테스트 코드이다. 편의를 위해 회원 정보 수정 테스트에 대해서만 이야기해보려 한다.
class MemberIntegrationTest extends BaseIntegrationTest {
private final String BASE_URL = "/members";
private final String EMAIL = "email@gmail.com";
@Autowired
private MemberService memberService;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private AES128Config aes128Config;
@BeforeEach
void befroeEach() {
MemberDto.SignUp signUpDto = StubData.MockMember.getSignUpDto();
memberService.signUp(signUpDto);
}
@AfterEach
void afterEach() {
memberService.deleteMember(EMAIL);
}
@Test
@DisplayName("회원 정보 수정")
void patchMemberTest() throws Exception {
// given
CustomUserDetails userDetails = StubData.MockMember.getUserDetails();
TokenDto tokenDto = jwtTokenProvider.generateTokenDto(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
// when
String json = ObjectMapperUtils.asJsonString(patchDto);
String uri = UriComponentsBuilder.newInstance().path(BASE_URL)
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.
patchRequestWithContentAndToken(mvc, uri, json, accessToken, encryptedRefreshToken);
// then
Response response = ObjectMapperUtils.actionsSingleResponseToMemberDto(actions);
MemberDto.Patch patchDto = StubData.MockMember.getPatchDto();
assertThat(response.getNickname()).isEqualTo(patchDto.getNickname());
assertThat(response.getAddress()).isEqualTo(patchDto.getAddress());
assertThat(response.getIntroduction()).isEqualTo(patchDto.getIntroduction());
assertThat(response.getNation()).isEqualTo(patchDto.getNation());
actions
.andExpect(status().isOk())
.andDo(document("patch-member",
getRequestPreProcessor(),
getResponsePreProcessor(),
MemberRequestSnippet.getPatchSnippet(),
MemberResponseSnippet.getMemberResponseSnippet()));
}
...
}
@BeforeEach, @AfterEach
@BeforeEach
void befroeEach() {
MemberDto.SignUp signUpDto = StubData.MockMember.getSignUpDto();
memberService.signUp(signUpDto);
}
@AfterEach
void afterEach() {
memberService.deleteMember(EMAIL);
}
- @BeforeEach: @BeforeEach 애너테이션이 붙은 메서드는 각 테스트가 실행 되기 전에 실행되는 메서드이다.
- @AfterEach: @AfterEach 애너테이션이 붙은 메서드는 각 테스트가 실행된 후에 실행되는 메서드이다.
회원 정보를 수정하기 위해서는 DB에 회원이 있어야 한다. 테스트를 진행한 이후에는 해당 회원을 DB에서 삭제해야 다음 테스트에 영향을 주지 않는다. 흐름을 정리해 보면 다음과 같다.
회원A 생성 후 회원을 DB에 저장(Before) → 회원A 정보 수정(Test) → 회원A를 DB에서 삭제(After)
이제 회원 정보 수정 테스트 코드를 살펴보자
given, when, then
테스트 코드 작성 시 사용되는 코딩 스타일이다.
- 어떤 값이 주어지고(given)
- 무엇을 했을 때(when)
- 어떤 값을 반환한다.(then)
직관적으로 테스트 코드의 흐름을 알 수 있기 때문에 코드의 가독성이 향상된다. 테스트 코드의 가독성이 중요한 이유는 테스트 코드가 문서로써의 역할을 하기 때문이다. 테스트 코드를 봄으로써, 해당 메서드를 작성한 개발자가 어떤 의도로 만들었으며, 어떻게 동작하길 원하는지를 알 수 있다.
given
// given
CustomUserDetails userDetails = StubData.MockMember.getUserDetails();
TokenDto tokenDto = jwtTokenProvider.generateTokenDto(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
현재 프로젝트에는 jwt를 사용한 Spring Security가 적용되어 있다. 그래서 회원 수정 요청이 오면 jwt 토큰을 통해 회원을 인증, 인가하는 과정을 거쳐야 한다.
jwt 토큰 생성을 담당하는 jwtTokenProvider 클래스로 Access Token과 Refresh Token을 생성한다. Aes128를 사용해서 Rfresh Token을 암호화해서 전달하기 때문에 똑같이 암호화해서 Header에 담아줬다.
when
String json = ObjectMapperUtils.asJsonString(patchDto);
String uri = UriComponentsBuilder.newInstance().path(BASE_URL)
.build().toUri().toString();
ResultActions actions = ResultActionsUtils
.patchRequestWithContentAndToken(mvc, uri, json, accessToken, encryptedRefreshToken);
ObjectMapperUtils.asJsonString(patchDto); : RequestBody로 전달하게 할 내용을 json으로 직렬화해 준다. 직렬화는 다양한 테스트 클래스에서 사용되기 때문에 ObjectMapperUtils 클래스를 만들어 추상화해 주었다. 이렇게 생성된 json은 mvc에 mvc.content(json) 으로 설정된다.
ObjectMapperUtils
public class ObjectMapperUtils {
private static final ObjectMapper objectMapper = new ObjectMapper();
public static String asJsonString(final Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
UriComponentsBuilder.newInstance().path(BASE_URL).build().toUri().toString(); :
Uri를 직접 작성하는 것은 굉장히 번거롭다. UriComponentBuilder 는 Uri를 보다 쉽게 만들 수 있도록 도와준다. 이렇게 만들어진 uri는 mvc.perform(get(url)) 형식으로 설정하는 데 사용한다.
UriComponentsBuilder
public class UriComponentsBuilder implements UriBuilder, Cloneable {
@Override
public UriComponentsBuilder path(String path) {
this.pathBuilder.addPath(path);
resetSchemeSpecificPart();
return this;
}
}
ResultActionsUtils.patchRequestWithContentAndToken(mvc, uri, json, accessToken, encryptedRefreshToken); : mvc를 사용해 HTTP 요청을 수행한다. ResultActionsUtils 을 만들어 다양한 테스트 클래스에서 사용할 수 있도록 했다.
ResultActionsUtils
public class ResultActionsUtils {
public static final String AUTHORIZATION_HEADER = "Authorization";
private static final String REFRESH_HEADER = "Refresh";
private static final String BEARER_PREFIX = "Bearer ";
public static ResultActions patchRequestWithContentAndToken(MockMvc mockMvc,
String url,
String json,
String accessToken,
String encryptedRefreshToken) throws Exception {
return mockMvc.perform(patch(url)
.contentType(MediaType.APPLICATION_JSON)
.content(json)
.header(AUTHORIZATION_HEADER, BEARER_PREFIX + accessToken)
.header(REFRESH_HEADER, encryptedRefreshToken))
.andDo(print());
}
}
Request Body는 Json 형식으로 설정하고 access token과 refresh token은 header에 설정했다. 마지막으로 print() 메서드를 호출해서 콘솔에 출력하게 했다.
지금은 통합테스트이기 때문에 문제가 없지만 @WebMvcTest를 진행할 때는 커스텀한 Security가 아닌 기본으로 설정되어 있는 Security를 사용하기 때문에 csrf()가 활성화되어 있다. 그래서 .with(csrf) 메서드를 추가해줘야 한다.
then
MemberDto.Response response = ObjectMapperUtils.actionsSingleResponseToMemberDto(actions);
MemberDto.Patch patchDto = StubData.MockMember.getPatchDto();
assertThat(response.getNickname()).isEqualTo(patchDto.getNickname());
assertThat(response.getAddress()).isEqualTo(patchDto.getAddress());
assertThat(response.getIntroduction()).isEqualTo(patchDto.getIntroduction());
assertThat(response.getNation()).isEqualTo(patchDto.getNation());
actions
.andExpect(status().isOk())
.andDo(document("patch-member",
getRequestPreProcessor(),
getResponsePreProcessor(),
MemberRequestSnippet.getPatchSnippet(),
MemberResponseSnippet.getMemberResponseSnippet()));
ObjectMapperUtils.actionsSingleResponseToMemberDto(actions); : 요청에 맞게 응답으로 날아온 Json형식의 Response Body를 ResponseDto로 역직렬화해주는 메서드.
ObjectMapperUtils
public class ObjectMapperUtils {
private static final ObjectMapper objectMapper = new ObjectMapper();
public static Response actionsSingleResponseToMemberDto(ResultActions actions) throws Exception {
String response = actions.andReturn().getResponse().getContentAsString();
return objectMapper.registerModule(new JavaTimeModule()).readValue(response, Response.class);
}
}
역직렬화하는 과정에서 Java 8 Local Date Time 직렬화/역직렬화하지 못해서 에러가 발생했었다. .registerModule(new JavaTimeModule()) 을 추가해 주어 해결했다. 자세한 내용은 해당 링크에 정리해 두었다.(https://green-bin.tistory.com/63)
assertThat(response.getNickname()).isEqualTo(patchDto.getNickname()); : JUnit의 assertThat 문을 사용해서 두 객체가 같은지 비교하고 있다. 두 객체가 같으면 테스트는 통과하고 두 객체가 다르면 실패를 나타내는 오류 메시지를 반환한다.
actions.andExpect(status().isOk())
.andDo(document("patch-member",
getRequestPreProcessor(),
getResponsePreProcessor(),
MemberRequestSnippet.getPatchSnippet(),
MemberResponseSnippet.getMemberResponseSnippet()));
- andExpect(status().isOk()) : 기대하는 HTTP status를 설정할 수 있다. isOk() 는 200 OK 상태를 나타낸다.
- andDo(document(....)) : Spring REST DOCS를 사용해 API 문서를 만들 때 사용된다. “patch-member”는 생성될 파일의 이름이다.
- getRequestPreProcessor(), getResponsePreProcessor() : preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) 를 반환한다.
public class ApiDocumentUtils {
public static OperationRequestPreprocessor getRequestPreProcessor() {
return preprocessRequest(prettyPrint());
}
public static OperationResponsePreprocessor getResponsePreProcessor() {
return preprocessResponse(prettyPrint());
}
}
prettyPrint() 는 Request와 Response를 API 문서에 조금 더 보기 좋게 표현해 준다.(들여 쓰기, 줄바꿈)
// prettyPrint() 적용 전
Path: /api/members
HTTP Method: POST
Request:
{"email":"jane.doe@example.com","nickname":"Jane","password":"test1234","nation":"USA","address":"123 Main St","image":"<https://example.com/image.jpg","introduction":"Hi>, I'm Jane!","role":"USER"}
// prettyPrint() 적용 후
Path: /api/members
HTTP Method: POST
Request:
{
"email": "jane.doe@example.com",
"nickname": "Jane",
"password": "test1234",
"nation": "USA",
"address": "123 Main St",
"image": "<https://example.com/image.jpg>",
"introduction": "Hi, I'm Jane!",
"role": "USER"
}
MemberRequestSnippet.getPatchSnippet(), MemberResponseSnippet.getMemberResponseSnippet() : 요청 및 응답 본문의 구조를 설명하는 스니펫이다. 스니펫도 굉장히 코드가 길어지기 때문에 별도 클래스로 관리하도록 했다.
public class MemberResponseSnippet {
public static Snippet getPatchSnippet() {
return requestFields(
List.of(
fieldWithPath("password").type(JsonFieldType.STRING).description("회원 비밀번호"),
fieldWithPath("nickname").type(JsonFieldType.STRING).description("회원 닉네임"),
fieldWithPath("image").type(JsonFieldType.STRING).description("프로필 이미지 url"),
fieldWithPath("address").type(JsonFieldType.STRING).description("회원 주소"),
fieldWithPath("introduction").type(JsonFieldType.STRING).description("자기 소개"),
fieldWithPath("nation").type(JsonFieldType.STRING).description("회원 국가")
)
);
}
public static ResponseFieldsSnippet getMemberResponseSnippet() {
return responseFields(
List.of(
fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("회원 ID"),
fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"),
fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("작성자 닉네임 및 타입"),
fieldWithPath("data.nation").type(JsonFieldType.STRING).description("회원 국가"),
fieldWithPath("data.address").type(JsonFieldType.STRING).description("회원 주소"),
fieldWithPath("data.image").type(JsonFieldType.STRING).description("프로필 이미지 url"),
fieldWithPath("data.introduction").type(JsonFieldType.STRING).description("자기소개"),
fieldWithPath("data.role").type(JsonFieldType.STRING).description("회원 역할"),
fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("회원 가입일"),
fieldWithPath("data.lastModifiedAt").type(JsonFieldType.STRING).description("회원 정보 수정일")
)
);
}
}
이제 테스트를 하면 문제없이 성공한다!
테스트 코드를 보면서 왜 이렇게 추상화를 많이 했지? 싶을 수도 있다. 추상화를 하지 않았을 때의 회원 정보 수정 테스트 코드를 살펴보자.
Before
@Test
@DisplayName("회원 정보 수정")
void patchMemberTest() throws Exception {
// given
CustomUserDetails userDetails = StubData.MockMember.getUserDetails();
TokenDto tokenDto = jwtTokenProvider.generateTokenDto(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
// when
String json = objectMapper.writeValueAsString(obj);
String uri = UriComponentsBuilder.newInstance().path(BASE_URL)
.build().toUri().toString();
ResultActions actions = mockMvc.perform(patch(url)
.contentType(MediaType.APPLICATION_JSON)
.content(json)
.with(csrf())
.header(AUTHORIZATION_HEADER, BEARER_PREFIX + accessToken)
.header(REFRESH_HEADER, encryptedRefreshToken))
.andDo(print());
// then
String response = actions.andReturn().getResponse().getContentAsString();
Response response = objectMapper.registerModule(new JavaTimeModule()).readValue(response, Response.class);
MemberDto.Patch patchDto = return MemberDto.Patch.builder()
.password("patch" + password)
.nickname("patch" + nickname)
.address("patch" + address)
.nation("patch" + nation)
.image("patch" + image)
.introduction("patch" + introduction)
.build();
assertThat(patchDto.getNickname()).isEqualTo(response.getNickname());
assertThat(patchDto.getAddress()).isEqualTo(response.getAddress());
assertThat(patchDto.getIntroduction()).isEqualTo(response.getIntroduction());
assertThat(patchDto.getNation()).isEqualTo(response.getNation());
actions
.andExpect(status().isOk())
.andDo(document("patch-member",
getRequestPreProcessor(),
getResponsePreProcessor(),
requestFields(
List.of(
fieldWithPath("password").type(JsonFieldType.STRING).description("회원 비밀번호"),
fieldWithPath("nickname").type(JsonFieldType.STRING).description("회원 닉네임"),
fieldWithPath("image").type(JsonFieldType.STRING).description("프로필 이미지 url"),
fieldWithPath("address").type(JsonFieldType.STRING).description("회원 주소"),
fieldWithPath("introduction").type(JsonFieldType.STRING).description("자기 소개"),
fieldWithPath("nation").type(JsonFieldType.STRING).description("회원 국가")
)
),
responseFields(
List.of(
fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("회원 ID"),
fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"),
fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("작성자 닉네임 및 타입"),
fieldWithPath("data.nation").type(JsonFieldType.STRING).description("회원 국가"),
fieldWithPath("data.address").type(JsonFieldType.STRING).description("회원 주소"),
fieldWithPath("data.image").type(JsonFieldType.STRING).description("프로필 이미지 url"),
fieldWithPath("data.introduction").type(JsonFieldType.STRING).description("자기소개"),
fieldWithPath("data.role").type(JsonFieldType.STRING).description("회원 역할"),
fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("회원 가입일"),
fieldWithPath("data.lastModifiedAt").type(JsonFieldType.STRING).description("회원 정보 수정일")
)
);
}
만약 추상화를 하지 않았다면 회원정보의 수정 메서드는 위 코드처럼 길고 복잡할 것이다.
테스트 코드는 가독성이 중요하다. given, when, then을 통해 어떤 데이터를 주고 어떤 행동을 했을 때 어떤 결과가 나오는지 한눈에 알기 쉬워야 한다.
그래서 테스트 코드에 노출될 필요가 없는 내용이나 공통적으로 사용되는 내용은 추상화해 주어 아래의 테스트 코드가 나오게 되었다.
After
@Test
@DisplayName("회원 정보 수정")
void patchMemberTest() throws Exception {
// given
CustomUserDetails userDetails = StubData.MockMember.getUserDetails();
TokenDto tokenDto = jwtTokenProvider.generateTokenDto(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
// when
String json = ObjectMapperUtils.asJsonString(patchDto);
String uri = UriComponentsBuilder.newInstance().path(BASE_URL)
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.
patchRequestWithContentAndToken(mvc, uri, json, accessToken, encryptedRefreshToken);
// then
Response response = ObjectMapperUtils.actionsSingleResponseToMemberDto(actions);
MemberDto.Patch patchDto = StubData.MockMember.getPatchDto();
assertThat(patchDto.getNickname()).isEqualTo(response.getNickname());
assertThat(patchDto.getAddress()).isEqualTo(response.getAddress());
assertThat(patchDto.getIntroduction()).isEqualTo(response.getIntroduction());
assertThat(patchDto.getNation()).isEqualTo(response.getNation());
actions
.andExpect(status().isOk())
.andDo(document("patch-member",
getRequestPreProcessor(),
getResponsePreProcessor(),
MemberRequestSnippet.getPatchSnippet(),
MemberResponseSnippet.getMemberResponseSnippet()));
}
마무리
테스트 코드 작성은 소프트웨어 개발 과정에서 빼놓을 수 없는 중요한 요소다. 그만큼 테스트 코드 작성에 충분한 시간과 노력을 투자하면 안전하고 안정적인 애플리케이션을 개발할 수 있다.
이전 프로젝트 때에는 마감 기한에 맞춰 프로젝트를 끝내기 위해 테스트를 제대로 진행하지 않았었다. 하지만 요구사항이 변경되거나 로직의 수정이 있을 때마다 변경된 코드가 잘 작동하는지 확인하기 위해 PostMan을 하나하나 날려보는 작업이 굉장히 번거로웠고 사이드 이펙트도 확인하기 어려웠다.
이번 프로젝트에서 TDD를 적용해서 테스트 코드에 시간과 노력을 투자해서 안정적인 애플리케이션을 개발해야겠다.
'Spring' 카테고리의 다른 글
Spring - Spring Security + JWT 적용기 2편: JWT 검증 (0) | 2023.04.14 |
---|---|
Spring - Spring Security + JWT 적용기 1편: 로그인 (5) | 2023.04.14 |
Spring - Spring Profile로 다양한 개발 환경 설정 관리하기 (0) | 2023.04.12 |
Spring Security - Spring Security란? (0) | 2023.03.27 |
Spring - Spring initializr로 프로젝트 생성하기 (0) | 2023.03.22 |