team logo icon
article content thumbnail

테스트 DB 초기화가 필요하다!

테스트를 수행할 때마다 DB의 초기화가 필요하다면?

테스트를 진행하다보면 사전/사후 처리가 상당히 빈번하게 활용됩니다.

@BeforeEach , @BeforeAll, @AfterEach, @AfterAll 등과 같이 말입니다.

오늘 다루는 내용 또한 사전에 테스트를 위한 더미데이터를 생성하는 과정에서 발생했던 문제와 그에 대한 해결 과정을 소개해드리고자 합니다.


🚨 문제 상황


유저 도메인에 대한 Entity와 JPA Repository를 구현한 뒤, Repository에 대한 테스트를 진행하고 있을 때였습니다.

Test의 경우, 아래와 같은 구조로 @Nested 를 통해 테스트 클래스에 대한 독립성을 분리하여 관리하고 했습니다.

class UserRepositoryTest {

    @Nested
    @TestInstance(Lifecycle.PER_CLASS)
    class SingleUserSelectTest {
        ...
    }

    @Nested
    @TestInstance(Lifecycle.PER_CLASS)
    class MultiUserSelectTest {
        ...
    }

}


@Nested 를 활용하여 관심사와 시나리오에 따라 클래스로 분리하여 테스트를 구현하고 있던 중,
@BeforeAll 을 통해 각 Test Class마다 필요한 Expected Dummy Data를 미리 Insert 준비해놓은 후, 각 Test 메서드에서 조회해오는 과정에서 한 가지 문제점을 발견했습니다.

바로 ID를 통한 조회에 실패한다는 것입니다.


@DataJpaTest 의 경우, 내부적으로 @Transactional 어노테이션이 부착되어 있어
각 Test 메서드 실행 후, 롤백(Rollback)이 되기 때문에 데이터 저장과 삭제는 문제 없었지만 ID의 Sequence, Index 등은 지속적으로 누계됩니다.

단일 사용자에 대한 조회 테스트의 경우,
아래와 같이 @BeforeAll 을 통해 더미 데이터 객체를 미리 저장해놓고 given 절에서 더미 객체로부터 ID를 가져와 조회하기 때문에 문제가 발생하지 않았지만

class UserRepositoryTest {

    ...

    @Nested
    @DisplayName("[TEST] 단일 유저 대상 단일 ID로 조회하는 시나리오")
    @TestInstance(Lifecycle.PER_CLASS)
    class SingleUserSelectTest {

        private User user;

        @BeforeAll
        void setUpSingleUser() {
            User userEntity = User.builder()
                    ....
                    .build();
            user = userRepository.save(userEntity);
        }

        @Test
        @DisplayName("Case. 단일 유저에 대한 조회 성공")
        void getSuccessTest() {
            // given
            val userId = user.getId();

            // when
            val result = userRepository.findUserById(userId);
            
            ...
        }
    ...
}


다중 사용자에 대한 조회 테스트의 경우,
ID의 시퀀스가 초기화되지 않아 의도했던 given 절에 주어진 ID 리스트 기반으로 객체 리스트를 조회해오는 테스트를 통과하지 못하는 문제가 발생했습니다.

 class MultiUserSelectTest {

    ...

    @BeforeAll
    void setUpMultiUser() {
        User userA = User.builder()
                ...
                .build();
        User userB = User.builder()
                ...
                .build();
        User userC = User.builder()
                ...
                .build();
        userRepository.saveAll(List.of(userA, userB, userC));
    }
    ...
    @Test
    @DisplayName("Case. 복수 유저 조회 성공")
    void getSuccessTest() {
        // given
        val ids = List.of(1L, 2L, 3L);
        
        // when
        val result = userRepository.findAllUsersById(ids);

        // then
        assertThat(users).usingRecursiveComparison()
                .isEqualTo(result);
    }
}

(단일 유저 조회 테스트가 수행된 이후, 다중 유저 조회 테스트가 수행될 경우, ID Sequence가 초기화되지 않아 다중 유저 조회 테스트 Nested Class에서의 @BeforeAll 에서 주입된 데이터의 ID는 3부터 시작되는 것을 알 수 있습니다.)



물론 @TestClassOrder(ClassOrderer.OrderAnnotation.class) 를 부착하고
다중 유저 테스트 Nested Class가 먼저 실행되도록 보장시켜준다면 급하게 해결이 가능하지만

테스트는 독립되어 있어야 하기 때문에 억지로 Order을 지정하여 순서를 보장하는 것은 바람직하지 못하다고 생각하여 해결 방안을 찾게 되었습니다.



💡 해결 방안 도출


1. DirtiesContext 활용

[장점]

