본문 바로가기
Backend/Spring

[예약 구매 프로젝트] Test Code

by DooDuZ 2024. 7. 30.

테스트 그거 어떻게 하는 건데

프로젝트를 진행하면서 어려운 부분이 정말 많았지만, 그중에서도 가장 스트레스받았던 부분이 바로 테스트였습니다. 다른 건 공부해서 하면 되겠다 싶었는데 테스트는 어떻게 짜야하나 도저히 감이 잡히질 않았습니다. 하여 정리해 보는 좌충우돌 테스트코드 작성기... 테스트에 대한 정확한 정보를 정리하는 글은 아니고, 제 이해가 어떤 식으로 변화했는지에 대한 글입니다. 아래 카테고리 역시 서로 구분되는 개념으로 나눈 게 아니라 제가 인지한 순서대로 구성했습니다.

 

Unit

먼저 단위 테스트 입니다. 코드를 작은 단위(ex - 기능) 별로 분리해서 테스트하는 것을 말합니다. 쉽게 생각하면 메서드 단위의 테스트부터, 메서드 내부에 적용할 로직에 대한 테스트라고 할 수 있을 것 같습니다. 먼저 Spring과 상관없는 Java 클래스에 대한 기능 테스트 예시입니다. 회원 정보 암호화 인코더를 만들고 암호화 후 매칭, 혹은 복호화 테스트를 진행했습니다.

 

암호화 인코더 클래스

public class UserInformationEncoder implements EnableDecoding{
    public final String secretKey;
    private final String separator = "-";

    public UserInformationEncoder(String secretKey) {
        this.secretKey = secretKey;
    }

    @Override
    public String encrypt(String data, String salt) {
        TextEncryptor textEncryptor = Encryptors.text(secretKey, salt);
        
        return salt + separator + textEncryptor.encrypt(data);
    }

    @Override
    public String decrypt(String encryptedData) {
        String[] data = encryptedData.split(separator);
        String salt = data[0];
        String encrypted = data[1];
        TextEncryptor textEncryptor = Encryptors.text(secretKey, salt);
        
        return textEncryptor.decrypt(encrypted);
    }
}

 

암호화 테스트

public class EncryptionTest {

    SaltGenerator saltGenerator = new SaltGenerator();
    UserInformationEncoder userInformationEncoder = new UserInformationEncoder("braaaaaaainCPR");
    PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    @ParameterizedTest
    @DisplayName("데이터 암호화 후 복호화 값이 동일한지 확인합니다.")
    @ValueSource(strings = {"!@#!$ASd", "zxcvd23!@#", "test12!@", "1234", "name", "addr", "경기도 안산시 단원구 어쩌구 비밀임니다"})
    void encodeTest(String password) {
        String salt = saltGenerator.generateSalt();

        String encodedPassword = userInformationEncoder.encrypt(password, salt);
        String decodedPassword = userInformationEncoder.decrypt(encodedPassword);

        Assertions.assertThat(decodedPassword).isEqualTo(password);
    }

    @Test
    @DisplayName("패스워드 일치 테스트")
    void passwordEncodeTest(){
        String password = "tDest12123!@";
        String DBPassword = passwordEncoder.encode(password);

        Assertions.assertThat(passwordEncoder.matches(password, DBPassword)).isEqualTo(true);
    }
}

Mock

위에 작성된 단위 테스트 예시에선 다행스럽게도 별도의 의존성이 필요하지 않습니다. new로 필요한 모든 객체를 만들고 사용할 수 있었죠. 다만 Spring을 사용한다면 각 레이어 간의 의존 관계가 존재할 수 있고 new로 인스턴스를 생성하기 어려운 상황이 있을 수 있습니다. service layer를 테스트하고 싶은데, repository가 interface여서 new로 인스턴스화가 불가능한 경우를 예시로 들 수 있습니다. 이때 Mock 객체를 사용할 수 있습니다. 처음엔 Mock이란 말이 정말 생소해서 뭔가 엄청난 무언가가 있는 건가했는데, 단순하게 가짜 객체를 만들어서 사용한다고 생각하니 쉽게 느껴졌습니다. 아래는 주문 서비스에 대한 테스트코드입니다.

 

