테스트 코드 유지보수 해보기 3

2023. 5. 14. 21:49테스트

이전 포스팅에 이어 테스트 코드 유지보수를 이어나가 보겠습니다.

이전 포스팅에서 적용한 것들은 다음과 같습니다.

  • test datasource 설정 (1)
  • schema.sql, data.sql 추가 (1)
  • 기획서를 참고하여 기존 로직 수정 (2)
  • JPA 영속성 컨텍스트 관련 이슈 조치 (2)

이렇게 했을 때, 마지막 테스트 수행 결과는 다음과 같았습니다.

이전 포스팅 마지막 테스트 결과

 

에러가 발생하는 테스트 케이스

여기서는 생각보다 다양한 Exception이 발생하고 있었습니다.

  • 도메인 예외 케이스
    • ex. 필수값이 없거나, 적절하지 않은 상태 등..
  • DB로 부터 조회 결과가 없는 경우
    • repository.findById(id).get() 호출 시 반환된 Optional이 empty인 경우
  • 데이터 insert 시 unique key 중복
  • Transaction 관련 예외 발생

도메인 예외 케이스

도메인 예외 케이스의 경우는 특정 도메인 객체 생성 시 시작 기간과 종료 기간에 대한 Validation을 통과하지 못하는 경우였습니다.

예를 들어, 주문 생성 시각이 주문 종료 시각 이후가 될 수 없겠죠?

테스트 코드 작성 시 테스트 객체 생성하는 작업이 굉장히 귀찮은 작업이라, 저는 몇몇 타입의 랜덤한 값을 생성해주는 유틸 클래스를 만들어서 사용하고 있었습니다. (요즘에는 FixtureMonkey 라는 멋진 라이브러리가 나왔죠 :) )

public class RandomUtil {

    private static final Random random = new Random();

    // ..

    public static Pair<ZonedDateTime, ZonedDateTime> getPeriod() {
        ZonedDateTime before = ZonedDateTime.of(START_YEAR, 
                                                START_MONTH, 
                                                START_DAY_OF_MONTH,
                                                START_HOUR, 
                                                START_MINUTE, 
                                                START_SECOND, 
                                                START_NANO_OF_SECOND, 
                                                STANDARD_ZONE_ID);
        ZonedDateTime after = before.plusDays(getLong(365 * 10)); // 종료 DateTime
        return Pair.create(before, after);
    }

    public static Long getLong(long bound) {
        if (bound == 0L) {
            return 0L;
        }
        return random.nextLong() % bound;
    }

    // ..
}

위 코드를 보시면 기간 데이터를 생성할 때 before.plusDays(getLong(365 * 10)) 를 종료 날짜/시각으로 사용하고 있습니다.

잘못된 부분을 눈치채셨나요?

 

getLong(long bound) 메서드에서는 양/음수 모두 반환될 수 있습니다. 즉, 종료 날짜/시각이 시작 날짜/시각 이전이 될 수 있는 것이죠.

이러한 문제로 인해서 테스트가 거의 50% 확률로 성공하고 있었습니다.

테스트 코드에서는 이러한 이슈들을 정말 조심해야 합니다. 테스트가 성공하여 마음 놓고 배포했는데 장애가 발생할 수 있으니까요.

그래서, 반환되는 랜덤 Long 값에 절댓값을 붙여 문제를 간단하게 해결할 수 있었습니다.

before.plusDays(getLong(365 * 10))before.plusDays(Math.abs(getLong(365 * 10)))

기간 생성 메서드 개선 후 테스트 결과

 

DB - 조회 결과 없는 경우 & Unique Key 중복

특정 테스트 클래스에서 해당 두 경우의 예외가 발생했습니다.

위 이미지에서 보이는 실패하는 클래스의 메서드들은 모두 @BeforeEach 메서드에서 예외가 발생했습니다.

그리고, 빨간색 네모 박스로 표시한 첫 메서드는 java.util.NoSuchElementException: No value present at java.util.Optional.get(Optional.java:135) 예외가 발생했고,

@BeforeEach
void init() {

    SaleRecord saleRecord = saleRecordRepository.save(
        new SaleRecord(1L,
                       ZonedDateTime.now(),
                           2L,
                       3L,
                       ZonedDateTime.now(),
                       ZonedDateTime.now(),
                       null,
                       null,
                       null));

    em.flush();
    em.clear();

    SaleRecord savedSaleRecord = saleRecordRepository.findById(id).get(); // here

    // ..
}

 

이후 메서드들은 모두 Caused by: java.sql.SQLException: Duplicate entry 'saleNumber' for key 'uk_sale_number’ 예외가 발생하였습니다.

@BeforeEach
void init() {

    SaleRecord saleRecord = saleRecordRepository.save(
        new SaleRecord(1L,
        ZonedDateTime.now(),
        2L,
        3L,
        ZonedDateTime.now(),
        ZonedDateTime.now(),
        null,
        null,
        null)); // here

    em.flush();
    em.clear();

    SaleRecord savedSaleRecord = saleRecordRepository.findById(id).get();

    // ..
}

 

이러한 결과를 보고 테스트 실행 중에 다른 테스트에 의해 영속성 컨텍스트가 비워지거나 DB 초기화가 이루어질 수 있다는 의심을 하게 되었습니다.

찾아보니, Spring Framework는 여러 단위 테스트를 한꺼번에 수행할 때, default로 컨텍스트를 재사용한다고 합니다.

Can Spring Boot test classes reuse application context for faster test run?

또한, Unique Key 중복 데이터 insert 예외가 발생하는 것을 보고 @Transactional 어노테이션이 존재하지만 테스트 메서드 간 격리가 잘 되고 있지 않음을 알 수 있었습니다.