  • 테스트간 환경을 명확하게 구분하여 격리 환경 관리가 가능하다. (컨텍스트를 다시 로드하는 방식)

[단점]

2. @SQL & 정적 SQL 리소스 파일 활용

Test Class에 @SQL 어노테이션을 부착하여
테스트가 실행될 때마다 실행 전에 SQL Script가 실행되도록 하는 방식입니다.

[장점]

  • 여러 Query & 여러 Script를 직접 설정하여 수행시킬 수 있다.

  • 초기화뿐만 아니라 초기 Dummy Data까지 함께 넣을 수 있다.

[단점]

  • 테스트에서 활용하는 DB System 환경이 변경되었을 때, Dialect 등과 같은 설정값까지 일괄적으로 반영해주어야 한다.

  • 쿼리를 정적 데이터로 관리하다보니 컴파일 타임에 오류를 발견하기 어렵다.

  • 연관관계, 제약조건 등이 복잡하게 설정되어 있는 경우, 쿼리문 작성에 부담이 늘어난다.



3. EntityManager 활용 & DB 초기화 실행하는 InitializingBean 구현

DB 초기화 관련 작업에 대해 InitializingBean 로 구현하여
Spring Bean이 초기화, 소멸 될 때 관련 행위 로직을 추가하여 Component로서 활용하는 방법입니다.

[장점]

  • 테스트 코드가 쿼리에 의존적이지 않다. (EntityManager 활용)

  • 설정을 분리하여 관리할 수 있다.
    ( @ActiveProfiles & @Profile 등과 같이 적용 설정 등을 유연하게 관리 가능 )


[단점]

  • JPA에 종속적이다.

  • Bean으로 생성하여 관리되기 때문에 해당 컴포넌트 클래스를 프로덕션에서도 활용 가능하다는 특징이 있다.



💻 실제 적용


해당 API에 대한 구현 Due date를 고려했을 때 가장 간단하게 구현할 수 있으면서도 Entity 구현 상황에 따라 자동으로 반영할 수 있고

직접 .sql 정적 파일과 같은 리소스들을 직접 관리하지 않을 수 있다는 점을 미루어
" EntityManager 를 활용하여 Table 들을 초기화 하는 방법 "을 채택하게 되었습니다.



우선 @DataJpaTest 에서 DB 초기화 객체를 주입받아 사용하기 위해 @Component 를 부착한 후,
InitializingBean 을 Implements 하여 자동으로 BeanFactory에 세팅되도록 해줍니다.

Interface to be implemented by beans that need to react once all their properties have been set by a BeanFactory: e.g. to perform custom initialization, or merely to check that all mandatory properties have been set.

< InitializingBean JavaDocs 중>


이 때 유의할 점은 테스트 내에서 DB에 있는 Table 정보를 파악해서 모든 Table에 TRUNCATE(초기화)해주기 위해서는
인스턴스 생성(초기화) 시점Table에 대한 정보를 파악하는 과정이 필요한데

이에 InitializingBean 을 Implements 하여 Table 정보 수집 로직커스텀 초기화 로직으로 구현했습니다.

public class DatabaseCleaner implements InitializingBean {
    @PersistenceContext
    private EntityManager entityManager;

    private final List<String> tableNames = new ArrayList<>();

    @Override
    public void afterPropertiesSet() {
        findAllTableNames();
    }
    private  void findAllTableNames() {
        List<String> names = entityManager.getMetamodel().getEntities().stream()
                .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
                .map(e -> {
                    String snakeName = convertToSnake(e.getName());
                    if (snakeName.equals("user")) {
                        return "users";
                    }
                    return snakeName;
                })
                .toList();
        tableNames.addAll(names);
    }

    // Camel Case인 Entity이름을 Snake Case로 변환하는 메서드입니다.
    private String convertToSnake(String camel) {
        StringBuilder builder = new StringBuilder();
        int index = 0;
        for (char alphabet : camel.toCharArray()) {
            if (Character.isUpperCase(alphabet)) {
                if (index == 0) {
                    builder.append(Character.toLowerCase(alphabet));
                } else {
                    builder.append("_").append(Character.toLowerCase(alphabet));
                }
            } else {
                builder.append(alphabet);
            }
            index++;
        }
        return builder.toString();
    }
}

이를 통해 해당 클래스가 Bean으로 등록되었을 때, 자동으로 Table 이름을 수집하여 저장하고 있게 됩니다.

그 다음, DB Table들을 TRUNCATE 해주는 메서드를 구현해주면 끝입니다.

public class DatabaseCleaner implements InitializingBean {