주문 서비스 테스트

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {
    @InjectMocks
    private OrderService orderService;
    @Mock
    private OrderRepository orderRepository;
    @Mock
    private OrderDetailService orderDetailService;
    @Mock
    private PaymentService paymentService;
    @Mock
    private CartService cartService;
    
    .
    .
    .   
}

 

Mock을 사용하는 방법은 다양해 보이는데, 저는 ExtendWith 어노테이션을 사용했습니다. 클래스 상단에 이를 명시하고, 실제 테스트할 클래스는 @InjectMocks, 그 외 의존 관계인 멤버는 @Mock을 적용했습니다.

 

주문 조회 메서드 테스트를 진행해 보겠습니다. 아래는 조회 메서드와, 그 내부에서 호출되는 검증 메서드입니다.

// service
public OrderResponseDto getOrder(UserDetails userDetails, Long orderId) {
    MemberEntity memberEntity = (MemberEntity) userDetails;
    OrderEntity orderEntity = getValidEntity(memberEntity, orderId);

    OrderResponseDto orderResponseDto = orderEntity.toDto();
    orderResponseDto.setOrderDetails(orderDetailService.getOrderedProduct(orderEntity));

    return orderResponseDto;
}

private OrderEntity getValidEntity(MemberEntity memberEntity, Long orderId) {
    OrderEntity orderEntity = orderRepository.findById(orderId).orElseThrow(
            () -> new OrderException(OrderResponseMessage.INVALID_ORDER)
    );

    if (memberEntity.getMemberId() - orderEntity.getMemberEntity().getMemberId() != 0) {
        throw new AuthorizationException(AuthMessage.AUTHORIZATION_DENIED);
    }

    return orderEntity;
}

 

 

InjectMocks로 주입된 OrderService의 메서드만 사용된다면 좋으련만, 위의 로직에선 Mock 객체의 메서드들이 함께 호출되고 있습니다. 해당 객체들은 말 그대로 '가짜'객체이기 때문에 호출된 메서드가 실제로 동작하지 않습니다. 이를 해결하기 위해 우리는 Mock 객체의 메서드가 호출되는 경우 어떤 결과가 반환되는지 지정해줘야 합니다.

 

위 코드 기준으로 외부 메서드는 orderDetailService를 통해 호출되는 getOrderedProduct, orderRepository를 통해 호출되는 findById가 있습니다.

@Test
@DisplayName("주문 상세 조회 성공")
void getOrderSuccessTest(){
    // given
    Long orderId = 1L;

    when(orderRepository.findById(orderId)).thenReturn(Optional.of(orderedEntity));
    when(orderDetailService.getOrderedProduct(any(OrderEntity.class))).thenReturn(new ArrayList<>());

    // when
    OrderResponseDto orderResponseDto = orderService.getOrder(memberEntity, orderId);

    // then
    assertThat(orderResponseDto.getOrderId()).isEqualTo(orderedEntity.getOrderId());
}

 

when을 사용해 각 Mock 객체의 행동과 그에 따른 리턴값을 지정합니다. 이 테스트는 orderService의 getOrder 메서드 로직이 정상작동 하는지 확인하는 게 목적이기 때문에, 다른 객체의 메서드 작동까지 검증할 필요 없이 예상 데이터를 반환받아 처리했습니다.

 

하나의 예시만 더 들어보겠습니다.

@Test
@DisplayName("주문 번호가 잘못된 경우 Exception이 발생합니다.")
void getOrderFailTest_InvalidOrderId(){
    // given
    Long orderId = 2L;

    when(orderRepository.findById(orderId)).thenReturn(Optional.ofNullable(null));

    // when
    assertThatThrownBy(
            () -> orderService.getOrder(memberEntity, orderId)
    ).isInstanceOf(OrderException.class).hasMessageContaining(OrderResponseMessage.INVALID_ORDER.getMessage());
}

 

이번엔 존재하지 않는 주문 번호를 넣었을 때 예외가 발생하는지 검증하는 테스트입니다. 이때는 repository의 findById가 빈 Optional을 반환하고 거기서 바로 Exception이 발생하기 때문에 when 절이 하나만 작성 되었습니다. 만약 성공 테스트처럼 뒤에 호출될 orderDetailService의 행동까지 명시하게 된다면 Unnecessary Stubbings Detected가 발생하며 테스트는 실패하게 됩니다.

 

