참고자료
스프링 프로젝트에서 데이터베이스에 연동하는 테스트에 대해 알아보는 포스팅이다. 데이터 접근 기술은 실제 데이터베이스에 접근해서 데이터를 잘 저장하고 조회할 수 있는지 확인하는 것이 필요하다. 지금부터 테스트를 실행할 때 실제 데이터베이스를 연동해서 진행해보자.
우선, main 하위에 있는 application.yaml과 test 하위에 있는 application.yaml이 나뉘어져 있는데, 그 이유는 테스트 케이스는 src/test 하위에 있기 때문에 실행하면 src/test에 있는 application.yaml 파일이 우선순위를 가지고 실행된다. 그래서 일단 이 파일을 다음과 같이 수정해보자.
spring:
profiles:
active: test
datasource:
url: jdbc:h2:tcp://localhost/~/h2/db/springdb
username: sa
password:
- 이렇게 설정을 하면 데이터베이스는 H2 데이터베이스를 사용하는 것이고, 로컬 DB에 있는 데이터베이스로 테스트를 하겠다는 의미가 된다.
테스트 실행
@SpringBootTest
class ItemRepositoryTest {...}
- 가장 먼저 이 @SpringBootTest 애노테이션이 달려있음을 확인할 수 있는데 이 애노테이션은 @SpringBootApplication을 찾아서 설정으로 사용한다.
- 그러니까 쉽게 말해서, 우리의 main 하위에 있는 스프링 애플리케이션을 사용한다고 보면 된다. 실제로 스프링 컨테이너도 띄워지고 우리가 작업했던 설정 데이터들을 사용해 빈으로 등록하고 한다.
ItemRepositoryTest - 전체 소스
package hello.itemservice.domain;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import hello.itemservice.repository.memory.MemoryItemRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class ItemRepositoryTest {
@Autowired
ItemRepository itemRepository;
@AfterEach
void afterEach() {
//MemoryItemRepository 의 경우 제한적으로 사용
if (itemRepository instanceof MemoryItemRepository) {
((MemoryItemRepository) itemRepository).clearStore();
}
}
@Test
void save() {
//given
Item item = new Item("itemA", 10000, 10);
//when
Item savedItem = itemRepository.save(item);
//then
Item findItem = itemRepository.findById(item.getId()).get();
assertThat(findItem).isEqualTo(savedItem);
}
@Test
void updateItem() {
//given
Item item = new Item("item1", 10000, 10);
Item savedItem = itemRepository.save(item);
Long itemId = savedItem.getId();
//when
ItemUpdateDto updateParam = new ItemUpdateDto("item2", 20000, 30);
itemRepository.update(itemId, updateParam);
//then
Item findItem = itemRepository.findById(itemId).get();
assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
}
@Test
void findItems() {
//given
Item item1 = new Item("itemA-1", 10000, 10);
Item item2 = new Item("itemA-2", 20000, 20);
Item item3 = new Item("itemB-1", 30000, 30);
itemRepository.save(item1);
itemRepository.save(item2);
itemRepository.save(item3);
//둘 다 없음 검증
test(null, null, item1, item2, item3);
test("", null, item1, item2, item3);
//itemName 검증
test("itemA", null, item1, item2);
test("temA", null, item1, item2);
test("itemB", null, item3);
//maxPrice 검증
test(null, 10000, item1);
//둘 다 있음 검증
test("itemA", 10000, item1);
}
void test(String itemName, Integer maxPrice, Item... items) {
List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
assertThat(result).containsExactly(items);
}
}
- 이 테스트 케이스 3개를 실행해보면 다음과 같은 결과를 얻을 것이다.
실행 결과
- updateItem() - 성공
- save() - 성공
- findItems() - 실패
왜 findItems()는 실패했을까? 이유는 간단하다. updateItem(), save()를 실행하면서 생긴 데이터를 데이터베이스가 그대로 가지고 있기 때문에 findItems() 테스트를 실행할 때 예상하는 데이터 개수가 다르기 때문이다. 그러니까 결론적으로 테스트 시 데이터베이스는 운영이나 로컬 데이터베이스와는 철저히 분리를 해야 하며, 분리한 데이터베이스로 테스트를 할때 테스트 후 데이터를 남겨두면 안된다!
테스트 - 데이터베이스 분리
가장 원초적인 방법으로는, 테스트용 데이터베이스를 새로 만드는 것이다. 따라서 H2 데이터베이스를 하나 더 만들면 된다.
그래서 나는 다음과 같이 application.yaml 파일을 수정했다.
spring:
profiles:
active: test
datasource:
url: jdbc:h2:tcp://localhost/~/h2/db/springdbtest
username: sa
password:
- 로컬 데이터베이스는 `springdb` 였다면, 테스트 데이터베이스는 `springdbtest`이다.
- 그리고 아래와 같이 테스트에 필요한 item 테이블 하나를 만든다.
create table item
(
id bigint generated by default as identity,
item_name varchar(10),
price integer,
quantity integer,
primary key (id)
);
이렇게 변경하고 테스트를 전체 실행해보자. 과연 잘될까? 물론 아니다.
당연하게도 로컬 데이터베이스와 테스트 데이터베이스는 분리해야 하는건 맞지만, 분리한 데이터베이스인 테스트용 데이터베이스 역시 여러 테스트가 진행된다면 데이터가 남아있게 된다. 즉, 테스트에서 매우 중요한 원칙은 다음과 같다.
- 테스트는 다른 테스트와 격리해야 한다.
- 테스트는 반복해서 실행할 수 있어야 한다.
그런데 지금은 findItems()라는 테스트는 updateItem(), save() 둘 다와 격리성이 없다. 왜냐하면 같은 데이터베이스를 사용하는데다가 테스트를 실행하고 생긴 데이터가 그대로 남아있으니 말이다. 물론, 테스트가 끝날때마다 DELETE 쿼리를 날리면 해결될 수도 있는데 이건 근본적인 해결책이 아니다. 만약 테스트 과정에서 이 쿼리에 대한 오류가 생기면 테스트가 끝나고 이 쿼리가 제대로 수행되지 않을 수 있기 때문이다. 그래서 이 문제를 해결하는 방법은 데이터 롤백을 하는것이다.
테스트 - 데이터 롤백
테스트가 끝나고 나서 트랜잭션을 강제로 롤백해버리면 데이터가 깔끔하게 제거된다. 테스트를 하면서 데이터를 이미 저장했는데, 중간에 테스트가 실패해서 롤백을 호출하지 못해도 괜찮다. 트랜잭션을 커밋하지 않았기 때문에 데이터베이스에 해당 데이터가 반영되지 않는다. 이렇게 트랜잭션을 활용하면 테스트가 끝나고 나서 데이터를 깔끔하게 원래 상태로 되돌릴 수 있다.
그리고 테스트는 각각의 테스트 실행 전 후로 동작하는 @BeforeEach, @AfterEach 라는 편리한 기능을 제공한다.
ItemRepositoryTest - 롤백 코드 추가
@SpringBootTest
class ItemRepositoryTest {
@Autowired
ItemRepository itemRepository;
@Autowired
PlatformTransactionManager transactionManager;
TransactionStatus transaction;
@BeforeEach
void beforeEach() {
transaction = transactionManager.getTransaction(new DefaultTransactionDefinition());
}
@AfterEach
void afterEach() {
//MemoryItemRepository 의 경우 제한적으로 사용
if (itemRepository instanceof MemoryItemRepository) {
((MemoryItemRepository) itemRepository).clearStore();
}
transactionManager.rollback(transaction);
}
}
- 위 코드처럼 @BeforeEach 애노테이션이 달리면, 각 테스트 마다마다 테스트 실행 전에, 작성한 코드를 수행해주는데 그게 트랜잭션 시작을 하는 코드이다.
- 위 코드처럼 @AfterEach 애노테이션이 달리면, 각 테스트 마다마다 테스트 실행 후에, 작성한 코드를 수행해주는데 그게 바로 롤백이다.
- 이렇게 @BeforeEach, @AfterEach를 사용해서, 테스트 실행 전 후로 트랜잭션을 시작하고 롤백을 해주면 데이터가 테스트 후에 남지 않게 된다.
이 상태로 테스트를 실행하면 계속 실행해도 테스트에 통과하게 된다. 그런데 여전히 불편한 건 뭐냐면 테스트 코드 파일 마다 이걸 이렇게 귀찮게 작성해줘야 할까?에 대한 고민이다. 스프링은 우리에게 이런 불편함을 그대로 떠안도록 내버려 두지 않는다.
테스트 - @Transactional
스프링은 테스트 데이터 초기화를 위해 트랜잭션을 적용하고 롤백하는 방식을 @Transactional 애노테이션 하나로 깔끔하게 해결해준다. 위에서 사용했던 직접 트랜잭션을 시작하고 롤백하는 코드를 다 날려도 된다.
@SpringBootTest
@Transactional
class ItemRepositoryTest {
@Autowired
ItemRepository itemRepository;
@AfterEach
void afterEach() {
//MemoryItemRepository 의 경우 제한적으로 사용
if (itemRepository instanceof MemoryItemRepository) {
((MemoryItemRepository) itemRepository).clearStore();
}
}
...
}
- 이렇게 클래스 레벨에 @Transactional 애노테이션 하나만 붙여주고 아까 위에서 작성했던 직접 트랜잭션을 시작하고 롤백하는 코드는 다 날려버렸다.
- 참고로 org.springframework.transaction.annotation.Transactional 패키지를 사용해야 한다.
- 이렇게 하기만 하면 끝이다. 이대로 테스트를 계속 실행해도 계속 통과한다. 자동으로 롤백이 된다.
원래, 메인 코드에서 @Transactional 애노테이션이 달리면 정상적으로 끝나면 커밋이 맞다. 근데 테스트 코드에서는 기본값이 롤백이다. 스프링이 우릴 위해 편리하게 사용할 수 있도록 이렇게 설계한 것이다.
정리하자면
- @Transactional은 테스트가 끝난 후 개발자가 직접 데이터를 삭제하지 않아도 되는 편리함을 제공한다.
- 테스트 실행 중에 데이터를 등록하고 중간에 테스트가 강제로 종료되어도 걱정이 없다. 이 경우, 트랜잭션 커밋을 하지 않기 때문에 데이터는 자동으로 롤백된다. (보통 데이터베이스 커넥션이 끊어지면 자동으로 롤백되어 버린다)
- 트랜잭션 범위 안에서 테스트를 진행하기 때문에 동시에 다른 테스트가 진행되어도 서로 영향을 주지 않는 장점이 있다.
- @Transactional 애노테이션 덕분에 아주 편리하게 다음 원칙을 지킬 수 있게 되었다.
- 테스트는 다른 테스트와 격리해야 한다.
- 테스트는 반복해서 실행할 수 있어야 한다.
테스트 시 강제로 커밋 - @Commit
@Transactional을 테스트에서 사용하면 테스트가 끝나면 바로 롤백되기 때문에 테스트 과정에서 저장한 모든 데이터가 사라진다. 당연히 이렇게 되는게 맞다. 그런데 가끔은 데이터베이스에 데이터가 잘 보관되었는지 최종 결과를 눈으로 확인하고 싶을때도 있다. 이럴때는 다음과 같이 @Commit 애노테이션을 클래스 또는 메서드에 붙이면 테스트 종료 후 롤백 대신 커밋이 호출된다.
@Test
@Commit
void save() {
//given
Item item = new Item("itemA", 10000, 10);
//when
Item savedItem = itemRepository.save(item);
//then
Item findItem = itemRepository.findById(item.getId()).get();
assertThat(findItem).isEqualTo(savedItem);
}
테스트 - 임베디드 모드 DB
테스트 케이스를 실행하기 위해서 별도의 데이터베이스를 설치하고, 운영하는 것은 상당히 번잡한 작업이다. 단순히 테스트를 검증할 용도로만 사용하기 때문에 테스트가 끝나면 데이터베이스와 데이터를 모두 삭제해도 된다. 더 나아가서 테스트가 끝나면 데이터베이스 자체를 제거해도 된다.
임베디드 모드
H2 데이터베이스는 자바로 개발되어 있고, JVM안에서 메모리 모드로 동작하는 특별한 기능을 제공한다. 그래서 애플리케이션을 실행할때 H2 데이터베이스도 해당 JVM 메모리에 포함해서 함께 실행할 수 있다. DB를 애플리케이션에 내장해서 함께 실행한다고 해서 임베디드 모드라 한다. 물론 애플리케이션이 종료되면 임베디드 모드로 동작하는 H2 데이터베이스도 함께 종료되고, 데이터도 모두 사라진다. 쉽게 이야기해서 애플리케이션에서 자바 메모리를 함께 사용하는 라이브러리처럼 동작하는 것이다.
이제 H2 데이터베이스를 임베디드 모드로 사용해보자.
임베디드 모드 직접 사용
임베디드 모드를 직접 사용하는 방법은 다음과 같다.
ItemServiceApplication
package hello.itemservice;
import hello.itemservice.config.*;
import hello.itemservice.repository.ItemRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Profile;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import javax.sql.DataSource;
@Slf4j
// @Import(MemoryConfig.class)
// @Import(JdbcTemplateV1Config.class)
// @Import(JdbcTemplateV2Config.class)
@Import(JdbcTemplateV3Config.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Bean
@Profile("local")
public TestDataInit testDataInit(ItemRepository itemRepository) {
return new TestDataInit(itemRepository);
}
@Bean
@Profile("test")
public DataSource dataSource() {
log.info("메모리 데이터베이스 초기화");
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName("org.h2.Driver");
dataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
dataSource.setUsername("sa");
dataSource.setPassword("");
return dataSource;
}
}
- 이 부분에서 주의깊게 볼 부분은 다음 부분이다.
@Bean
@Profile("test")
public DataSource dataSource() {
log.info("메모리 데이터베이스 초기화");
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName("org.h2.Driver");
dataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
dataSource.setUsername("sa");
dataSource.setPassword("");
return dataSource;
}
- @Profile("test") 애노테이션으로 현재 프로파일이 `test`인 경우에만 이 빈은 등록된다. 테스트 케이스에서만 이 데이터소스를 스프링 빈으로 등록해서 사용하겠다는 뜻이 된다.
- 이때 DataSource를 빈으로 등록하는데 URL 부분을 자세히 보면 `jdbc:h2:mem:...` 으로 되어 있다. mem은 memory를 의미한다. 즉, 메모리에 H2 데이터베이스를 띄우겠다는 의미이다. DB_CLOSE_DELAY=-1이 의미하는건, 임베디드 모드에서는 데이터베이스 커넥션 연결이 모두 끊어지면 데이터베이스도 종료되는데 그것을 방지하는 설정이다.
이제 테스트 케이스를 실행해보면 된다. 근데! 그 전에, 애플리케이션이 뜨는 시점에 메모리에 H2 데이터베이스를 띄우는데 우리가 원하는 테이블이 만들어질까? 아니다. 메모리에 띄운 H2 데이터베이스에서 사용할 테이블도 정의를 해줘야 한다. 그것은 `src/test/resources` 하위에 다음과 같은 파일로 정의할 수 있다.
schema.sql
drop table if exists item CASCADE;
create table item
(
id bigint generated by default as identity,
item_name varchar(10),
price integer,
quantity integer,
primary key (id)
);
- 내가 원하는 테이블은 딱 이렇게 생긴 item 테이블 하나이다.
- 이렇게 파일도 만들었으면 이제 테스트를 마음껏 실행할 수 있다.
테스트는 실행해보면 결과를 확인할 수 있을 것이고, 그건 그렇고, 이 작업 과연 개발자가 직접 해줘야할까?
테스트 - 스프링 부트와 임베디드 모드
스프링 부트는 개발자에게 정말 많은 편리함을 제공하는데, 임베디드 데이터베이스에 대한 설정도 기본으로 제공한다. 그래서 스프링 부트는 데이터베이스에 대한 별다른 설정이 없으면 기본으로 임베디드 데이터베이스를 사용한다. 이건 테스트나 메인 소스나 동일하다.
그래서 위에서 작업했던 이 부분 주석처리해보자.
ItemServiceApplication 일부분
/*@Bean
@Profile("test")
public DataSource dataSource() {
log.info("메모리 데이터베이스 초기화");
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName("org.h2.Driver");
dataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
dataSource.setUsername("sa");
dataSource.setPassword("");
return dataSource;
}*/
그리고 테스트 하위에 있는 application.yaml 파일에서도 datasource 관련 내용을 다 주석처리하자.
src/test/resources/application.yaml 일부분
# datasource:
# url: jdbc:h2:tcp://localhost/~/h2/db/springdbtest
# username: sa
# password:
이렇게 까지 하면, 데이터베이스에 대한 아무런 내용도 지금 없는 상태이다. 그러면 스프링 부트는 기본으로 임베디드 데이터베이스를 사용한다. (당연히, 테이블 정의에 대한 DDL 스크립트, 즉, schema.sql은 살려두어야 한다)
이 상태로 테스트 실행해보자. 정상적으로 잘 진행될 것이다.
결론
결국, 결론은 그냥 테스트할 때 데이터베이스와 연동이 필요하면 그냥 스프링 부트가 제공하는 임베디드 데이터베이스를 사용하면 된다. 데이터베이스를 별도로 만들 필요도 없고, 그저 테스트를 위한 것이기 때문에 완전히 안성맞춤이다. 그리고 테이블 정의에 대한 작업만 해주면 되는데 이 부분도 나중에 ORM을 사용하면 그마저도 필요없다. ORM으로 테이블 매핑을 다 해놓으면 그냥 그대로 동작하기 때문에.
'Spring + Database' 카테고리의 다른 글
[Renewal] MyBatis (4) | 2024.12.06 |
---|---|
[Renewal] JdbcTemplate (8) | 2024.12.06 |
[Renewal] 예외2, 스프링의 데이터접근 예외 추상화 (3) | 2024.12.05 |
[Renewal] 예외 (0) | 2024.12.05 |
[Renewal] 스프링의 트랜잭션 (0) | 2024.11.24 |