    private static final String TRUNCATE_TABLE_QUERY_FORMAT = "TRUNCATE TABLE %s";
    private static final String ALTER_TABLE_ID_COLUMN_TO_START_FROM_ONE_QUERY_FORMAT = "ALTER TABLE %s ALTER COLUMN id RESTART WITH 1";

    @PersistenceContext
    private EntityManager entityManager;

    private final List<String> tableNames = new ArrayList<>();

    @Override
    public void afterPropertiesSet() {
        findAllTableNames();
    }

    @Transactional
    public void execute() {
        entityManager.clear();
        truncate();
    }

    private void truncate() {
        for (String name : tableNames) {
            entityManager.createNativeQuery(
                        String.format(TRUNCATE_TABLE_QUERY_FORMAT, name)
                    ).executeUpdate();
            entityManager.createNativeQuery(
                        String.format(ALTER_TABLE_ID_COLUMN_TO_START_FROM_ONE_QUERY_FORMAT, name)
                    ).executeUpdate();
        }
    }
   
}

DELETEDROP 을 쓰지 않고 TRUNCATE 를 사용하는 이유는 아래와 같습니다.

  • DELETE : 단순 행이 삭제되는 것 뿐 의도했던 ID 초기화는 이루어지지 않는다

  • DROP : 테이블 자체를 삭제하는 것은 의도한 처리가 아니다.


위와 같이 구현한 DatabaseCleaner 객체를 Test에 직접 사용해봅시다.

@DataJpaTest
@EntityScan(basePackages = "...")
@ContextConfiguration(classes = {
        ...,
        DatabaseCleaner.class,
        ...
})
@EnableAutoConfigurationclass UserRepositoryTest {

    @Nested
    @TestInstance(Lifecycle.PER_CLASS)
    class MultiUserSelectTest {
        
        @BeforeAll
        void setUpMultiUser() {
            cleaner.execute();
            User userA = User.builder()
                    ...
                    .build();
            User userB = User.builder()
                    ...
                    .build();
            User userC = User.builder()
                    ...
                    .build();
            userRepository.saveAll(List.of(userA, userB, userC));
        }

        @Test
        void getSuccessTest() {
            // given
            val ids = List.of(1L, 2L, 3L);

            // when
            val result = userRepository.findAllUsersById(ids);

            // then
            assertThat(users).usingRecursiveComparison()
                    .isEqualTo(result);
        }
    }

}





그 결과, 성공적으로 테스트를 수행하는 것을 확인해볼 수 있습니다.



❓ @DataJpaTest 에서 새로 정의한 @Component 활용

기본적으로 @DataJpaTest 는 JPA 구성 요소에만 초점을 맞춘 어노테이션이기 때문에
전체 Auto Configuration이 비활성화된다는 특징이 있습니다.

Annotation for a JPA test that focuses only on JPA components.
Using this annotation will disable full auto-configuration and instead apply only configuration relevant to JPA tests.

...

<@DataJpaTest JavaDoc>



이에 @ContextConfiguration 을 통해 @Component 로 등록한 DatabaseCleaner.class 를 불러와 사용할 수 있게 해야 합니다.

The term component class can refer to any of the following.

  • A class annotated with @Configuration

  • A component (i.e., a class annotated with @Component, @Service, @Repository, etc.)

  • A JSR-330 compliant class that is annotated with jakarta.inject annotations

  • Any class that contains @Bean-methods

  • Any other class that is intended to be registered as a Spring component (i.e., a Spring bean in the ApplicationContext), potentially taking advantage of automatic autowiring of a single constructor without the use of Spring annotations

...

<@ContextConfiguration JavaDoc>


물론 위 방법도 분명히 단점이 존재하는 방법이며 추후 사이드 이펙트가 충분히 발생할 수 있지만
팀의 상황과 구현 환경, 러닝 커브 등 여러 환경을 고려하여 현재 상황에 가장 적합한 선택지였기에 채택하게 되었습니다.

꾸준히 테스트 독립성(Test Isolation)을 위해 더 고민하고
조금 더 유연하고 확장성 있는 방법이 무엇인지 더 탐구해볼 필요가 있을 것 같습니다.


📚 Ref.


최신 아티클
Article Thumbnail
꿀맛규동
|
2024.08.11
커뮤니티 부스로 인프콘 2024 참가한 ssul (with SOPT)
인프콘 2024(INFCON 2024)에 SOPT 커뮤니티 부스 운영진으로 참여한 후기