그래서 우선 테스트 간 격리를 시키기 위해 data.sql에 있는 init data를 제외한 데이터들을 각 테스트 메서드 수행 후 제거하고자 했습니다.

앞서 포스팅에서 다루었던 방법 중 TestExecutionListener를 사용하는 방법을 택했습니다.

@Slf4j
public class DBTestExecutionListener extends AbstractTestExecutionListener {
    private static final List<String> TRUNCATED_TABLES = Lists.newArrayList("order", "sales", "sales_record"); // .. 

    public final int getOrder() {
        return 4001;
    }

    @Override
    public void beforeTestClass(TestContext testContext) throws Exception {
        log.info("DBCleanerTestExecutionListener beforeTestClass() Started .. ");
        final JdbcTemplate jdbcTemplate = testContext.getApplicationContext().getBean(JdbcTemplate.class);
        useTestDatabase(jdbcTemplate);
        log.info("DBCleanerTestExecutionListener beforeTestClass() Finished .. ");
    }

    private void useTestDatabase(JdbcTemplate jdbcTemplate) {
        execute(jdbcTemplate, "use test;");
    }

    @Override
    public void afterTestMethod(TestContext testContext) throws Exception {
        log.info("DBCleanerTestExecutionListener afterTestMethod() Started .. ");
        final JdbcTemplate jdbcTemplate = testContext.getApplicationContext().getBean(JdbcTemplate.class);
        truncateTables(jdbcTemplate);
        log.info("DBCleanerTestExecutionListener afterTestMethod() Finished .. ");
    }

    private void truncateTables(JdbcTemplate jdbcTemplate) {
        execute(jdbcTemplate, "SET foreign_key_checks=0;");
        TRUNCATED_TABLES.forEach(t -> execute(jdbcTemplate, "DELETE FROM " + t + ";"));
        execute(jdbcTemplate, "SET foreign_key_checks=1;");
    }

    private void execute(JdbcTemplate jdbcTemplate, String query) {
        jdbcTemplate.execute(query);
    }
}

 

내부적으로 실행되는 기본적인 TestExecutionListener 들은 다양하게 있는데, 그 중 테스트 메서드에 @Transactional 어노테이션이 존재하는 경우 롤백을 시켜주는 등의 처리를 해주는 TransactionalTestExecutionListener 가 있습니다.

아래에는 해당 클래스 일부를 캡처한 이미지입니다. getOrder() 메서드에서 4000을 반환하고 있는데, TestExecutionListener 간 실행 순서를 뜻합니다. (높을수록 후순서)

커스텀 TestExecutionListenerDBTestExecutionListenergetOrder() 메서드에서 4001을 반환하게 한 이유는 트랜잭션 롤백 이후에 처리되도록 하기 위한 의도였습니다.

(하지만 어째서 인지 실제 실행된 로그를 보면 의도대로 잘 동작하지는 않습니다. 정확한 내부적인 구조는 점차 분석해볼 필요가 있을 것 같습니다.)

 

또한, 이를 테스트 전역에 적용하려면 resources 디렉토리에 META-INF/spring.factories 파일을 생성하여 다음과 같이 작성해주어야 합니다.

org.springframework.test.context.TestExecutionListener=\
com.hrp.config.DBTestExecutionListener

 

DB - No database selected?

 

에러 중 다른 하나는 특정 테스트 클래스 내 하나의 테스트 메서드가 실패를 하고 있었는데, 뜬금없이 선택할 데이터베이스(스키마)가 없다고 합니다.

Junit이 기본적으로 테스트 간 격리를 보장하기 위해 @Test 가 달린 메서드 마다 테스트 인스턴스를 새로 생성합니다.

설정이 포함된 무거운 객체(ex. DataSource)들은 어플리케이션 컨텍스트 내에서 공유된다고 알고 있었습니다. 그런데 No database selected 문제가 발생하여 위에서 잠깐 언급했었던 다른 테스트에 의한 영향일 수 있겠다고 생각했습니다.

(application.yml에 정의된 datasource에 database 설정이 없다.)

spring:
  datasource:
    url: jdbc:mariadb://localhost:3306
    # ..

 

그래서, 이전에 추가한 TestExecutionListener에 test 스키마를 생성하는 로직을 하나 추가하였습니다.

@Slf4j
public class DBTestExecutionListener extends AbstractTestExecutionListener {

    // ..

    private void createTestDatabase(JdbcTemplate jdbcTemplate) {
        try {
            jdbcTemplate.execute("create schema test;");
        } catch (Exception e) {
            return;
        }
    }

    @Override
    public void beforeTestClass(TestContext testContext) throws Exception {
        log.info("DBCleanerTestExecutionListener beforeTestClass() Started .. ");
        final JdbcTemplate jdbcTemplate = testContext.getApplicationContext().getBean(JdbcTemplate.class);
        createTestDatabase(jdbcTemplate); // added
        useTestDatabase(jdbcTemplate);
        log.info("DBCleanerTestExecutionListener beforeTestClass() Finished .. ");
    }

    private void useTestDatabase(JdbcTemplate jdbcTemplate) {
        execute(jdbcTemplate, "use test;");
    }

    // ..
}

 

이렇게 여러 수정 사항을 거친 결과, 다음과 같이 모든 테스트가 통과하는 모습을 확인할 수 있습니다.

 

테스트 결과

 

이렇게 모두 해결이 된 것일까요?

그랬으면 좋겠지만, 아직 문제가 남아있었습니다. 테스트 유지보수에 대한 찐 마지막 포스팅으로 찾아뵙도록 하겠습니다! :)