레이어별 테스트 코드 작성

NCOOKIE·2025년 4월 27일
0

TIL

목록 보기
20/20

개요

프로젝트마다 테스트 코드 적용해야지 해야지 하다가 드디어 본격적으로 해보게 되었다.

테스트 코드 작성

공통

  • 항상 @DisplayName 어노테이션이 @Nested나 @Test보다 위로 오도록 설정함
    • @DisplayName은 설명을 나타낸다.
    • 설명이 위에 와야 코드를 읽을 때 "이 테스트는 무엇을 검증하는지"가 먼저 보이기 떄문에 이를 위에 두었다.
  • @BeforeEach 사용 시 주의할 점
    • 어떤 상황(테스트 픽스처)일 경우에 이 기대값이 나오는지를 한 눈에 알 수가 없다.
    • @BeforeEach 내부의 코드가 조금이라도 변경되면 모든 테스트 메서드에 영향 → 테스트 간 결합도 상승
    • 때문에 팩토리 메서드를 만들어서 코드 중복을 줄이고, @BeforeEach에서는 필요한 설정만 수행한다.
  • 이와 같이 클래스 내부 또는 외부에 팩토리 메서드를 만들어서 사용하면 다음과 같은 이점을 가진다.
    • 전체 테스트 코드의 양이 줄어들고, 재사용성이 좋다
    • 각각의 테스트 메소드의 가독성이 좋아지고, 맥락 파악이 쉬워진다.
    • 각각의 테스트 픽스처가 모두 1회성으로 끝나는 지역변수를 사용하기 때문에 테스트간 결합도가 낮아진다.

DTO

  • validation이 복잡하거나 내부 변환, 파싱 로직을 가진 DTO 클래스들을 위주로 테스트 코드 작성
  • Validator를 초기화하는 추상 클래스 생성
    • 테스트 메서드 실행 직전마다 validator를 초기화하도록 함
    • 테스트가 종료되면 factory 자원 해제
public abstract class AbstractValidatorTest {

    private ValidatorFactory factory;
    protected Validator validator;

    @BeforeEach
    void setUpValidator() {
        factory = Validation.buildDefaultValidatorFactory();
        validator = factory.getValidator();
    }

    @AfterEach
    void tearDownValidator() {
        if (factory != null) {
            factory.close();
        }
    }

}
@Test
void givenInvalidPhoneNumber_whenValid_thenFailValidation() {
    // given
    RequestStoreBasicInfo request = new RequestStoreBasicInfo(
            "맛있는김밥",
            "한식",
            "010-1234-578asd",
            "신선한 재료로 매일 준비합니다."
    );

    // when
    Set<ConstraintViolation<RequestStoreBasicInfo>> violations = validator.validate(request);

    // then
    assertThat(violations).hasSize(1);
    assertThat(violations)
            .extracting("message")
            .contains("전화번호 형식이 올바르지 않습니다. 예: 010-1234-5678 또는 02-123-4567");
}

Service 레이어

  • 도메인에서 공통으로 사용되는 테스트 더미 생성 클래스 구현 (Fixture 클래스)
    • 서비스 뿐만 아니라 다른 레이어의 테스트에서도 아래 기능을 사용하기 때문에 별도의 외부 클래스로 만들었다.
public class StoreFixture {

    public static Store createStore(User user) {
        StoreRegisterRequestDto dto = new StoreRegisterRequestDto(
                createBasicInfo(),
                createOperationInfo(),
                createOrderSettings()
        );
        return dto.toEntity(user);
    }

    public static StoreRegisterRequestDto createStoreRegisterRequestDto() {
        return new StoreRegisterRequestDto(
                StoreFixture.createBasicInfo(),
                StoreFixture.createOperationInfo(),
                StoreFixture.createOrderSettings()
        );
    }

    public static User createUser() {
        return User.of(
                "test@email.com",
                "pw",
                "사장님",
                "010-1234-5678",
                UserRole.OWNER
        );
    }
    
    ...
    
}
  • mock() : 내부 값이 중요하지 않은 경우에 사용
  • ReflectionTestUtils : 일반적으로 entity의 id 값은 직접 설정할 수 없다. 때문에 리플렉션을 써서 강제로 값을 설정한다.
