반응형

테스트 코드 작성 시 데이터베이스 초기화 하는 방법

이전까지 테스트 코드 작성 시 데이터베이스 초기화 방법


데이터베이스를 초기화 해야하는 이유는 각각의 테스트는 독립된 환경에서 검증이 되어야 하기 때문이다.

테스트 코드를 작성하면 각 테스트마다 초기화된 데이터 베이스로 테스트 하기 위해
데이터 베이스를 초기화 해주어야 한다.
그 때 나는 보통 repository.deleteAll()을 사용했었다.
이전에는 몰랐는데 이번 프로젝트를 하면서 고민을 하게되며, 단점과 다른 방법을 알게 되었다.

이전 방법의 단점


  • repository.deleteAll()의 단점은 테스트 코드가 프로덕션 코드에 의존하게 되는 것이다.
    프로덕션 코드에 의존하게 되면, repository가 변경될 경우 테스트 코드에 의존 역시 변경되어야 하기 때문이다.
    객체지향의 OCP를 위반하는 것이다.
  • repository.deleteAll()은 auth_increment나 시퀀스를 초기화 하지 못한다.
    즉, 테스트 작성 시 작성된 ROW의 ID를 알 수 없기 때문에 테스트 시 굉장히 불편하다.
    반환 받으면 알 수 있지만, 그렇지 않은 상황도 있다. 예를 들어 보자.
    1. 포스트 목록 조회 테스트를 하기 위해 11개의 포스트를 만든다.
    2. incrementHits()는 포스트의 조회수를 증가시킨다.
    3. 조회수가 많은 순서대로 조회되었는지 확인한다.
@Test
void 포스트_목록_조회() {
    // given
    memberRepository.save(createMember(NAVER_EMAIL));
    // 포스트 11개 생성
    for (int i = 1; i <= 11; i++) {
        PostRequest postRequest = new PostRequest("포스트제목" + i, "포스트1내용" + i);
        postService.createPost(NAVER_EMAIL, postRequest);
    }

    incrementHits();

    // when
    String[] sort = {"hits", "createDate"};
    PageRequest pageRequest = PageRequest.of(0, 10, Sort.Direction.DESC, sort);
    PostPagingResponses findPosts = postService.findAllPostPaging(pageRequest);

    // then
    assertThat(findPosts.getPosts().get(0).getId()).isEqualTo(5L);
    assertThat(findPosts.getPosts().get(1).getId()).isEqualTo(1L);
    assertThat(findPosts.getPosts().get(2).getId()).isEqualTo(3L);
    assertThat(findPosts.getTotalCount()).isEqualTo(11);
    assertThat(findPosts.getPageSize()).isEqualTo(10);
    assertThat(findPosts.getCurrentPage()).isEqualTo(0);
    assertThat(findPosts.getTotalPage()).isEqualTo(2);
}

private void incrementHits() {
    hitsRepository.incrementHits(1L);
    hitsRepository.incrementHits(1L);
    hitsRepository.incrementHits(3L);
    hitsRepository.incrementHits(5L);
    hitsRepository.incrementHits(5L);
    hitsRepository.incrementHits(5L);
    hitsRepository.updateRDB();
}

위와 같은 테스트를 할 때 ID가 초기화되지 않아서 ID가 1부터 시작하지 않는다면, 이와 같은 테스트는 불가능하다.
이와 같은 상황이 아니더라도 ID반환이 필요없는데 테스트를 위해 ID를 반환하도록 메서드를 만들어야 하는 경우도있다.

물론 하면 되지만, 이런 사소한 부분을 신경써서 테스트하는 건 리소스 낭비라고 생각한다.
이번에 프로젝트를 하면서 아주 많이 느꼈다. 처음에는 인수 테스트와 문서 테스트에 Repository를 의존하지 않기 위해서만
적용했었는데 개발하다보니 위와 같이 단위 테스트를 할 때도 굉장히 불편함을 느껴 뒤늦게 모든 테스트에 적용하기로 결정했다.