불필요한 코드를 지우라고 하네요. 즉 일어날 행위에 대한 것만 when절로 지정하라는 뜻입니다.

 

SpringBootTest

그러나 Mock Test 역시 특정 메서드의 행동을 내가 지정해줘야 한다는 것 때문에 이게 제대로된 테스트가 맞나 하는 생각이 들 때가 있습니다. 로직이 작성됐으니 테스트는 해야 하는데, 중간 결과를 내가 지정하기 때문에 when에 입력한 결과가 그대로 검증에 사용되거나 하는 경우도 있기 때문이죠. 저의 경우 파라미터로 1을 넣고 whe으로 1을 반환시켜서 1과 1이 동일한지 검증하는 느낌의... 불필요한 코드가 나오곤 했습니다.

 

이때 필요한 게 통합 테스트라고 이해했습니다. 통합 테스트는 유닛 테스트와 다르게 통합 테스트의 경우 애플리케이션 전체를 로드하고 모든 Bean을 등록해서 사용하게 됩니다. 이 때문에 유닛테스트에 비해 속도가 많이 느리다는 단점이 있지만, 대신 테스트에 필요한 모든 인스턴스가 Bean등록이 되어있는 상태기 때문에 의존성 주입을 통해 더 정확한 테스트를 할 수 있다는 장점이 있습니다.

 

실제 서비스 환경과 유사한 상황에서 테스트를 할 수록 테스트에 대한 신뢰도가 올라간다고 생각하면 유닛 테스트와 다른 의미로 꼭 필요한 방식이라고 볼 수 있습니다. 

 

여기서 하나의 가정을 해보겠습니다. 위에서 통합 테스트는 애플리케이션 전체를 로드하고 모든 Bean을 등록한다고 했습니다. 이때 통합 테스트에서 Service를 주입받아 Create에 해당하는 메서드를 실행한다면 실제 DB에 테스트 데이터가 입력되지 않을까요? Repository 또한 정상 등록된 Bean인데 단지 Test환경이라고 해서 영속화 작업이 제한될 이유가 없어 보입니다.

 

서로 다른 테스트끼리도 독립성을 가져야 할 판에 테스트 결과가 프로덕션 환경까지 전파된다는 건 말이 안 되죠. 이를 해결하기 위한 조치가 필요해 보입니다.

Test Containers

이를 위해 테스트 컨테이너를 사용할 수 있습니다. 테스트 컨테이너는 테스트에 필요한 특정 환경을 도커 컨테이너로 구성하여 독립적으로 사용할 수 있도록 해줍니다. 예를 들어 서비스에 사용되는 것과는 다른 테스트용 DB나 Redis 등이 필요할 때 사용할 수 있습니다.

 

testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'

 

사용을 위해 의존성을 추가해 줍니다.

 

@SpringBootTest
@Testcontainers
public class CustomerProductServiceTest {

    @Container
    public static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:latest"))
            .withExposedPorts(6379);

    @DynamicPropertySource
    static void redisProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.redis.host", redis::getHost);
        registry.add("spring.redis.port", redis::getFirstMappedPort);
    }

    @Autowired
    private CustomerProductService customerProductService;
    
    @Autowired
    private ProductRedisRepository productRedisRepository;

    @MockBean
    private ProductRepository productRepository;
    
    @MockBean
    private StockService stockService;
    
    @BeforeEach
    void init() {
        CategoryEntity categoryEntity = CategoryEntity.builder().categoryId(1L).categoryName("콤퓨타").build();

        cachedProductEntity = ProductEntity.builder()
                .productId(1L)
                .productStatus(ProductStatus.ON_SALE)
                .productName("캐시 상품")
                .productDetail("빠르다")
                .categoryEntity(categoryEntity)
                .imageVersion(1L)
                .sellerEntity(MemberEntity.builder().memberId(1L).build())
                .build();

        notCachedProductEntity = ProductEntity.builder()
                .productId(2L)
                .productStatus(ProductStatus.ON_SALE)
                .productName("DB 상품")
                .productDetail("느리다")
                .categoryEntity(categoryEntity)
                .imageVersion(1L)
                .sellerEntity(MemberEntity.builder().memberId(1L).build())
                .build();
    }
    
    @AfterEach
    void clear() {
        productRedisRepository.flushAll();
    }
    
    .
    .
    .
}

 

