JdbcTemplate, JPA 같은 데이터 접근 기술을 사용할 때 테스트 코드를 어떻게 짜고, 테스트를 어떻게 진행해야 하는 지 알아본다.
데이터베이스 연동 테스트
테스트 케이스는 src/test에 있다. test를 실행하면 src/test에 있는 application.properties 파일이 우선순위를 갖게 된다.
즉, src/main 아래의 application.properties에 작성했던
1
2
spring.datasource.url= ~
spring.datasource.username=sa
이러한 정보가 src/test 아래의 application.properties에도 있어야 한다.
Test
1
2
3
4
5
6
7
8
@SpringBootTest
class ItemRepositoryTest {
@Autowired
ItemRepository itemRepository;
/...
}
- @SpringBootTest를 사용하면 이 애노테이션이 @SpringBootApplication 애노테이션을 찾아 설정으로 사용한다.
1
2
3
4
@Slf4j
@Import(JdbcTemplateV3Config.class)
@SpringBootApplication(scanBasePackages="hello.itemservice.web")
public class ItemServiceApplication {}
- @SpringBootTest가 위의 이 부분을 찾게 되고, 해당 부분은@Import(JdbcTemplateV3Config)를 사용하는데 테스트도 이를 적용해 JdbcTemplate을 사용해 실제 데이터베이스를 호출하게 된다.
- test의 itemRepository가 JdbcTemplateItemRepository를 사용하게 된 것이다.
Test를 실행해보면 잘 작동하는 것을 알 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@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());
}
이렇게 save()와 update() 메서드는 테스트에 성공한다.
그런데 아래의 조건별 검색 기능 테스트는 실패하게 된다.
문제 발생
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@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);
}
참고로 테스트를 실행할 때 초기 데이터를 추가해주는 TestDataInit()이 실행되지는 않는다. 프로필이 local일 때만 실행되는데 test 케이스의 경우 프로필이 test이기 때문이다.
DB를 확인해보면 문제를 알 수 있다.
테스트 코드에서의 의도는 데이터 3개를 넣어두고, 조건에 따라 해당 데이터들이 알맞게 조회되는지 확인하는 것이다.
itemA-1, itemA-2, itemB-1 이 세 가지 데이터만 들어있어야 조건에 맞는 데이터들이 테스트코드의 의도대로 나올텐데, 기존 데이터들이 데이터베이스에 그대로 들어있어 조건에 맞는 아이템의 개수가 훨씬 많이 조회되어 테스트가 실패하는 것이다.
이 문제를 해결하려면 로컬에서 사용하는 애플리케이션 서버와 테스트에서 같은 데이터베이스를 사용하지 않으면 된다. 방법을 알아보자.
테스트에서의 데이터베이스 분리
테스트를 다른 환경과 철저하게 분리시켜야 한다.
가장 간단한 방법은 테스트 전용 데이터베이스를 별도로 운영하면 된다.
지금 사용하고 있는 H2 데이터베이스를 예로 들면 데이터베이스를 2개 열어두고 URL을 다르게 하면 된다.
1
2
3
4
5
##새 db
spring.datasource.url=jdbc:h2:tcp://localhos2/~/testcase
##기존 db
spring.datasource.url=jdbc:h2:tcp://localhos2/~/test2
main, local 에서는 test2 DB를 사용하고, test에서는 testcase DB를 사용하는 것이다.
테스트에 성공하는 것을 볼 수 있다.
그러나 DB가 이렇게 초기화된 상태에서 테스트를 하면 성공하지만, 테스트를 다시 실행하면 testcase DB로 DB를 분리했더라도 기존에 테스트하면서 추가된 데이터가 삭제되지 않았기 때문에 테스트에 실패한다.
테스트는 아래 원칙을 지켜야 한다.
- 테스트는 다른 테스트와 격리해야 한다.
- 테스트는 반복해서 실행할 수 있어야 한다.
DB를 격리해 해결했지만 반복해서 실행할 수는 없었다. 기존에 테스트하면서 추가된 데이터를 항상 지워주어야 한다.
테스트가 끝날때마다 DELETE SQL을 사용하는 것은 해결책이 되지 않는다. 상황에 따라 테스트 실행 도중 예외가 발생한다면 DELETE SQL 호출이 안되고 테스트가 종료될 수 있다.
데이터를 계속 지워주는 부분은 어떻게 해결할까?
데이터 롤백
테스트를 반복해서 실행할 수 있으려면 먼저 테스트하면서 추가된 데이터를 지워주어야 한다.
이 부분은 트랜잭션이 힌트가 된다.
테스트가 끝나고 트랜잭션을 강제로 롤백해버리면 된다.
테스트를 하면서 데이터를 이미 저장했는데, 중간에 테스트가 실패해서 롤백을 호출하지 못하면 DELETE SQL하고 같은거 아니냐 라고 생각할 수 있다.
그러나 트랜잭션을 이용하면 롤백을 호출하지 못해도, 애초에 커밋을 하지 않았기 때문에 해당 데이터가 데이터베이스에 반영되지 않는다.
각 테스트 실행 전 트랜잭션을 시작하고, 테스트 실행 직후 트랜잭션을 롤백하면 된다.
적용
test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@SpringBootTest
class ItemRepositoryTest {
@Autowired
ItemRepository itemRepository;
@Autowired
PlatformTransactionManager transactionManager;
TransactionStatus status;
@BeforeEach
void beforeEach() {
//트랜잭션 시작
status = transactionManager.getTransaction(new DefaultTransactionDefinition());
}
@AfterEach
void afterEach() {
//트랜잭션 롤백
transactionManager.rollback(status);
}
/...
}
- TransactionManager를 사용해 트랜잭션을 시작하고 롤백시켜주면 된다.
- 스프링 부트는 자동으로 적절한 트랜잭션 매니저를 스프링 빈으로 등록해주어 @Autowired 가능한 것이다. (복습 확인)
이제 test를 실행하고 db를 확인해봐도 데이터가 추가되지 않은 모습을 볼 수 있다. 여러 번 반복 실행해도 문제 없다.
커밋이 되지 않아도 테스트 중에는 데이터가 DB에 임시로 들어가 있는 상태이기 때문에 테스트에는 전혀 문제가 되지 않는다.
하지만 애플리케이션 코드에서도 번거로워서 @Transactional로 대체했는데 테스트에서도 계속 @BeforeEach, @AfterEach를 사용하며 하나하나 해주기에는 번거롭다.
테스트에서도 @Transactional 애노테이션을 적용할 수 있다.
@Transactional - 테스트
테스트 코드에서 트랜잭션 매니저를 통해 트랜잭션을 시작하고 롤백하기 번거롭다. 테스트 코드에서도 @Transactional을 사용할 수 있다.
단순히 테스트 클래스 위에 @Transactional 애노테이션을 붙여주면 된다.
1
2
3
4
@Transactional
@SpringBootTest
class ItemRepositoryTest {
}
이렇게 하고 기존 트랜잭션 매니저 사용 부분을 지워준 후 테스트를 실행해보면 정상 작동되는 것을 알 수 있다.
단, 이 애노테이션은 애플리케이션 서버에서의 @Transactional과 기능이 다르다.
테스트에서의 @Transactional
기존 애플리케이션 단계에서의 @Transactional은 로직이 성공적으로 수행되면 커밋하고, 중간에 오류가 발생하면 롤백시키는 작업을 대신 해주는 역할이었다.
이 애노테이션을 테스트에서 사용하면 스프링은 테스트를 트랜잭션 안에서 실행하고 테스트가 끝나면 트랜잭션을 자동으로 롤백시켜준다.
findItems()를 통해 작동 과정을 알아본다.
- 테스트에 @Transactional 애노테이션이 있으면 우선 트랜잭션을 시작한다.
- 테스트 로직을 실행한다. 테스트가 끝날 때 까지 모든 로직은 트랜잭션 안에서 수행된다.
- 트랜잭션은 기본적으로 전파되기 때문에, 리포지토리에서 사용하는 JdbcTemplate도 같은 트랜잭션을 사용한다.
- 테스트 실행 중 INSERT SQL을 사용해 item1,2,3을 DB에 저장한다.
- 이 작업은 테스트가 리포지토리를 호출하고 리포지토리는 JdbcTemplate을 사용해 저장한다.
- 검증을 위해 SELECT SQL로 데이터를 조회한다.
- SELECT SQL도 같은 트랜잭션을 사용하기 때문에 저장한 데이터를 조회할 수 있다. 다른 트랜잭션에서는 확인할 수 없다.
- @Transactional이 테스트에 있으면 테스트가 끝날 때 트랜잭션을 강제로 롤백한다. 롤백에 의해 앞서 추가한 item 1,2,3이 제거된다.
@Transactional 덕분에 테스트는 다른 테스트와 격리되어야 하고, 반복해서 실행할 수 있어야 한다는 원칙을 지킬 수 있다.
참고
만약 롤백하지 않고 강제로 커밋하고 싶다면 해당 메서드나 클래스에 @Commit 애노테이션을 붙여주면 된다. 테스트 종료 후 롤백대신 커밋이 호출된다. 혹은 @Rollback(false) 를 붙여주면 된다.
임베디드 모드 DB
테스트 케이스를 실행하기 위해 위에서는 별도의 DB를 열고, 운영했다. 이는 상당히 번거롭다.
단순히 테스트 검증 용도이기 때문에 테스트가 끝나면 데이터베이스의 데이터를 모두 삭제해도 되고, 더 나아가 데이터베이스 자체를 제거해도 무방하다.
임베디드 모드
H2 데이터베이스는 자바로 개발되어 있고, JVM 안에서 메모리 모드로 동작하는 특별한 기능을 제공한다. 그래서 애플리케이션을 실행할 때 H2 데이터베이스도 해당 JVM 메모리에 포함해 함께 실행할 수 있다.
이를 임베디드 모드라고 한다.
애플리케이션이 종료되면 임베디드 모드로 동작하는 H2 데이터베이스도 함께 종료되고, 데이터도 모두 사라진다.
임베디드 모드 DB 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Import(JdbcTemplateV3Config.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
@Slf4j
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:db;DB_CLOSE_DELAY=-1");
dataSource.setUsername("sa");
dataSource.setPassword("");
return dataSource;
}
}
Application에 dataSource()를 추가해준다.
- @Profile(“test”)
- 프로필이 test인 경우 데이터소스를 스프링 빈으로 등록.
- 테스트 케이스에서만 이 데이터소스를 빈으로 등록해 사용한다.
- dataSource()
- 이 데이터소스를 사용하면 메모리 DB를 사용할 수 있다.
- jdbc:h2:mem:db
- 이 부분이 핵심이다. 데이터소스를 만들 때 URL을 이렇게 세팅하면 임베디드 모드로 동작하는 H2 데이터베이스를 사용할 수 있다.
- DB_CLOSE_DELAY=-1
- 임베디드 모드에서는 데이터베이스 커넥션 연결이 모두 끊어지면 데이터베이스도 종료되는데 그것을 방지하는 설정이다.
이렇게 하고 테스트를 실행하면 된다. 메모리에서 실행되기 때문에 실행되고 있는 H2 데이터베이스 서버는 종료해도 된다.
단, 현재 위와 같이 해놓으면 item 테이블이 없다. 그냥 아무것도 없는 생 데이터베이스인 것이다. 수동으로 테이블을 생성할 수도 있지만 스프링 부트는 이러한 문제를 해결하는 기능을 제공해준다.
스프링부트 - SQL 스크립트를 이용한 데이터베이스 초기화
메모리 DB는 애플리케이션이 종료될 때 함께 사라지기 때문에, 애플리케이션 실행 시점에 데이터베이스 테이블도 새로 만들어주어야 한다.
직접 테이블을 생성하는 DDL을 호출해도 되지만, 매우 번거롭다.
스프링부트가 제공하는 기능을 보자.
/test/resource/ 에 schema.sql 파일명의 파일을 추가한다. 파일명이 정확해야 한다.
1
2
3
4
5
6
7
8
9
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)
);
그리고 테이블을 생성해주는 해당 파일에 SQL문을 넣어주면 된다.
스프링부트와 임베디드 모드 DB
위의 임베디드 모드 DB를 하나하나 설정하는 것도 사실 번거롭다.
스프링부트가 임베디드 데이터베이스에 대한 설정도 기본으로 제공한다.
- 기존에 Application에서 빈으로 등록했던 DataSource 세팅을 지운다.
- 기존에 Application.properties에서 datasource.url 등으로 제공했던 정보도 지운다.
참고로 테이블 생성해주는 SQL 스크립트 파일은 지우면 안된다.
이렇게 데이터베이스에 접근하는 모든 설정 정보가 사라져 별다른 정보가 없으면 스프링 부트는 임베디드 모드로 접근하는 데이터소스를 만들어 제공한다.
실행해보면 정상적으로 테스트가 성공하는 것을 볼 수 있다.
즉 아무런 설정 정보가 없다면 임베디드 DB(메모리 DB)를 알아서 만들어주고 = dataSource를 만들어 제공하고, 거기서 테스트할 수 있도록 동작한다.
@Transactional은 붙여야 한다. 테스트를 클래스 단위로 한 번에 실행하면 save()에서 저장한 데이터를 롤백하고 findItem()을 실행해야 하는데 그렇지 않기 때문이다. @Transactional을 지우고 findItem()을 따로 실행하는 것은 성공한다.
결국 알아보는 과정은 길었지만, 최종적으로 보면 DB기술을 테스트 할 때 DB와 연동하는 테스트를 하기 위해서는 @Transactional만 넣어주면 된다는 것을 알 수 있다.