해결 방법


프로덕션 코드와의 의존을 제거해보도록 하자.

먼저 @BeforeEach를 담당하는 테스트 클래스를 만든다.
나는 API 테스트를 RestAssured를 사용하기 때문에 RestAssured.port = port를 넣었고,
중요한 부분은 DatabaseCleanup, databaseCleanup.execute()이다.
해당 클래스를 import하는 방식으로 사용할 것이다.

import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;
import project.myblog.utils.DatabaseCleanup;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AcceptanceTest {
    @LocalServerPort
    int port;

    @Autowired
    private DatabaseCleanup databaseCleanup;

    @BeforeEach
    public void setUp() {
        RestAssured.port = port;
        databaseCleanup.execute();
    }
}

DatabaseCleanup

EntityManager

  • JPA를 사용하기 때문에 주입받았다.

tableNames

  • 영속성 컨텍스트에 등록된 모든 엔티티 테이블을 보관할 컬

afterPropertiesSet

  • InitializingBean의 구현 메서드로 스프링의 모든 빈이 주입되고 난후 동작하는 메서드이다.
  • 내부 코드를 보면 영속성 컨텍스트에 있는 모든 엔티티를 꺼내서
    Entity 애노테이션이 달린 클래스들을 필터링한다.
  • 필터링된 클래스들은 자바 파일이기 때문에 upperCamel(SoccerMember)형태이기 때문에 JPA에서 자동으로 생성해주는
    데이터 베이스 테이블명 규칙인 lowerUnderscore로 변경한다.
    데이터 베이서 테이블명 규칙이 다르다면 다른 케이스로 변환하면 된다.
    그럼 tableNames에 담는다.

execute()

  • @Transactional이 있는 이유는 JPA는 당연하지만, 트랜잭션 내에서 동작해야하기 때문이다.
  • 클라이언트가 사용하는 인터페이스이다.
  • “SET REFERENTIAL_INTEGRITY FALSE"로 테이블의 제약 조건들을 비활성화하고,
    ”TRUNCATE TABLE " + tableName”으로 TRUNCATE하고,
    "ALTER TABLE " + tableName + " ALTER COLUMN ID RESTART WITH 1"으로 데이터베이스의
    ID컬럼을 1부터 시작하도록 초기화한다.
    마지막으로 "SET REFERENTIAL_INTEGRITY TRUE"로 다시 테이블의 제약 조건들을 활성화한다.
import com.google.common.base.CaseFormat;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Service;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.Entity;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.List;
import java.util.stream.Collectors;

@Service
@ActiveProfiles("test")
public class DatabaseCleanup implements InitializingBean {
    @PersistenceContext
    private EntityManager entityManager;

    private List<String> tableNames;

    @Override
    public void afterPropertiesSet() {
        tableNames = entityManager.getMetamodel().getEntities().stream()
                .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
                .map(e -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getName()))
                .collect(Collectors.toList());
    }

    @Transactional
    public void execute() {
        entityManager.flush();
        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();

        for (String tableName : tableNames) {
            entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
            entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN ID RESTART WITH 1").executeUpdate();
        }

        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
    }
}

사용 방법은 AcceptanceTest를 상속받아 사용하면
AcceptanceTest에서 databaseCleanup.execute()에 의해
테스트마다 데이터 베이스를 초기화하여 작업하게 되며,
테스트끼리 꼬일 일이 없게된다.

이렇게 되면, 프로덕션 코드에 의존성을 제거하여, OCP를 지킬 수 있게된다.

TRUNCATE VS DELETE

DatabaseCleanup을 보면 DELETE가 아닌 TRUNCATE를 하고있는데
TRUNCATE일까? 둘의 차이는 여러가지가 있지만
이 코드를 작성한 의미는 속도 측면이다.
DELETE는 로우를 하나씩 제거하는 반면, TRUNCATE는 테이블의 공간 자체를 통으로 날려버리기 때문이다.

반응형
복사했습니다!