DB와 연동하여 데이터를 조작하는 역할을 담당하는 클래스를 테스트 해야할 경우, 운영 환경과 같은 DB 종류를 사용하는 것이 좋다. Isolation이나, Propagation 등은 DB마다 정책이 다르기 때문에, 테스트 환경에서는 문제가 없었지만 운영 환경에서 오류를 범할 수 있기 때문이다.
이럴 경우 실제 DB 서버를 띄우고 테스트를 해야하는 경우가 많다. 만약 JPA를 사용하고 DB 서버가 띄어져 있지 않다면, 당연히 Spring 컨테이너를 초기화 하는 도중에 문제가 발생할 것이다.
그렇다면 테스트를 하기 위해 항상 DB 서버를 띄어놓아야 한다는 소리인데, RDS 같은 클라우드 서비스를 이용하는 것이 아닌 이상, 당연히 테스트 진행 환경에 따라 테스트가 실패할 수도 있다.
이럴 경우 Docker의 Container를 통해서 Test를 수행할 때 DB서버를 띄울 수 있도록 TestContainers를 사용할 수 있다.
상세 사용법은 TestContainers의 공식 문서를 참조하자.
testImplementation 'org.testcontainers:junit-jupiter:1.17.5'
testImplementation "org.testcontainers:mysql:1.17.5"
JUnit을 사용하기 때문에 junit-jupiter 라이브러리를 추가해준다. 그리고 TestContainers의 공식 문서에 가 보면, 지원하는 모듈 목록을 볼 수 있는데 MySQL 모듈을 사용할 것이기 때문에 이를 등록해주자.
공식 문서를 확인해보면, JDBC URL로 연결하기 위해 설정하는 방법이 나와있다. 이를 적용시켜보자.
spring:
datasource:
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
url: jdbc:tc:mysql:///member-test
원래는 접속할 DB의 Host와 Port도 명시를 해주어야 하지만, 컨테이너가 띄어질 때마다 항상 다른 Host와 Port를 가질 수 있기 때문에 TestContainers가 이를 알아서 처리해준다. 따라서 jdbc:tc
로 시작하면 되고, 뒤에 사용할 데이터베이스 이름만 명시해주면 된다.
그리고 Spring Boot를 사용할 경우에는 driver-class-name에 org.testcontainers.jdbc.ContainerDatabaseDriver
를 작성해야 한다고 나와있다.
@SpringBootTest
@ActiveProfiles("test")
@Transactional
public class ContainerTest {
@Autowired
MemberRepository memberRepository;
@Autowired
EntityManager em;
static MySQLContainer mySQLContainer = new MySQLContainer("mysql");
@BeforeAll
static void beforeAll() {
mySQLContainer.start();
}
@BeforeEach
public void beforeEach() {
memberRepository.deleteAll();
}
@AfterAll
static void afterAll() {
mySQLContainer.stop();
}
@Test
void test() {
Member member = Member.builder()
.name("name")
.build();
memberRepository.save(member);
em.flush();
em.clear();
Member findMember = memberRepository.findById(member.getId()).get();
assertThat(findMember.getId()).isEqualTo(member.getId());
}
}
TestContainers의 MySQL 모듈 의존성을 추가했기 때문에 MySQLContainer 객체를 사용할 수 있다. 이를 사용할 Image 이름을 명시하여 생성해주면 된다. static 필드로 선언하지 않으면, 테스트 메서드마다 항상 컨테이너를 생성하기 때문에 이를 정적으로 만드는 것이 좋다.
TestContainer를 테스트 메서드들이 시작하기 전에 start 시키고, 모든 테스트가 종료되면 stop 할 수 있도록 @BeforeAll, @AfterAll을 정의해 주었다. 그리고 테스트 간의 데이터가 공유되면 문제가 발생할 수 있기 때문에 @BeforeEach로 이를 그리고 실제로 테스트를 진행해보면 정상적으로 DB와 연동이 되는 것을 볼 수 있다.
다만 컨테이너를 띄워야 하기 때문에 테스트 속도는 엄청나게 느려진다. 이 점이 단점이다.
위의 방법은 사실 테스트 시작 시 start, 종료 시 stop을 해야 하는 불편함이 존재한다. 이를 해결해 주는 것이 @TestContainers, @Container 어노테이션이다.
@SpringBootTest
@ActiveProfiles("test")
@Testcontainers
@Transactional
public class ContainerTest {
@Autowired
MemberRepository memberRepository;
@Autowired
EntityManager em;
@Container
static MySQLContainer mySQLContainer = new MySQLContainer("mysql");
@BeforeEach
public void beforeEach() {
memberRepository.deleteAll();
}
...
}
이러면 Test 시작 시 컨테이너를 띄우고, Test가 종료되면 알아서 컨테이너를 종료시킨다.
MySQLContainer의 경우에는 TestContainers에서 지원하기 때문에 손쉽게 구성이 가능하지만, 만약 지원하지 않는 모듈에 대해 컨테이너를 만들어야 할 경우에는 GenericContainer를 사용하면 된다.
@Container
static MySQLContainer mySQLContainer = new MySQLContainer("mysql")
.withDatabaseName("membertest");
@Container
static GenericContainer genericContainer = new GenericContainer("mysql")
.withExposedPorts(20000)
.withEnv("MYSQL_DB", "membertest");
GenericContainer를 통해 이미지 명을 명시해 준다면 해당 이미지로 컨테이너를 생성하게 된다. 다만 MySQLContainer는 MySQL 이미지를 사용함을 알기 때문에 withDatabaseName과 같은 특화된 메서드를 이용할 수 있지만, GenericContainer는 withEnv와 같이 도커 컨테이너를 띄울 때 환경 변수를 넘겨주는 방식으로 적용해야 한다.
이 외에도 TestContainer는 볼륨 마운팅, 명령어 실행 등등의 도커에서 사용할 수 있는 여러 기능들을 제공해주고 있다. 자바 코드로 컨테이너의 정보도 불러올 수 있다. 이는 필요한 경우 공식 문서를 참조하자.
Test Container의 정보 들을 Test시에 사용해야 할 일이 있을 경우 아래와 같이 적용하면 된다.
@SpringBootTest
@ActiveProfiles("test")
@Testcontainers
@Transactional
@ContextConfiguration(initializers = ContainerTest.ContainerPropertyInitializer.class)
public class ContainerTest {
@Autowired
Environment env;
@Container
static MySQLContainer mySQLContainer = new MySQLContainer("mysql")
.withDatabaseName("membertest");
static class ContainerPropertyInitializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
TestPropertyValues.of("container.port=" + mySQLContainer.getMappedPort(10000))
.applyTo(applicationContext.getEnvironment());
}
}
@Test
void propertyTest() {
String property = env.getProperty("container.port");
System.out.println("property = " + property);
}
}
ApplicationContextInitializer는 Spring의 ConfigurableApplicationContext를 초기화 할 때 실행하는 메서드가 정의되어 있는 콜백 인터페이스이다. 이를 구현하여 initailize 메서드를 재정의한다면 프로퍼티를 추가할 수 있다.
우선 간단하게 MySQL Container의 Port 정보를 꺼내서 Environment에 넣어주는 코드를 작성했다. 이제 이렇게 정의한 ContainerPropertyInitializer 구현체를 @ContextConfiguration 어노테이션을 통해 등록해주면 된다.
@Container
static DockerComposeContainer composeContainer =
new DockerComposeContainer(new File("src/test/resources/docker-compose.yml"))
.withExposedService("member-db", 10000);
DockerComposeContainer를 사용하면 간단하게 compose 파일로 여러 개의 컨테이너를 띄울 수 있다.