이번 게시글에서는 지속 가능한 테스트 코드를 작성하기 위해 노력한 내용에 대해서 정리하고 생각을 정리해보려고 한다.
location_tracking_module: 아쉬웠던 점에서 알 수 있듯이, 지난번 프로젝트에서는 테스트를 통해 모든 동작을 검증하려고 집중하다 보니, 문제점은 테스트가 코드의 행동 변경 뿐만 아니라 코드의 구조 변경에도 취약해지게 되었다. 이 때문에 코드가 리팩터링 되면 테스트도 변경될 확률이 높아졌고, 이는 결국 테스트의 가치를 떨어뜨리게 되었다. 그래서 새로 시작한 bamboo-forest에서는 이러한 문제를 해결하고자 여러가지 방법들을 적용해보았고, 해당 내용에 대한 생각을 정리해보려고 한다.
먼저 Entity에 대해서 생각해볼 점이 있다. 많은 사람들은 프로젝트를 진행하면서 아래와같이, Entity에 대해서 코드를 작성할 것이다.
@Entity
@Getter
@Table(name = "member")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberEntity extends JpaBaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
@Column(nullable = false, unique = true)
private String name;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OAuth2Type oAuth2;
@Column(nullable = false)
private String username;
@Column(nullable = false)
private String profileImage;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private RoleType role;
@Column(nullable = false)
private int batteryCount;
/**
* 트랜잭션 내부에서는 단일 스레드 환경에서 동작함. synchronizedSet 사용 필요 없음
*/
@Convert(converter = ChatBotItemEnumSetConverter.class)
@Column(name = "chat_bots", nullable = false)
private EnumSet<ChatBotItem> chatBots = EnumSet.noneOf(ChatBotItem.class);
@Version
private Long version;
private MemberEntity(final String name, final OAuth2Type oAuth2, final String username,
final String profileImage, final RoleType role) {
this.name = name;
this.oAuth2 = oAuth2;
this.username = username;
this.profileImage = profileImage;
this.role = role;
this.batteryCount = 0;
this.chatBots = EnumSet.noneOf(ChatBotItem.class);
}
public static MemberEntity of(final String name, final OAuth2Type oAuth2, final String username,
final String profileImage, final RoleType role) {
return new MemberEntity(name, oAuth2, username, profileImage, role);
}
}
MemberEntity는 생성자를 통해 필요한 값을 전달받고, 정적 팩토리 메서드 of를 이용해 객체를 생성한다.
이 방법은 기능 구현에는 문제가 없으나, 단위 테스트를 진행할 때 발생할 수 있는 몇 가지 문제가 있다. 특히, @Id 필드가 GenerationType.IDENTITY 전략을 사용하고 있기 때문에, 테스트 중에 ID 값을 자동으로 생성할 수 없다는 점이 문제다.
이 문제를 해결하기 위해 ID 값을 주입할 수 있는 생성자를 추가하는 방법도 고려될 수 있지만, 이는 프로젝트의 다른 부분에서 부작용을 초래할 가능성이 있다. 대신, 테스트 코드에서 리플렉션(Reflection)을 사용하여 ID 값을 주입하는 방법이 제시될 수 있다. 다음과 같이 ReflectionUtils 클래스를 정의할 수 있다.
public class ReflectionUtils {
public static void setField(Object target, String fieldName, Object value) {
try {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException("Failed to set field " + fieldName, e);
}
}
}
객체와 수정하고 싶은 필드의 이름과 값을 넣으면 Reflection을 이용하여 주입해준다.
이렇게 ReflectionUtils을 구현한다면, 아래와 같이 사용할 수 있다.
public class PaymentEntityFixture {
public static PaymentEntity createPaymentEntity(final UUID id, final PaymentStatusType status,
final BatteryItem batteryItem,
final BigDecimal amount, final MemberEntity member) {
final PaymentEntity paymentEntity = PaymentEntity.of(status, batteryItem, member);
paymentEntity.updatePaymentDetails("InvalidKey", "Toss", amount);
ReflectionUtils.setField(paymentEntity, "id", id);
return paymentEntity;
}
}
이를 통해 PaymentEntity 객체를 생성할 때 명시적으로 ID 값을 설정할 수 있다.
예를 들어, 다음과 같은 테스트 코드에서 이를 활용할 수 있다.
@Test
void testGetPayment() {
// given
PaymentEntity paymentEntity = createPaymentEntity(UUID.randomUUID(), COMPLETED, SMALL_BATTERY,
SMALL_BATTERY.getPrice(), memberEntity);
when(paymentRepository.findById(eq(paymentEntity.getId())))
.thenReturn(Optional.of(paymentEntity));
// when
PaymentDto paymentDto = paymentService.getPayment(paymentEntity.getId(), customOAuth2User);
// then
assertSoftly(softly -> {
softly.assertThat(paymentDto.getId()).isEqualTo(paymentEntity.getId());
verify(paymentRepository).findById(eq(paymentEntity.getId()));
});
}
paymentEntity.getId()를 사용하여도 자연스럽게 ID를 사용할 수 있다.
이와 같은 방법은 매 테스트마다 리플렉션을 사용하는 것은 코드의 가독성과 유지 보수 측면에서 부정적인 영향을 미칠 수 있으므로, 이를 피하기 위해 테스트 픽스처(Test Fixture)를 사용하는 것이 좋다.
Test Fixture를 통해 공통의 테스트 데이터를 관리하면, 반복적인 리플렉션 호출을 줄이고 테스트 코드의 일관성과 가독성을 높일 수 있다.
저번 프로젝트에서도 팩토리 클래스를 이용한 Test Fixture를 만들어 각 테스트에서 적용한 경험이 있다. Test Fixture를 통해 필요한 객체 생성을 추상화하면, 코드 리팩터링이 발생해도 Test Fixture의 코드만 수정하면 테스트 코드를 변경하지 않고 유지할 수 있다는 장점이 있다.
하지만 이러한 Test Fixture를 사용할 때 고려해야 할 몇 가지 사항이 있다. 아래는 프로젝트에서 작성한 MemberEntity에 대한 Fixture 클래스의 예시다.
public class MemberEntityFixture {
public static final MemberEntity MEMBER_ENTITY;
static {
MEMBER_ENTITY = MemberEntity.of(
"OAUTH2_KAKAO_12345",
OAUTH2_KAKAO,
"송제용",
"http://t1.kakaocdn.net/account_images/default_profile.jpeg.twg.thumb.R640x640",
ROLE_USER
);
setField(MEMBER_ENTITY, "id", 1L);
}
}
MemberEntityFixture에서 MEMBER_ENTITY를 전역 변수로 관리하여 필요한 값을 미리 주입해 두고, 사용자가 편리하게 사용할 수 있도록 하고 있다.
이렇게 전역 변수를 사용하면 매우 편리하지만, 문제가 발생할 수 있다. 바로 이러한 방식이 변수의 상태를 관리하게 되면서, 각 테스트가 독립적이지 않게 된다는 것이다. 이를 해결하기 위해 전역 변수가 아닌 스태틱 메서드를 이용해 팩토리 패턴을 적용하는 방법이 있다.
아래는 팩토리 패턴을 이용한 Fixture의 예시다.
public class ChatBotPurchaseDtoFixture {
public static ChatBotPurchaseDto getUnclePurchaseDto() {
return new ChatBotPurchaseDto(
1L,
0,
ChatBotItemDtoFixture.getUncleChatBotDto(),
LocalDateTime.now()
);
}
public static ChatBotPurchaseDto getAuntPurchaseDto() {
return new ChatBotPurchaseDto(
2L,
3,
ChatBotItemDtoFixture.getAuntChatBotDto(),
LocalDateTime.now()
);
}
public static ChatBotPurchaseDto getChildPurchaseDto() {
return new ChatBotPurchaseDto(
3L,
5,
ChatBotItemDtoFixture.getChildChatBotDto(),
LocalDateTime.now()
);
}
}
ChatBotPurchaseDtoFixture는 스태틱 메서드를 사용하여 테스트 코드에서 새로운 객체를 생성할 수 있게 해 각 테스트가 독립적으로 관리될 수 있도록 한다. 이 방식은 편리하지만, 이에도 문제가 존재한다.
ChatBotPurchaseDtoFixture에서 값을 미리 주입해두는 방식은 새로운 종류의 ChatBotPurchaseDto가 생길 때마다 ChatBotPurchaseDtoFixture를 수정해야 한다는 단점이 있다. 또한, ChatBotItemDtoFixture.getUncleChatBotDto()와 같이 다른 Fixture에 의존성이 생기면, 해당 의존성이 변경될 때마다 Fixture의 변경 전파 가능성이 발생할 수 있다. 이와 같은 구조에서는 Fixture 관리가 매우 어려워질 수 있으며, 많은 Fixture가 생길 경우 테스트 코드를 작성하는 것보다 Fixture를 관리하는 데 더 많은 시간을 투자하게 될 가능성이 있다.
이 문제를 해결하기 위해, 상태를 가지지 않고 변하지 않는 값만 넣어주며, 변하는 값은 외부에서 주입받는 스태틱 메서드를 작성하는 방식이 좋다.
public class ChatBotPurchaseDtoFixture {
public static ChatBotPurchaseDto createChatBotPurchaseDto(final Long id, final int amount,
final ChatBotItemDto chatBotItemDto) {
return new ChatBotPurchaseDto(id, amount, chatBotItemDto, LocalDateTime.now());
}
}
id, amount, chatBotItemDto를 외부에서 주입받아 의존성을 최소화함으로써 변경 전파 가능성을 제거할 수 있다.
또한, 만약 프로젝트에서 ChatBotPurchaseDto가 변경되더라도 변하지 않는 값이라면 그대로 사용하면 되고, 변하는 값이라도 다른 테스트 코드에 영향을 미치지 않는다면 새로운 스태틱 메서드를 추가하여 처리할 수 있다.
Test Fixture를 이용해 테스트 코드의 가독성을 높였다면, 여기서 조금 더 나아가 @BeforeEach를 활용해 코드의 중복을 줄이고 가독성을 더욱 개선할 수 있다. 아래 코드는 Payment에 대한 테스트 코드의 일부다.
@Test
void testConfirmPayment_InvalidAmount() {
// given
MemberEntity memberEntity = createMemberEntity(1L, null, "username", "profileImageUrl", null);
CustomOAuth2User customOAuth2User = createCustomOAuth2User(memberEntity.getId(), memberEntity.getRole(),
memberEntity.getOAuth2());
PaymentConfirmRequest paymentConfirmRequest = createPaymentConfirmRequest("validPaymentKey",
UUID.randomUUID(), SMALL_BATTERY.getPrice());
PaymentEntity paymentEntity = createPaymentEntity(paymentConfirmRequest.getOrderId(),
PaymentStatusType.PENDING, MEDIUM_BATTERY, null, memberEntity);
when(paymentRepository.findById(eq(paymentConfirmRequest.getOrderId())))
.thenReturn(Optional.of(paymentEntity));
// when & then
assertThatThrownBy(() -> paymentService.confirmPayment(paymentConfirmRequest, customOAuth2User))
.isInstanceOf(PaymentFailureException.class);
}
@Test
void testGetPayment() {
// given
MemberEntity memberEntity = createMemberEntity(1L, null, "username", "profileImageUrl", null);
CustomOAuth2User customOAuth2User = createCustomOAuth2User(memberEntity.getId(), memberEntity.getRole(),
memberEntity.getOAuth2());
PaymentEntity paymentEntity = createPaymentEntity(UUID.randomUUID(), COMPLETED, SMALL_BATTERY,
SMALL_BATTERY.getPrice(), memberEntity);
when(paymentRepository.findById(eq(paymentEntity.getId())))
.thenReturn(Optional.of(paymentEntity));
// when
PaymentDto paymentDto = paymentService.getPayment(paymentEntity.getId(), customOAuth2User);
// then
assertSoftly(softly -> {
softly.assertThat(paymentDto.getId()).isEqualTo(paymentEntity.getId());
verify(paymentRepository).findById(eq(paymentEntity.getId()));
});
}
위 코드는 Payment에 대한 테스트를 다루고 있지만, memberEntity, customOAuth2User를 생성하는 로직이 반복되면서 테스트 코드의 가독성이 떨어지고 있다.
이러한 문제는 @BeforeEach를 이용해 해결할 수 있다.
@BeforeEach
void setUp() {
memberEntity = createMemberEntity(1L, null, "username", "profileImageUrl", null);
customOAuth2User = createCustomOAuth2User(memberEntity.getId(), memberEntity.getRole(),
memberEntity.getOAuth2());
}
memberEntity와 customOAuth2User의 생성 로직을 @BeforeEach를 이용해 관리함으로써, 테스트 코드에서 이들이 주요 관심사가 아닌 경우 코드의 가독성을 높일 수 있다.
하지만 이러한 방식은 주요 관심사가 아닌 경우에만 사용해야 한다. 테스트의 가독성을 위해 @BeforeEach에 모든 로직을 구현하게 되면, 오히려 테스트가 어떤 내용을 다루고 있는지 파악하기 어려워질 수 있다. 이는 테스트의 가치를 떨어뜨릴 수 있으므로, 신중하게 사용해야 한다. 각 테스트가 무엇을 검증하고자 하는지를 명확히 하기 위해, 주요 관심사는 여전히 테스트 메서드 내에서 직접 작성하는 것이 중요하다.
테스트에서 null을 적극적으로 활용하는 것이 좋다. 처음에는 다소 이상하게 들릴 수 있지만, 예제 코드를 통해 그 이유를 살펴보자.
@Test
void testGetMember() throws Exception {
// given
MemberDto memberDto =
createMemberDto(1L, OAuth2Type.OAUTH2_KAKAO, "username", "profileImageUrl", RoleType.ROLE_USER, 0, null);
when(memberService.getMember(any(CustomOAuth2User.class))).thenReturn(memberDto);
// when & then
mockMvc.perform(get("/api/members"))
.andExpect(status().isOk())
.andExpect(content().json(convertToJson(memberDto)));
}
위 코드는 createMemberDto에서 OAuth2Type.OAUTH2_KAKAO와 RoleType.ROLE_USER를 사용하여 테스트를 구성하고 있다.
이러한 구조는 특정 상황에서 문제가 될 수 있다. 예를 들어, 만약 프로젝트에서 OAUTH2_KAKAO를 더 이상 지원하지 않도록 변경된다면, 이와 관련 없는 테스트 코드도 수정해야 하는 문제가 발생할 수 있다.
이러한 문제를 해결하기 위해서는 테스트에서 null 값을 활용할 수 있다.
@Test
void testGetMember() throws Exception {
// given
MemberDto memberDto =
createMemberDto(1L, null, "username", "profileImageUrl", null, 0, null);
when(memberService.getMember(any(CustomOAuth2User.class))).thenReturn(memberDto);
// when & then
mockMvc.perform(get("/api/members"))
.andExpect(status().isOk())
.andExpect(content().json(convertToJson(memberDto)));
}
위 코드에서 createMemberDto는 이제 null 값을 사용하고 있으며, 이를 통해 특정 필드에 의존하지 않는 테스트를 작성할 수 있다. 이 방식은 코드가 변화에 강해지도록 만들어준다.
여기서 중요한 점은 null을 활용하는 것 자체가 아니라, 테스트에서 자신이 테스트하고자 하는 값에만 집중하는 것이다. 즉, 테스트에서 불필요한 값들을 포함시키지 않고, 오직 테스트의 목적에 맞는 값들만을 사용함으로써 코드의 유연성과 유지보수성을 높일 수 있다는 것이다.
여러 가지 방법을 통해 테스트 코드를 최대한 지속 가능하게 만들기 위해 노력해 보았다. 사실, 이러한 방법들을 적용하더라도 프로젝트 코드가 변경될 때 테스트 코드 역시 수정이 필요할 수 있다. 가장 중요한 것은 도메인에 대한 이해라고 생각한다. 도메인에 대한 정확한 이해를 바탕으로 테스트 해야할 요소만 테스트를 하는 것이 가장 중요하다.
아무튼, 결론을 이야기 하자면 테스트 코드는 프로젝트의 신뢰성을 확보하는 중요한 도구다. 하지만 프로젝트 구조의 변경을 막는 걸림돌이 된다면, 그 테스트 코드는 오히려 프로젝트의 발전을 저해하는 요소가 될 수 있다.
따라서, 테스트 코드를 작성할 때는 지속 가능성을 염두에 두어야 하며, 변화에 강한 테스트 코드를 작성하는 것이 중요하다. 코드의 구조가 변경될 때, 최소한의 수정만으로도 테스트가 정상적으로 작동할 수 있도록 만드는 것이 좋다.