테스트 코드 작성 시 데이터베이스 초기화 하는 방법
이전까지 테스트 코드 작성 시 데이터베이스 초기화 방법
데이터베이스를 초기화 해야하는 이유는 각각의 테스트는 독립된 환경에서 검증이 되어야 하기 때문이다.
테스트 코드를 작성하면 각 테스트마다 초기화된 데이터 베이스로 테스트 하기 위해
데이터 베이스를 초기화 해주어야 한다.
그 때 나는 보통 repository.deleteAll()
을 사용했었다.
이전에는 몰랐는데 이번 프로젝트를 하면서 고민을 하게되며, 단점과 다른 방법을 알게 되었다.
이전 방법의 단점
repository.deleteAll()
의 단점은 테스트 코드가 프로덕션 코드에 의존하게 되는 것이다.프로덕션 코드에 의존
하게 되면,repository
가 변경될 경우 테스트 코드에 의존 역시 변경되어야 하기 때문이다.
객체지향의OCP를 위반
하는 것이다.repository.deleteAll()
은 auth_increment나 시퀀스를 초기화 하지 못한다.
즉, 테스트 작성 시 작성된 ROW의 ID를 알 수 없기 때문에 테스트 시 굉장히 불편하다.
반환 받으면 알 수 있지만, 그렇지 않은 상황도 있다. 예를 들어 보자.- 포스트 목록 조회 테스트를 하기 위해 11개의 포스트를 만든다.
- incrementHits()는 포스트의 조회수를 증가시킨다.
- 조회수가 많은 순서대로 조회되었는지 확인한다.
@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
는 테이블의 공간 자체를 통으로 날려버리기 때문이다.
'JPA' 카테고리의 다른 글
JPA 프록시의 field 접근과 get메서드 접근 (0) | 2022.05.04 |
---|---|
JPA 기본키 전략을 뭘 사용해야 할까? (0) | 2022.03.26 |
JPA에서 영속화라는 의미는 뭘까? (14) | 2022.03.25 |
ORM(JPA)를 왜 사용할까? (0) | 2022.03.24 |
JPA 영속성 컨텍스트가 뭘까? (0) | 2021.10.18 |