@DisplayName("성공 - 가게 상세 조회 (소유주 일치)")
@Test
void givenStoreIdAndUserId_whenGetOwnerStoreDetail_thenReturnStoreDetail() {
    // given
    Long userId = 1L;
    Long storeId = 2L;

    User user = mock(User.class);
    Store store = StoreFixture.createStore(user);

    ReflectionTestUtils.setField(store, "user", user);
    ReflectionTestUtils.setField(user, "id", userId);

    given(userRepository.findByIdOrElseThrow(userId)).willReturn(user);
    given(storeRepository.findByIdOrElseThrow(storeId)).willReturn(store);
    given(user.getId()).willReturn(1L);

    // when
    OwnerStoreDetailResponseDto response = storeService.getOwnerStoreDetail(2L, 1L);

    // then
    assertThat(response).isNotNull();
    assertThat(response.basicInfo().category()).isEqualTo("한식");
    assertThat(response.status()).isEqualTo(StoreStatus.CLOSED);
}

Repository 레이어 테스트 코드 작성

  • 검증 시 id 값은 직접적으로 비교하지 말고, 상태나 비즈니스 로직 등만 테스트한다.
    • @DataJpaTest 사용 시 자동으로 트랜잭션이 적용되어 테스트 메서드마다 롤백이 된다.
    • 그러나 트랜잭션 rollback은 데이터 "삽입"을 취소할 뿐이지, DB 내부 auto_increment 시퀀스는 한 번 증가했으면 되돌릴 수 없다.
@DataJpaTest
@Import(TestAuditorConfig.class)
@ActiveProfiles("test")
class StoreRepositoryTest {

    @Autowired
    private StoreRepository storeRepository;

    @Autowired
    private UserRepository userRepository;

    private User userEntity;
    private StoreRegisterRequestDto dto;

    @BeforeEach
    void setup() {
        userEntity = StoreFixture.createUser();
        dto = StoreFixture.createStoreRegisterRequestDto();
    }

    @DisplayName("가게 검색 목록 조회 테스트")
    @Nested
    class FindAllOpenedStoreByNameTest {

        @DisplayName("성공 - 가게 이름 검색 +_폐업 가게 제외 + OPEN 가게 우선 정렬")
        @Test
        void givenStoreName_whenFindAllStoreByName_thenReturnPagedStores() {
            // given
            Pageable pageable = PageRequest.of(0, 10);

            User savedUser = userRepository.save(userEntity);

            Store store1 = dto.toEntity(savedUser);
            Store store2 = dto.toEntity(savedUser);
            Store store3 = dto.toEntity(savedUser);

            store1.updateStatus(StoreStatus.PERMANENTLY_CLOSED);
            store2.updateStatus(StoreStatus.TEMPORARILY_CLOSED);
            store3.updateStatus(StoreStatus.OPEN);

            storeRepository.save(store1);
            storeRepository.save(store2);
            storeRepository.save(store3);

            // when
            Page<Store> result = storeRepository.findAllStoreByName("%김밥%", pageable);

            // then
            List<Store> stores = result.getContent();

            assertThat(stores.get(0).getStatus()).isEqualTo(StoreStatus.OPEN);
            assertThat(stores.get(1).getStatus()).isEqualTo(StoreStatus.TEMPORARILY_CLOSED);
            assertThat(stores).noneMatch(s -> s.getStatus() == StoreStatus.PERMANENTLY_CLOSED);
        }

    }

    @DisplayName("가게 추가 등록 가능 여부 조회 테스트")
    @Nested
    class IsStoreRegistrationAvailableTest {

        @Test
        @DisplayName("성공 - 운영중 가게 수가 3개 미만이면 등록 가능")
        void givenLessThanThreeStores_whenCheckAvailability_thenReturnTrue() {
            // given
            User savedUser = userRepository.save(userEntity);

            Store store1 = dto.toEntity(savedUser);
            Store store2 = dto.toEntity(savedUser);
            Store store3 = dto.toEntity(savedUser);

            // 등록된 가게는 3개지만 활성화된 가게는 2개 (폐업 상태인 가게는 제외)
            store1.updatePermanentlyClosed(true);

            storeRepository.save(store1);
            storeRepository.save(store2);
            storeRepository.save(store3);

            // when
            boolean available = storeRepository.isStoreRegistrationAvailable(savedUser.getId());

            // then
            assertThat(available).isTrue();
        }

        @Test
        @DisplayName("성공 - 운영중 가게 수가 3개 이상이면 등록 불가")
        void givenThreeOrMoreStores_whenCheckAvailability_thenReturnFalse() {
            // given
            User savedUser = userRepository.save(userEntity);

            Store store1 = dto.toEntity(savedUser);
            Store store2 = dto.toEntity(savedUser);
            Store store3 = dto.toEntity(savedUser);

            storeRepository.save(store1);
            storeRepository.save(store2);
            storeRepository.save(store3);

            // when
            boolean available = storeRepository.isStoreRegistrationAvailable(savedUser.getId());

            // then
            assertThat(available).isFalse();
        }

    }

}

참고

profile
일단 해보자

0개의 댓글