Use TestContainer on Runtime in Spring Boot
RECOMMEND POSTS BEFORE THIS
1. Improved Testcontainers Support in Spring Boot 3.1
최근 릴리즈 된 스프링 부트(spring boot) 3.1 버전에서 테스트 컨테이너(TestContainer)에 대한 지원이 향상되었습니다. 테스트 컨테이너는 어플리케이션에서 경량화 된 도커 컨테이너를 띄워서 사용할 수 있는 오픈 소스 프레임워크입니다.
Testcontainers is an open source framework for providing throwaway, lightweight instances of databases, message brokers, web browsers, or just about anything that can run in a Docker container.
테스트 컨테이너를 지원하는 기능에서 크게 바뀐 부분은 다음과 같습니다. 자세한 내용들을 살펴보겠습니다.
- 결합 테스트(integration test)에서 컨테이너 접속 정보 자동 설정
- 로컬 런타임 환경에서 테스트 컨테이너 활용
1.1. @ServiceConnection Annotation
결합 테스트에서 테스트 컨테이너를 사용하면 어플리케이션이 실행 중인 컨테이너에 접근하기 위한 정보가 필요합니다. 3.1 버전 이전에는 접근 정보를 설정하기 위한 별도 코드가 필요했습니다.
- @DynamicPropertySource 애너테이션과 DynamicPropertyRegistry 클래스를 사용합니다.
@SpringBootTest
@Testcontainers
class MyIntegrationTests {
@Container
static Neo4jContainer<?> neo4j = new Neo4jContainer<>("neo4j:5");
@Test
void myTest() {
// ...
}
@DynamicPropertySource
static void neo4jProperties(DynamicPropertyRegistry registry) {
registry.add("spring.neo4j.uri", neo4j::getBoltUrl);
}
}
- 코틀린(kotlin)의 경우 init 메소드와 시스템 프로퍼티 설정 코드를 사용할 수도 있습니다.
@Testcontainers
@AutoConfigureMockMvc
@SpringBootTest(
properties = [
"spring.datasource.url=jdbc:h2:mem:test",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa"
]
)
class RestControllerIT {
private final val redis = GenericContainer("redis:5.0.3-alpine")
.withExposedPorts(6379)
init {
redis.start()
System.setProperty("spring.data.redis.host", redis.host)
System.setProperty("spring.data.redis.port", redis.getMappedPort(6379).toString())
}
@Autowired
lateinit var userRepository: UserRepository
@Autowired
lateinit var sut: MockMvc
@BeforeEach
fun setup() {
userRepository.save(
UserEntity(
name = "Junhyunny",
favoritePosts = mutableListOf(1L, 2L)
)
)
}
@Test
fun saveUserEntityInSession() {
sut.perform(get("/users/1"))
.andExpect(status().isOk)
}
}
1.1.1. Get Connection Details
스프링 부트 3.1 버전부터 제공하는 @ServiceConnection 애너테이션을 사용하면 컨테이너 접근 정보를 획득하기 위한 코드가 필요 없습니다. 스프링 팀은 컨테이너 설정 초기화 문제를 다음과 같이 해결하였습니다.
- @ServiceConnection 애너테이션을 붙이면 컨테이너 객체가 생성되는 시점에 컨테이너 커넥션 정보를 관리하는 ConnectionDetails 인터페이스를 구현 객체가 생성됩니다.
- ConnectionDetails 구현체는 내부에서 해당 컨테이너 객체를 참조하고 있습니다.
- ConnectionDetails 구현체는 아래 이미지에서 JdbcContainerConnectionDetails 인스턴스입니다.
- 컨테이너 객체는 아래 이미지에서 JdbcDatabaseContainer 인스턴스입니다.
- 어플리케이션이 커넥션 정보를 ConnectionDetails 구현체에게 요청합니다.
- ConnectionDetails 구현체는 내부적으로 참조하고 있는 컨테이너 객체에게 접근 정보를 요청합니다.
1.1.2. Usage @ServiceConnection Annotation
@ServiceConnection 애너테이션을 사용하면 다음과 같이 코드를 변경할 수 있습니다.
- @DynamicPropertySource 애너테이션이 붙은 메소드가 필요하지 않습니다.
@SpringBootTest
@Testcontainers
class MyIntegrationTests {
@Container
@ServiceConnection
static Neo4jContainer<?> neo4j = new Neo4jContainer<>("neo4j:5");
@Test
void myTest() {
// ...
}
}
코틀린 코드도 다음과 같이 변경할 수 있습니다.
- 컨테이너 객체를 static 변수로 변경하고 @ServiceConnection 애너테이션을 추가합니다.
- init 메소드를 제거합니다.
@Testcontainers
@AutoConfigureMockMvc
@SpringBootTest(
properties = [
"spring.datasource.url=jdbc:h2:mem:test",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa"
]
)
class RestControllerIT {
companion object {
@Container
@ServiceConnection
private val redis = GenericContainer("redis:5.0.3-alpine")
.withExposedPorts(6379)
}
@Autowired
lateinit var userRepository: UserRepository
@Autowired
lateinit var sut: MockMvc
@BeforeEach
fun setup() {
userRepository.save(
UserEntity(
name = "Junhyunny",
favoritePosts = mutableListOf(1L, 2L)
)
)
}
@Test
fun saveUserEntityInSession() {
sut.perform(get("/users/1"))
.andExpect(status().isOk)
}
}
1.2. TestContainers at development time
로컬 개발 환경에서 테스트 컨테이너를 어플리케이션과 함께 실행할 수 있습니다. 로컬 개발 환경에서 겪는 다음과 같은 불편함들을 해소해줄 수 있는 좋은 기능이라고 생각됩니다.
- 로컬 환경에서만 사용하는 임베디드 데이터베이스
- 필요한 데이터베이스를 로컬 환경에 설치하는 작업은 번거롭기 때문에 H2 같은 임베디드 데이터베이스를 사용한다.
- 실제 프로덕션에서 사용하는 데이터베이스와 H2 데이터베이스의 초기화 SQL이나 마이그레이션 SQL 스크립트 문법이 다르다.
- 어플리케이션 코드는 JPA 같은 추상화 된 계층 덕분에 코드의 변경이 크게 없겠지만 실제로 실행되는 동작이 다를 수 있으므로 주의가 필요하다.
- 데이터베이스를 컨테이너를 통해 구축하더라도 컴퓨터를 재부팅하면 매번 재실행해야 한다.
- 세션이나 캐시를 위해 사용하는 레디스 스토리지
- 임베디드 레디스를 사용한다면 이를 위한 설정들과 프로파일이 필요하다.
- 로컬 환경에 레디스 서버를 구축한다면 설치하는 작업이 불편하다.
- 스토리지를 컨테이너를 통해 구축하더라도 컴퓨터를 재부팅하면 매번 재실행해야 한다.
프로젝트 테스트 컨텍스트에 다음과 같은 main 메소드를 작성하고 실행시키면 테스트 컨테이너들이 연결된 환경으로 어플리케이션을 실행할 수 있습니다.
- ActionInBlogApplication 클래스의 main 메소드를 실행합니다.
- TestActionInBlogApplication 클래스의 빈(bean)들을 함께 사용합니다.
- @TestConfiguration 애너테이션이 붙은 설정 빈입니다.
- 내부에 테스트 컨테이너를 빈으로 생성하는 코드가 필요합니다.
- 어플리케이션이 실행하면서 테스트 컨테이너의 접속 정보를 얻을 수 있도록 @ServiceConnection 애너테이션이 필요합니다.
package action.in.blog;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
@TestConfiguration(proxyBeanMethods = false)
public class TestActionInBlogApplication {
public static void main(String[] args) {
SpringApplication
.from(ActionInBlogApplication::main)
.with(TestActionInBlogApplication.class)
.run(args);
}
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest"));
}
}
2. Practice
간단하게 로컬 개발 환경을 구축해보겠습니다. 프로젝트 구조는 다음과 같습니다.
./
├── HELP.md
├── build.gradle
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle
└── src
├── main
│ ├── java
│ │ └── action
│ │ └── in
│ │ └── blog
│ │ ├── ActionInBlogApplication.java
│ │ ├── controller
│ │ │ └── UserController.java
│ │ ├── domain
│ │ │ └── User.java
│ │ └── repository
│ │ └── UserRepository.java
│ └── resources
│ └── application.yml
└── test
├── java
│ └── action
│ └── in
│ └── blog
│ ├── ActionInBlogApplicationTests.java
│ └── TestActionInBlogApplication.java
└── resources
├── application.yml
└── data.sql
2.1. application.yml
테스트 패키지에 위치한 설정 파일입니다. 어플리케이션 실행을 위한 main 메소드의 클래스가 테스트 패키지에 위치하기 때문에 해당 위치의 리소스를 사용합니다.
- 프로파일 설정을
test
로 지정합니다. - data.sql 파일을 사용해 스키마, 데이터를 초기화합니다.
spring:
profiles:
active: test
---
spring:
config:
activate:
on-profile: test
sql:
init:
mode: always
data-locations: classpath:data.sql
jpa:
defer-datasource-initialization: true
2.2. data.sql
해당 스키마, 데이터 초기화 SQL 스크립트도 테스트 패키지에 위치합니다.
create table if not exists tb_user (
id serial primary key,
name varchar(50)
);
insert into tb_user (name) values ('Junhyunny');
insert into tb_user (name) values ('Jua');
2.3. UserController Class
기능을 제공하는 코드는 메인 패키지에 위치합니다.
- 사용자 정보를 조회 후 반환합니다.
package action.in.blog.controller;
import action.in.blog.domain.User;
import action.in.blog.repository.UserRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class UserController {
private final UserRepository userRepository;
public UserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@GetMapping("/users")
public List<User> getUsers() {
return userRepository.findAll();
}
}
2.4. TestActionInBlogApplication Class
테스트 패키지에 위치한 TestActionInBlogApplication 클래스의 main 메소드를 실행합니다.
- main 메소드
- 어플리케이션 실행을 위해 ActionInBlogApplication 클래스의 main 메소드를 사용합니다.
- TestActionInBlogApplication 클래스 자신을 설정 빈으로 사용합니다.
- 내부에 정의된 빈들을 컨텍스트에 포함하여 실행합니다.
- postgresContainer 메소드
- @Bean 애너테이션을 붙혀 반환하는 객체를 빈으로 관리됩니다.
- @ServiceConnection 애너테이션을 붙혀 어플리케이션이 접속하기 위해 필요한 정보를 제공합니다.
package action.in.blog;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
@TestConfiguration(proxyBeanMethods = false)
public class TestActionInBlogApplication {
public static void main(String[] args) {
SpringApplication
.from(ActionInBlogApplication::main)
.with(TestActionInBlogApplication.class)
.run(args);
}
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest"));
}
}
Run Application with TestContainers
TestActionInBlogApplication 클래스의 main 메소드를 실행합니다.
- 테스트 컨테이너와 함께 어플리케이션이 실행됩니다.
> Task :TestActionInBlogApplication.main()
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.1.3)
2023-08-30T23:20:41.314+09:00 INFO 65213 --- [ main] action.in.blog.ActionInBlogApplication : Starting ActionInBlogApplication using Java 17.0.8.1 with PID 65213 (/Users/junhyunk/Desktop/action-in-blog/build/classes/java/main started by junhyunk in /Users/junhyunk/Desktop/action-in-blog)
2023-08-30T23:20:41.319+09:00 INFO 65213 --- [ main] action.in.blog.ActionInBlogApplication : The following 1 profile is active: "test"
2023-08-30T23:20:42.763+09:00 INFO 65213 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2023-08-30T23:20:42.909+09:00 INFO 65213 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 125 ms. Found 1 JPA repository interfaces.
2023-08-30T23:20:44.272+09:00 INFO 65213 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)
2023-08-30T23:20:44.295+09:00 INFO 65213 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2023-08-30T23:20:44.295+09:00 INFO 65213 --- [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.12]
2023-08-30T23:20:44.524+09:00 INFO 65213 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2023-08-30T23:20:44.526+09:00 INFO 65213 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 3094 ms
2023-08-30T23:20:44.708+09:00 INFO 65213 --- [ main] o.t.utility.ImageNameSubstitutor : Image name substitution will be performed by: DefaultImageNameSubstitutor (composite of 'ConfigurationFileImageNameSubstitutor' and 'PrefixingImageNameSubstitutor')
2023-08-30T23:20:45.158+09:00 INFO 65213 --- [ main] o.t.d.DockerClientProviderStrategy : Loaded org.testcontainers.dockerclient.UnixSocketClientProviderStrategy from ~/.testcontainers.properties, will try it first
2023-08-30T23:20:45.754+09:00 INFO 65213 --- [ main] o.t.d.DockerClientProviderStrategy : Found Docker environment with local Unix socket (unix:///var/run/docker.sock)
2023-08-30T23:20:45.757+09:00 INFO 65213 --- [ main] org.testcontainers.DockerClientFactory : Docker host IP address is localhost
2023-08-30T23:20:45.806+09:00 INFO 65213 --- [ main] org.testcontainers.DockerClientFactory : Connected to docker:
Server Version: 24.0.5
API Version: 1.43
Operating System: Docker Desktop
Total Memory: 7958 MB
2023-08-30T23:20:45.865+09:00 INFO 65213 --- [ main] tc.testcontainers/ryuk:0.5.1 : Creating container for image: testcontainers/ryuk:0.5.1
2023-08-30T23:20:47.147+09:00 INFO 65213 --- [ main] tc.testcontainers/ryuk:0.5.1 : Container testcontainers/ryuk:0.5.1 is starting: 3d818ab82cdc8ebca9e366996268914d15df82c20c14b9dde30f792f0123bd86
2023-08-30T23:20:47.911+09:00 INFO 65213 --- [ main] tc.testcontainers/ryuk:0.5.1 : Container testcontainers/ryuk:0.5.1 started in PT2.087009S
2023-08-30T23:20:47.939+09:00 INFO 65213 --- [ main] o.t.utility.RyukResourceReaper : Ryuk started - will monitor and terminate Testcontainers containers on JVM exit
2023-08-30T23:20:47.939+09:00 INFO 65213 --- [ main] org.testcontainers.DockerClientFactory : Checking the system...
2023-08-30T23:20:47.940+09:00 INFO 65213 --- [ main] org.testcontainers.DockerClientFactory : ✔︎ Docker server version should be at least 1.6.0
2023-08-30T23:20:47.942+09:00 INFO 65213 --- [ main] tc.postgres:latest : Creating container for image: postgres:latest
2023-08-30T23:20:48.045+09:00 INFO 65213 --- [ main] tc.postgres:latest : Container postgres:latest is starting: bc969ada5ef373384f522ebc05cd552b33c516c13ed3b1c147f11482f715ef99
2023-08-30T23:20:50.908+09:00 INFO 65213 --- [ main] tc.postgres:latest : Container postgres:latest started in PT2.967174S
2023-08-30T23:20:50.910+09:00 INFO 65213 --- [ main] tc.postgres:latest : Container is started (JDBC URL: jdbc:postgresql://localhost:54940/test?loggerLevel=OFF)
2023-08-30T23:20:51.055+09:00 INFO 65213 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2023-08-30T23:20:51.388+09:00 INFO 65213 --- [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@2cd3fc29
2023-08-30T23:20:51.391+09:00 INFO 65213 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
2023-08-30T23:20:51.481+09:00 INFO 65213 --- [ main] o.hibernate.jpa.internal.util.LogHelper : HHH000204: Processing PersistenceUnitInfo [name: default]
2023-08-30T23:20:51.606+09:00 INFO 65213 --- [ main] org.hibernate.Version : HHH000412: Hibernate ORM core version 6.2.7.Final
2023-08-30T23:20:51.610+09:00 INFO 65213 --- [ main] org.hibernate.cfg.Environment : HHH000406: Using bytecode reflection optimizer
2023-08-30T23:20:51.916+09:00 INFO 65213 --- [ main] o.h.b.i.BytecodeProviderInitiator : HHH000021: Bytecode provider name : bytebuddy
2023-08-30T23:20:52.225+09:00 INFO 65213 --- [ main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer
2023-08-30T23:20:52.690+09:00 INFO 65213 --- [ main] o.h.b.i.BytecodeProviderInitiator : HHH000021: Bytecode provider name : bytebuddy
2023-08-30T23:20:53.791+09:00 INFO 65213 --- [ main] o.h.e.t.j.p.i.JtaPlatformInitiator : HHH000490: Using JtaPlatform implementation: [org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform]
2023-08-30T23:20:53.797+09:00 INFO 65213 --- [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2023-08-30T23:20:54.303+09:00 WARN 65213 --- [ main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2023-08-30T23:20:55.011+09:00 INFO 65213 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
2023-08-30T23:20:55.028+09:00 INFO 65213 --- [ main] action.in.blog.ActionInBlogApplication : Started ActionInBlogApplication in 14.655 seconds (process running for 15.75)
Test Result
- 테스트 패키지에 위치한 어플리케이션을 실행했지만, 메인 패키지에 위치한 UserController 클래스의 기능이 정상적으로 수행됩니다.
- Postgres 테스트 컨테이너 데이터베이스에 초기화 된 데이터들이 정상적으로 조회됩니다.
$ curl http://localhost:8080/users | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 51 0 51 0 0 144 0 --:--:-- --:--:-- --:--:-- 147
[
{
"id": 1,
"name": "Junhyunny"
},
{
"id": 2,
"name": "Jua"
}
]
댓글남기기