1. @Container를 통해 어떤 이미지를 사용하여 컨테이너를 만들지, 컨테이너 내부 포트는 어떤 걸 사용할지 설정합니다. 2. @DynamicPropertySource는 redis사용을 위한 설정을 추가하고 사용 가능한 외부 포트(Host OS의 포트)를 찾아 매핑하는 역할을 합니다.

3. 테스트와 검증이 필요한 Bean은 @Autowired를 통해 주입받고, 그 외의 필드 멤버는 @MockBean을 적용합니다.

 

상품 조회 메서드를 테스트해 보겠습니다.

public ProductDto getProduct(Long productId) {
    String key = String.valueOf(productId);

    if (isCached(key)) {
        return (ProductDto) productRedisRepository.find(key);
    }

    ProductEntity productEntity = getProductEntity(productId);

    // 공개되지 않았거나 숨김 처리된 상품이면 throw
    ProductStatus productStatus = productEntity.getProductStatus();
    if (productStatus == ProductStatus.NOT_PUBLISHED || productStatus == ProductStatus.SUSPENDED_SALE) {
        throw new ProductException(ProductMessage.NOT_FOUND_PRODUCT);
    }

    ProductDto productDto = getProductDto(productEntity);
    productDto.setAmount(0L);
    productRedisRepository.cache(key, productDto);

    return productDto;
}

 

서비스에 작성된 로직입니다. 캐시 데이터가 있다면 redisRepository에서 바로 값을 리턴하고, 그렇지 않다면 DB에서 값을 꺼낸 뒤 캐싱하고 값을 리턴합니다.

 

@Test
@DisplayName("캐시된 상품을 조회합니다.")
void getCachedProductSuccessTest(){
    // given
    productRedisRepository.save("1", cachedProductEntity.toDto());

    // when
    ProductDto productDto = customerProductService.getProduct(1L);

    // Then
    assertThat(productDto).isNotNull();
    assertThat(productDto.getProductId()).isEqualTo(cachedProductEntity.getProductId());
    assertThat(productDto.getProductStatus()).isEqualTo(cachedProductEntity.getProductStatus());

    // DB 조회 메서드가 호출되지 않음
    verify(productRepository, times(0)).findById(anyLong());
}

 

먼저 redisRepository에 데이터를 save 하고 조회 메서드를 실행합니다. 불러온 결과 값이 캐싱한 데이터와 일치하고, 동시에 db를 통한 조회가 발생하지 않았음을 확인합니다. 

테스트 성공

다음은 캐시되지 않은 상품을 조회해보겠습니다.

@Test
@DisplayName("캐사되지 않은 상품을 조회합니다.")
void getUncachedProductSuccessTest() {
    // given
    Long productId = 2L;

    // 따로 캐시하지 않은 상태
    when(productRepository.findById(productId)).thenReturn(java.util.Optional.of(notCachedProductEntity));
    when(stockService.getStockEntity(notCachedProductEntity)).thenReturn(StockEntity.builder().productEntity(notCachedProductEntity).amount(0L).build());

    // when
    ProductDto result = customerProductService.getProduct(productId);

    // then
    assertThat(result).isNotNull();
    assertThat(result.getProductId()).isEqualTo(productId);
    assertThat(productRedisRepository.hasKey(String.valueOf(productId))).isTrue();

    verify(productRepository, times(1)).findById(productId);
}

 

 

이번엔 값을 따로 캐시하지 않고 바로 getProduct()를 실행합니다. 이번엔 캐시 데이터가 없기 때문에, DB에서 Entity를 찾아오는 메서드 호출이 한 번 발생해야 합니다. verify의 times를 1로 설정해 줍니다.

 

 

 

역시 통과되는 모습을 확인할 수 있습니다.

 

TestContainer를 통해 의존 객체의 동작을 when으로 제어하지 않고 직접 상호작용 함으로써 보다 신뢰성 높은 테스트를 작성할 수 있습니다.