2024. 5. 8. 16:33ㆍProject/데브툰
데브툰 프로젝트를 진행하면서 작성한 테스트 코드를 소개하려고 합니다. 이론적인 설명보다는 효율적으로 테스트 코드를 작성하는 방법에 대해 고민한 부분을 다룹니다. 또한, 발생한 이슈와 해결 방법도 함께 소개하고, 마지막은 회고로 마무리하는 흐름입니다. 앞으로 글의 가독성을 위해 말을 편하게 할 예정입니다. 양해 부탁드립니다. 🍎🍎🍎
목차
테스트 코드를 왜 작성할까?
통합 테스트 작성
통합 테스트 이점
유용한 메서드 소개 및 코드 예시
@Transactional 롤백이 안 되는 이슈 발생
문제 발생
문제 발생 지점
문제 원인
원인 분석
문제 해결
회고
만족한 점
아쉬운 점
다음에는 이렇게
테스트 코드를 왜 작성할까?
테스트 코드를 작성하는 이유와 방법을 찾아보면 많은 자료들이 나온다. 대부분 공감이 가고 앞으로도 기능 개발과 함께 테스트 코드 작성에 힘을 쏟아야겠다는 다짐을 하게 만든다.
여기서는 내가 생각하는 테스트 코드를 작성하는 이유를 말하고자 한다.
1. 기능 개발을 하면서 놓친 부분에 대한 확인이 가능하다.
postman을 통해서 통과가 되고 데이터베이스에도 데이터가 잘 들어갔지만, 통합 테스트를 통해서 예외처리가 안되어 있거나 생성자 추가, null체크 0체크 부분을 실제로 잡을 수 있었다.
2. 리팩토링 시 테스트 코드가 수정해야 할 지점을 잡아준다.
개발의 꽃은 리팩토링이라고 생각한다. 하지만 특정 api를 수정하는 건 괜찮은데 이와 연관된 다른 api들은 과연 잘 돌아갈까 걱정이 된다. 아주 많이... 이때 내가 이전에 작성한 테스트 코드의 위력을 발견할 수 있다. 그럼 이제 너만 믿는다....😎😎
3. 테스트 코드도 코드이다.
하나의 api 문서로 활용할 수 있다고 생각한다. 이런 이유로 fail 상황까지 최대한 테스트하려고 한다.
물론 단점도 존재한다.
1. 개발 시간 증대가 가장 크다.
내 기준으로 3배는 걸리는 것 같다. 이로 인한 순기능이 존재하는데, 테스트 코드에 많은 시간이 걸릴 걸 알기에 오히려 기능 개발의 속도가 올라갔다. 😅
2. 그럼에도 불완전한 테스트 코드
발생가능한 모든 경우의 수를 테스트할 수 없어 불안한 건 마찬가지이다. 하지만 아무것도 안 하는 것보단 어느 정도 채우는 게 훨씬 불안감을 감소시킨다.
이렇게 정리해 보니 단점이 있지만 막상 단점이 아닌 것 같다. 이는 아직 익숙하지 않아서 발생하는 거기도 하고 현업에서 많은 테스트 코드를 보다 보면 해결될 문제라 생각한다. 장점이 압도적이라 앞으로도 테스트 코드를 빼놓지 않고 작성할 예정이다.
다음은 [데브툰 프로젝트]에서 작성한 테스트 코드를 소개할 텐데, 어떻게 하면 좀 더 가독성 좋게 유지보수성이 좋게 코드를 작성할 수 있을지 고민하였다.
통합 테스트 작성
이전 개인 프로젝트에서는 서비스 단 단위 테스트를 작성했다면, 이번에는 통합테스트를 주로 작성했다. 이유는 크게 세 가지이다.
1. 통합테스트가 무엇인지 직접 작성하면서 익히고 싶다.
2. 시스템이 전체적으로 올바르게 동작하는지를 한 번에 확인하고 싶다.
통합 테스트는 여러 모듈의 상호작용을 검증하여 시스템의 전체 동작을 정확히 파악할 수 있다. 이는 단위 테스트로 발견하기 어려운 복잡한 버그를 찾을 수 있도록 도와준다.
3. 새로운 코드 추가나 수정 시 기존 기능에 미치는 영향을 확인할 수 있어 시스템 안정성을 유지한다는 장점이 있다.
[깨알 비교] Spring Boot 테스트 애노테이션 :
@AutoConfigureMockMvc vs. @WebMvcTest
둘 다 Spring MVC 애플리케이션을 테스트할 때 사용되는 Spring Boot 테스트 애노테이션이다. 그러나 이 둘은 서로 다른 목적을 가지고 있다.
@WebMvcTest
• Spring MVC 컨트롤러 레이어를 테스트하기 위함
• 해당 컨트롤러에 의존하는 컴포넌트들만 로드되어 테스트가 빠르게 실행된다. 그러나 이 애노테이션은 MockMvc를 자동으로 구성하지 않는다.
@AutoConfigureMockMvc
• MockMvc 인스턴스를 자동으로 구성하여 Spring MVC 애플리케이션의 통합 테스트를 가능하게 한다.
• MockMvc를 명시적으로 설정할 필요 없이 자동으로 설정된다. 그러나 애플리케이션의 다른 컴포넌트들도 모두 로드하므로 테스트 실행 속도가 느릴 수 있다.
따라서 일반적으로 Spring MVC 컨트롤러 레이어의 단위 테스트를 작성할 때는 @WebMvcTest를 사용하고, 통합 테스트를 작성할 때는 @AutoConfigureMockMvc를 사용한다.
더 나아가, 어떻게 하면 코드 중복을 줄일 수 있을까 생각해 보자
단순히 이론을 학습하고 적용하고 끝이 아닌, 추가적으로 생각한 것을 정리해 보았다.
문제
통합테스트 특성상 요청을 보내기 전에 사전 작업이 필요한데, 이로 인해 코드가 길어질 수밖에 없다.
분명 같은 기능을 하는 건데, 경우의 수에 따라 검증을 해야 하는 경우 중복 테스트 코드가 여럿 생성된다.
이를 도와줄 어노테이션 두 가지 (by. JUnit 프레임워크 제공 )
@ParameterizedTest
@TestFactory
@ParameterizedTest
테스트에 필요한 매개변수를 전달해 주는 어노테이션으로 한 개의 메서드만으로 다수의 테스트 케이스에 적용될 수 있어 중복을 제거할 수 있다.
• @ValueSource(ints = {1, 2, 3, 4}): 배열의 요소가 하나씩 인자 값으로 전달된다
• @CsvSource(value = {"1,2", "2,4"}): 두 개의 인수를 전달하며, 첫 번째는 메서드 인자 값, 두 번째는 기댓값이다.
🔽 다음은 프로젝트에서 해당 어노테이션을 사용한 코드이다.
첫 번째,
메서드 소개 : 웹툰 등록 유효성 검사
웹툰을 등록하려면 title, writerName, genre를 필수적으로 적어야 한다.
원래는?
테스트 로직이 비슷함에도 불구하고, 경우의 수를 나누어 중복 메서드를 생성해야 한다.
해당 어노테이션 적용 후!
파라미터로 다양한 경우의 수를 , 로 구분해 테스트 코드를 하나로 줄였다. 이를 통해 가독성을 높이고, 추후 유지보수를 할 때 해당 메서드만 수정하면 된다.
@DisplayName("웹툰 등록 실패 - 필드가 null인 경우")
@ParameterizedTest
@CsvSource(value = {", 카레곰, horror", "쿠베라, , horror"}, delimiter = ',')
void givenNullField_whenRegisterWebtoon_thenThrowException(String title,
String writerName,
String genre) throws Exception {
// given
final WebtoonCreateRequest request = new WebtoonCreateRequest(title, writerName, genre);
final String requestBody = objectMapper.writeValueAsString(request);
// when
mockMvc.perform(post("/v1/webtoons")
.content(requestBody)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusMessage").value("실패"))
.andExpect(jsonPath("$.data.status").value(HttpStatus.BAD_REQUEST.value()));
}
// -------------------------------------------------------------------------------------
@DisplayName("웹툰 등록 실패 - 필드가 공백인 경우")
@ParameterizedTest
@CsvSource(value = {"' ', 카레곰, horror", "쿠베라, ' ', horror"}, delimiter = ',')
void givenEmptyField_whenRegisterWebtoon_thenThrowException(String title,
String writerName,
String genre) throws Exception {
// given
final WebtoonCreateRequest request = new WebtoonCreateRequest(title, writerName, genre);
final String requestBody = objectMapper.writeValueAsString(request);
// when
mockMvc.perform(post("/v1/webtoons")
.content(requestBody)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusMessage").value("실패"))
.andExpect(jsonPath("$.data.status").value(HttpStatus.BAD_REQUEST.value()));
}
// -------------------------------------------------------------------------------------
@DisplayName("웹툰 등록 실패 - 필드 사이즈 범위가 [1~20]이 아닌경우")
@ParameterizedTest
@CsvSource(value = {"123456789012345678901, 카레곰, horror", "쿠베라, 123456789012345678901, " +
"horror"}, delimiter = ',')
void givenNotRangeFiled_whenRegisterWebtoon_thenThrowException(String title,
String writerName,
String genre) throws Exception {
// given
final WebtoonCreateRequest request = new WebtoonCreateRequest(title, writerName, genre);
final String requestBody = objectMapper.writeValueAsString(request);
// when
mockMvc.perform(post("/v1/webtoons")
.content(requestBody)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusMessage").value("실패"))
.andExpect(jsonPath("$.data.status").value(HttpStatus.BAD_REQUEST.value()));
}
두 번째,
메서드 소개: 프로모션 등록 필드 유효성 검사
프로모션 등록 시, 다양한 필드 유효성 검사를 통해 등록 실패를 테스트하려고 한다.
원래는?
비슷한 테스트 로직을 여러 경우의 수에 대해 각각 작성하여 중복된 메서드를 생성했다.
해당 어노테이션 적용 후!
@CsvSource를 통해 모든 경우의 수를 파라미터로 넘기기엔 이 또한 사이즈가 커져 가독성을 헤칠 것 같아 하단에 메서드를 만들었다. 결과적으로 같은 기능을 하는 한 메서드 안에서 여러 경우의 수를 테스트할 수 있었고 가독성을 높이고 유지보수를 용이하게 했다.
@DisplayName("프로모션 등록 실패 - 필드 유효성 검사")
@ParameterizedTest
@MethodSource("givenInvalidField_whenRegisterPromotion_thenThrowException")
void givenInvalidField_whenRegisterPromotion_thenThrowException(
String description,
DiscountType discountType,
Boolean isDiscountDuplicatable,
LocalDateTime startDate,
List<PromotionAttributeCreateRequest> promotionAttributes
) throws Exception {
// given
PromotionCreateRequest request = new PromotionCreateRequest(
description,
discountType,
DISCOUNT_RATE,
DISCOUNT_QUANTITY,
isDiscountDuplicatable,
startDate,
ENDDATE,
promotionAttributes
);
String requestBody = objectMapper.writeValueAsString(request);
// when, then
mockMvc.perform(post("/v1/promotions")
.content(requestBody)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusMessage").value("실패"))
.andExpect(jsonPath("$.data.status").value(HttpStatus.BAD_REQUEST.value()));
}
private static Stream<Arguments> givenInvalidField_whenRegisterPromotion_thenThrowException() {
return Stream.of(
// description 유효성 검사
Arguments.of(null, DISCOUNT_TYPE, IS_DISCOUNT_DUPLICATABLE, STARTDATE,
PROMOTION_ATTRIBUTES),
Arguments.of("", DISCOUNT_TYPE, IS_DISCOUNT_DUPLICATABLE, STARTDATE,
PROMOTION_ATTRIBUTES),
Arguments.of(" ", DISCOUNT_TYPE, IS_DISCOUNT_DUPLICATABLE, STARTDATE,
PROMOTION_ATTRIBUTES),
// startDate 유효성 검사
Arguments.of(DESCRIPTION, DISCOUNT_TYPE, IS_DISCOUNT_DUPLICATABLE, null,
PROMOTION_ATTRIBUTES),
Arguments.of(DESCRIPTION, DISCOUNT_TYPE, IS_DISCOUNT_DUPLICATABLE,
LocalDateTime.now().minusDays(1), PROMOTION_ATTRIBUTES),
// promotionAttributes 유효성 검사
Arguments.of(DESCRIPTION, DISCOUNT_TYPE, IS_DISCOUNT_DUPLICATABLE, STARTDATE,
List.of(
new PromotionAttributeCreateRequest(null, "value"))),
Arguments.of(DESCRIPTION, DISCOUNT_TYPE, IS_DISCOUNT_DUPLICATABLE, STARTDATE,
List.of(
new PromotionAttributeCreateRequest("", "value"))),
Arguments.of(DESCRIPTION, DISCOUNT_TYPE, IS_DISCOUNT_DUPLICATABLE, STARTDATE,
List.of(
new PromotionAttributeCreateRequest(" ", "value"))),
Arguments.of(DESCRIPTION, DISCOUNT_TYPE, IS_DISCOUNT_DUPLICATABLE, STARTDATE,
List.of(
new PromotionAttributeCreateRequest("name", null))),
Arguments.of(DESCRIPTION, DISCOUNT_TYPE, IS_DISCOUNT_DUPLICATABLE, STARTDATE,
List.of(
new PromotionAttributeCreateRequest("name", ""))),
Arguments.of(DESCRIPTION, DISCOUNT_TYPE, IS_DISCOUNT_DUPLICATABLE, STARTDATE,
List.of(
new PromotionAttributeCreateRequest("name", " ")))
);
어디서 실패했는지 확인할 수 있어 디버깅에도 유리하다.
@TestFactory
동적으로 테스트 메서드를 생성하는 팩토리 메서드를 정의하는 어노테이션이다. 원래 테스트 메서드는 컴파일 시 정적으로 확정되지만, 이 경우 런타임 시점에 확정된다.
장점
1. 인수 테스트(E2E) 진행 시, 필요한 데이터를 먼저 저장하고 조회하는 순서로 작성하면 가독성이 떨어질 수 있다. 그러나 dynamicTest 메서드를 사용하면 테스트 절차를 나누고, 각 테스트의 이름을 설정할 수 있어 가독성이 높아진다.
2. 테스트 케이스 이름과 테스트 메서드를 명확하게 표시하여 콘솔에 에러 지점을 명확하게 나타낸다. 이를 통해 디버깅이 용이하다.
언제 사용할까?
1. 동적으로 다양한 테스트 케이스를 검증하거나 사용자 시나리오 테스트(인수 테스트)를 하는 경우 유용하다.
2. BDD방식을 사용하는 경우, 주로 @TestFactory를 사용하여 사용자 행동기반으로 테스트를 작성한다.
[깨알 비교] BDD vs. TDD
BDD의 테스트 케이스로 시나리오(통합 테스트)를 검증하고, 시나리오에서 사용되는 각 모듈들은 TDD의 테스트 케이스로 검증을 한다.
예)
BDD방식의 로그인 시나리오 테스트(통합 테스트)
given : id, password가 주어졌을 때
when: 로그인 시도
then: access토큰 반환
TDD방식의 로그인 기능 테스트(단위 테스트)
회원 여부를 확인하는 테스트 케이스를 통과하는 단위 테스트를 작성함으로써 로그인 기능의 모듈 구현
주의사항
1. @TestFactory 어노테이션이 붙은 메서드는 private 및 static으로 선언할 수 없다.
2. 리턴 타입은 Stream, Collection, Iterable, Iterator 중 하나여야 한다. 리턴 타입이 Collections 타입을 반환하지 않는 경우, JUnitException이 발생하여 테스트가 실패로 종료된다.
🔽 다음은 프로젝트에서 해당 어노테이션을 사용한 코드이다.
첫 번째,
메서드 소개 : 웹툰 등록 실패- 중복된 웹툰 이름 존재
웹툰 등록 시, 중복된 웹툰 이름이 존재할 경우 등록 실패를 테스트하는 메서드이다.
@DisplayName("웹툰 등록 실패 - 중복된 웹툰 이름 존재")
@TestFactory
Stream<DynamicTest> givenDuplicatedTitleField_whenRegisterWebtoon_thenThrowException() {
return Stream.of(
DynamicTest.dynamicTest("1. 웹툰 저장", () -> {
// given
WebtoonEntity webtoonEntity = WebtoonEntity.builder()
.title("쿠베라")
.writerName("카레곰")
.genre(Genre.HORROR)
.build();
// when
webtoonRepository.save(webtoonEntity);
// then
Optional<WebtoonEntity> saved = webtoonRepository.findByTitle("쿠베라");
assertAll(
() -> assertThat(saved.isPresent()).isTrue(),
() -> assertThat(saved.get().getTitle()).isEqualTo("쿠베라")
);
}), DynamicTest.dynamicTest("2. 중복된 웹툰 저장 시도", () -> {
// given
final WebtoonCreateRequest request = new WebtoonCreateRequest(
"쿠베라",
"짜장곰",
"horror"
);
final String requestBody = objectMapper.writeValueAsString(request);
// when
mockMvc.perform(post("/v1/webtoons")
.content(requestBody)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusMessage").value("실패"))
.andExpect(jsonPath("$.data.status").value(HttpStatus.CONFLICT.value()));
})
);
}
두 번째,
메서드 소개 : 현재 적용 가능한 모든 프로모션 조회
프로모션 조회 시, 현재 적용 가능한 모든 프로모션을 테스트하는 메서드이다.
@DisplayName("현재 적용 가능한 모든 프로모션 조회")
@TestFactory
Stream<DynamicTest> whenCurrentTimeGiven_thenReturnActivePromotionsList() {
return Stream.of(
DynamicTest.dynamicTest("1. 프로모션1 저장 ->", () -> {
// given
PromotionEntity promotion1 = PromotionEntity.create(
"12월 로맨스 장르 파격 할인 행사입니다.",
CASH_DISCOUNT,
BigDecimal.valueOf(20),
null,
true,
LocalDateTime.now().minusMonths(1),
LocalDateTime.now().plusMonths(1)
);
PromotionEntity savedPromotion1 = promotionRepository.save(promotion1);
PROMOTION_ATTRIBUTES.stream()
.map(promotionAttributeRequest -> PromotionAttributeEntity.create(
savedPromotion1,
promotionAttributeRequest.getAttributeName(),
promotionAttributeRequest.getAttributeValue()
))
.forEach(promotionAttributeRepository::save);
}), DynamicTest.dynamicTest("2. 프로모션2 저장 ->", () -> {
// given
PromotionEntity promotion2 = PromotionEntity.create(
"7월 로맨스 장르 파격 할인 행사입니다.",
COOKIE_QUANTITY_DISCOUNT,
null,
2,
true,
LocalDateTime.now().minusMonths(1),
LocalDateTime.now().plusMonths(1)
);
PromotionEntity savedPromotion2 = promotionRepository.save(promotion2);
PROMOTION_ATTRIBUTES.stream()
.map(promotionAttributeRequest -> PromotionAttributeEntity.create(
savedPromotion2,
promotionAttributeRequest.getAttributeName(),
promotionAttributeRequest.getAttributeValue()
))
.forEach(promotionAttributeRepository::save);
}), DynamicTest.dynamicTest("3. 현재 적용 가능한 모든 프로모션 조회", () -> {
// when, then
mockMvc.perform(get("/v1/promotions/now")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusMessage").value("성공"))
.andExpect(jsonPath("$.data[0].description").value("12월 로맨스 장르 파격 할인 행사입니다."))
.andExpect(jsonPath("$.data[1].description").value("7월 로맨스 장르 파격 할인 행사입니다."));
})
);
원래는?
각각의 테스트 케이스를 개별적인 메서드로 작성해야 한다. 이는 코드의 중복을 초래하고 유지보수를 어렵게 만든다
해당 어노테이션 적용 후!
동적으로 여러 테스트를 작성하면서 코드는 가독성이 높아지고, 코드 중복을 줄이며, 유지보수를 더 쉽게 할 수 있게 된다.
통합 테스트를 작성하면서 발생한 이슈도 하나 정리하고자 한다. 관련해서 이슈를 발행했는데 해당 내용을 발췌했다.
@Transactional 적용이 안 돼요! 롤백이 안된다?!
문제 발생
프로모션 조회에 캐시 적용 로직을 변경한 후에 테스트 코드를 전체 돌려보았을 때, 한 테스트 케이스가 실패했다.
@ Transactional 롤백이 제대로 작동하지 않는 문제였다.
문제 발생 지점
[쿠키 결제 통합 테스트]에서 등록한 프로모션이 [웹툰 결제 통합 테스트] 프로모션 등록 로직에 재사용되었다.
문제 원인
Spring 테스트 프레임워크는 테스트의 성능을 향상하기 위해 애플리케이션 콘텍스트를 캐시 한다. 동일한 애플리케이션 콘텍스트 구성을 사용하는 테스트는 재사용된다.
원인 분석
두 테스트 클래스가 동일한 콘텍스트를 공유하고, 데이터베이스를 사용하여 데이터를 변경한다면, 첫 번째 테스트 클래스에서의 데이터 변경이 두 번째 테스트 클래스에 영향을 미칠 수 있다.
문제 해결
@DirtiesContext 애너테이션을 사용하여 각 테스트가 끝난 후 애플리케이션 콘텍스트를 다시 로드하도록 강제하여 해결했다. 그러나 이 방법은 성능 이슈를 야기할 수 있어 성능 이슈를 최소화하기 위해 @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)를 설정하여 각 테스트 클래스가 끝난 후에만 콘텍스트를 다시 로드하도록 했다.
회고
만족한 점
단순히 통합 테스트 작성에 그치지 않고, 중복 코드 삭제와 효율적인 코드 작성을 고민한 점이 만족스럽다. 그리고 관련 문제 발생 시 이슈 관리를 통해 추후에도 쉽게 찾아볼 수 있게 하였는데, 글로만 접하는 팀원이 바로 이해할 수 있도록 꼼꼼하게 적으려 노력했다. 이는 추후 관련 이슈를 찾아보는 나에게도 도움이 될 것이다.
아쉬운 점
더 많은 테스트 케이스를 작성하지 못한 점이 아쉬움으로 남았다. 확실히 테스트 케이스가 다양하고 꼼꼼하면 추후 리팩토링 시 불안감이 많이 낮아짐을 경험했다.
다음에는 이렇게
다양한 테스트 케이스를 생각하고 작성하는 연습을 통해 테스트 작성에 익숙해지도록, 별도로 시간을 마련해 볼 생각이다.
'Project > 데브툰' 카테고리의 다른 글
[데브툰] 리팩토링: 프로모션 조회 설계 및 성능 개선 도전하기 - 설계 편 (0) | 2024.05.14 |
---|---|
[데브툰] 🎁 리팩토링 모음.zip (0) | 2024.05.11 |
[데브툰] 다양한 정책을 쉽게 등록하고 삭제하기 (0) | 2024.05.07 |
[데브툰] Git 활용하여 자신있게 프로젝트 협업하기 (0) | 2024.05.01 |
[데브툰] 프로젝트 기획부터 설계까지 (0) | 2024.04.30 |