
실무에서 테스트 코드를 작성하는 것은 선택이 아닌 필수가 되어가고 있다.
테스트 코드의 중요성이 높아지면서, 백엔드 개발자들은 DB를 연동한 테스트를 어떻게 할 것인지에 대해 많은 고민을 해왔다.
그러나 기존의 인메모리 데이터베이스(H2 등)나 로컬 DB를 사용하는 방식은 운영 환경과 차이가 발생하거나, 테스트 간 데이터 충돌이 발생하는 문제점을 가지고 있었다.
이러한 문제를 해결하기 위해 Testcontainers가 등장했다. Testcontainers는 Docker 컨테이너를 활용하여 테스트 환경에서 실제 운영과 유사한 DB나 외부 서비스를 실행할 수 있도록 지원하는 라이브러리다. 이를 통해 테스트의 신뢰성을 높이고, CI/CD 환경에서도 일관된 테스트 환경을 유지할 수 있다.
이번 글에서는 Testcontainers가 무엇인지, 왜 필요한지, 그리고 Spring Boot 환경에서 어떻게 활용할 수 있는지에 대해 자세히 알아본다.
Docker 기반 동작
테스트 격리
유연성
실제 환경과 동일한 환경으로 테스트 가능
테스트 종료 후 컨테이너 자동 종료
테스트 속도
@Testcontainers와 static 컨테이너를 활용하면 테스트 실행 간 컨테이너를 재사용할 수 있어 속도 개선 가능로컬 개발 환경에서 인메모리 DB로 테스트하고, Production 환경에서만 TestContainers를 적용하는 전략 고려Docker 사용 강제로 인한 추가 리소스 필요
테스트를 실행하는 서버에 반드시 Docker 설치 강제✅Docker 컨테이너 기반으로 동작
✅실제 Production 환경과 유사하게 테스트 가능
✅자동 리소스 정리 지원
🚨테스트 속도 느림
🚨Docker 설치 강제됨
Jenkins 연동 -> TestContainers 적용 시, Jenkins 환경에서 Docker 실행 가능한 환경이어야 합니다.
ex) 젠킨스 컨테이너 실행 시, host의 /var/run/docker.sock 볼륨 설정 및 호스트(EC2) Docker 설치
로컬에서 TestContainers 구축 -> docker desktop 실행 후 진행
build.gradle📜
testImplementation 'org.testcontainers:mysql:1.20.5'
testImplementation 'org.testcontainers:testcontainers:1.20.5'
testImplementation 'org.testcontainers:junit-jupiter:1.20.5'
DataBaseConnectionSupport.class📜
✅ container 재사용을 위해, @Testcontainers와 static 컨테이너 설정
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
@ActiveProfiles("test") // 필요 시
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public abstract class DataBaseConnectionSupport {
protected static final MySQLContainer<?> mysqlContainer;
static {
mysqlContainer = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("db_name")
.withUsername("root")
.withPassword("password")
.withEnv("DOCKER_HOST", "unix:///var/run/docker.sock")
.withNetworkAliases("beauty_care");
mysqlContainer.start();
}
@DynamicPropertySource
public static void overrideProps(DynamicPropertyRegistry dynamicPropertyRegistry) {
dynamicPropertyRegistry.add("spring.datasource.url", mysqlContainer::getJdbcUrl);
dynamicPropertyRegistry.add("spring.datasource.username", mysqlContainer::getUsername);
dynamicPropertyRegistry.add("spring.datasource.password", mysqlContainer::getPassword);
}
}
MemberRepositoryTest.class📜
@DataJpaTest
class MemberRepositoryTest extends DataBaseConnectionSupport {
@Autowired
private MemberRepository repository;
@DisplayName("로그인 아이디와 일치하는 멤버를 조회한다.")
@Test
void findByLoginIdAndIsUseIsTrue() {
// given
createMember();
// when
Member findMember = repository.findByLoginIdAndIsUseIsTrue("user1")
.orElseThrow(() -> new RequestInvalidException(Errors.ANONYMOUS_USER));
// then
assertThat(findMember)
.extracting("name", "role", "loginId")
.containsExactly("user1", Role.USER.getValue(), "user1");
}
private void createMember() {
Member member = Member.builder()
.name("user1")
.role(Role.USER)
.loginId("user1")
.password("1234")
.build();
repository.save(member);
}
}
테스트 실행 시, 아래와 같이 도커 컨테이너가 실행되는 로그가 나타나고, docker desktop에 TestContainers 관련 컨테이너들이 실행된 것을 확인할 수 있다.
11:51:36.269 [main] INFO tc.testcontainers/ryuk:0.11.0 -- Creating container for image: testcontainers/ryuk:0.11.0
11:51:36.439 [main] INFO org.testcontainers.utility.RegistryAuthLocator -- Credential helper/store (docker-credential-desktop) does not have credentials for https://index.docker.io/v1/
11:51:36.600 [main] INFO tc.testcontainers/ryuk:0.11.0 -- Container testcontainers/ryuk:0.11.0 is starting: ef24c933849e7850104bf84a7ddf53f8223cd58eba9f1a6faf06175208ebbcd9
11:51:37.169 [main] INFO tc.testcontainers/ryuk:0.11.0 -- Container testcontainers/ryuk:0.11.0 started in PT0.8999913S
11:51:37.173 [main] INFO org.testcontainers.utility.RyukResourceReaper -- Ryuk started - will monitor and terminate Testcontainers containers on JVM exit
11:51:37.174 [main] INFO org.testcontainers.DockerClientFactory -- Checking the system...
11:51:37.174 [main] INFO org.testcontainers.DockerClientFactory -- ✔︎ Docker server version should be at least 1.6.0
11:51:37.179 [main] INFO tc.mysql:8.0 -- Creating container for image: mysql:8.0
11:51:37.365 [main] INFO tc.mysql:8.0 -- Container mysql:8.0 is starting: c4f90b4b8808105414cb2a9800e616b09e069887029d70cfffb6b5428a2b8083
11:51:37.786 [main] INFO tc.mysql:8.0 -- Waiting for database connection to become available at jdbc:mysql://localhost:63567/beauty_care using query 'SELECT 1'
11:51:58.696 [main] INFO tc.mysql:8.0 -- Container mysql:8.0 started in PT21.5162626S
최초 한번만 컨테이너 실행 로그가 나오고, 나머지 테스트에서는 관련 로그가 안나오는 것을 확인할 수 있다.
2025-03-12T11:56:57.599+09:00 INFO 28204 --- [beauty-care] [ main] t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.project.beauty_care.domain.member.MemberRepositoryTest]: MemberRepositoryTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
2025-03-12T11:56:57.642+09:00 INFO 28204 --- [beauty-care] [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.project.beauty_care.BeautyCareApplication for test class com.project.beauty_care.domain.member.MemberRepositoryTest
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.4.2)
2025-03-12T11:56:57.722+09:00 INFO 28204 --- [beauty-care] [ main] c.p.b.d.member.MemberRepositoryTest : Starting MemberRepositoryTest using Java 21.0.4 with PID 28204 (started by sng49 in C:\IdeaProjects\beauty-care)
2025-03-12T11:56:57.722+09:00 INFO 28204 --- [beauty-care] [ main] c.p.b.d.member.MemberRepositoryTest : The following 1 profile is active: "test"
2025-03-12T11:56:57.955+09:00 INFO 28204 --- [beauty-care] [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2025-03-12T11:56:58.005+09:00 INFO 28204 --- [beauty-care] [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 51 ms. Found 1 JPA repository interface.
2025-03-12T11:56:58.096+09:00 INFO 28204 --- [beauty-care] [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-8 - Starting...
2025-03-12T11:56:58.158+09:00 INFO 28204 --- [beauty-care] [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-8 - Added connection com.mysql.cj.jdbc.ConnectionImpl@2de55285
2025-03-12T11:56:58.160+09:00 INFO 28204 --- [beauty-care] [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-8 - Start completed.
2025-03-12T11:56:58.174+09:00 INFO 28204 --- [beauty-care] [ main] o.hibernate.jpa.internal.util.LogHelper : HHH000204: Processing PersistenceUnitInfo [name: default]
2025-03-12T11:56:58.189+09:00 INFO 28204 --- [beauty-care] [ main] o.h.c.internal.RegionFactoryInitiator : HHH000026: Second-level cache disabled
2025-03-12T11:56:58.206+09:00 INFO 28204 --- [beauty-care] [ main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer
2025-03-12T11:56:58.206+09:00 WARN 28204 --- [beauty-care] [ main] org.hibernate.orm.deprecation : HHH90000025: MySQL8Dialect does not need to be specified explicitly using 'hibernate.dialect' (remove the property setting and it will be selected by default)
2025-03-12T11:56:58.206+09:00 WARN 28204 --- [beauty-care] [ main] org.hibernate.orm.deprecation : HHH90000026: MySQL8Dialect has been deprecated; use org.hibernate.dialect.MySQLDialect instead
2025-03-12T11:56:58.206+09:00 INFO 28204 --- [beauty-care] [ main] org.hibernate.orm.connections.pooling : HHH10001005: Database info:
Database JDBC URL [Connecting through datasource 'HikariDataSource (HikariPool-8)']
Database driver: undefined/unknown
Database version: 8.0
Autocommit mode: undefined/unknown
Isolation level: undefined/unknown
Minimum pool size: undefined/unknown
Maximum pool size: undefined/unknown
2025-03-12T11:56:58.322+09:00 INFO 28204 --- [beauty-care] [ main] o.h.e.t.j.p.i.JtaPlatformInitiator : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
Hibernate: drop table if exists member
Hibernate: create table member (is_use bit, created_by bigint, created_date datetime(6), id bigint not null auto_increment, last_login_date_time datetime(6), updated_by bigint, updated_date datetime(6), login_id varchar(255), name varchar(255) not null, password varchar(255) not null, role varchar(255), primary key (id)) engine=InnoDB
Hibernate: alter table member add constraint UQ_MEMBER_LOGIN_ID unique (login_id)
2025-03-12T11:56:58.621+09:00 INFO 28204 --- [beauty-care] [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2025-03-12T11:56:58.722+09:00 INFO 28204 --- [beauty-care] [ main] c.p.b.d.member.MemberRepositoryTest : Started MemberRepositoryTest in 1.071 seconds (process running for 41.553)
Hibernate: insert into member (created_by,created_date,is_use,last_login_date_time,login_id,name,password,role,updated_by,updated_date) values (?,?,?,?,?,?,?,?,?,?)
Hibernate: select m1_0.id,m1_0.created_by,m1_0.created_date,m1_0.is_use,m1_0.last_login_date_time,m1_0.login_id,m1_0.name,m1_0.password,m1_0.role,m1_0.updated_by,m1_0.updated_date from member m1_0 where m1_0.login_id=? and m1_0.is_use
Could not find a valid Docker environment ➡️ 도커 환경을 찾지 못해, 예외 발생
12:40:52.866 [main] INFO org.testcontainers.images.PullPolicy -- Image pull policy will be performed by: DefaultPullPolicy()
12:40:52.866 [main] INFO org.testcontainers.utility.ImageNameSubstitutor -- Image name substitution will be performed by: DefaultImageNameSubstitutor (composite of 'ConfigurationFileImageNameSubstitutor' and 'PrefixingImageNameSubstitutor')
12:40:52.888 [main] INFO org.testcontainers.DockerClientFactory -- Testcontainers version: 1.20.5
12:40:53.168 [main] INFO org.testcontainers.dockerclient.DockerClientProviderStrategy -- Loaded org.testcontainers.dockerclient.NpipeSocketClientProviderStrategy from ~/.testcontainers.properties, will try it first
12:40:53.184 [main] INFO org.testcontainers.dockerclient.DockerMachineClientProviderStrategy -- docker-machine executable was not found on PATH ([C:\Java\jdk-21\bin, C:\Program Files\Common Files\Oracle\Java\javapath, C:\WINDOWS.X64_193000_db_home\bin, C:\app\sng49\product\21c\dbhomeXE\bin, C:\Program Files (x86)\Common Files\Oracle\Java\javapath, C:\WINDOWS\system32, C:\WINDOWS, C:\WINDOWS\System32\Wbem, C:\WINDOWS\System32\WindowsPowerShell\v1.0\, C:\WINDOWS\System32\OpenSSH\, C:\Program Files\Git\cmd, C:\Program Files (x86)\PuTTY\, C:\Program Files\Docker\Docker\resources\bin, C:\Program Files (x86)\Pulse Secure\VC142.CRT\X64\, C:\Program Files (x86)\Pulse Secure\VC142.CRT\X86\, C:\Program Files (x86)\Common Files\Pulse Secure\TNC Client Plugin\, C:\Program Files\nodejs\, C:\Users\sng49\AppData\Local\Microsoft\WindowsApps, , C:\Program Files\JetBrains\IntelliJ IDEA 2024.2.1\bin, , C:\Users\sng49\AppData\Local\Programs\Microsoft VS Code\bin, C:\Users\sng49\AppData\Local\Microsoft\WinGet\Packages\Schniz.fnm_Microsoft.Winget.Source_8wekyb3d8bbwe, C:\Program Files\JetBrains\DataGrip 2024.2.2\bin, , C:\Users\sng49\AppData\Roaming\npm])
12:40:53.184 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy -- Could not find a valid Docker environment. Please check configuration. Attempted configurations were:
As no valid configuration was found, execution cannot continue.
See https://java.testcontainers.org/on_failure.html for more details.
Exception in thread "main" java.lang.ExceptionInInitializerError
at java.base/java.lang.Class.forName0(Native Method)
at java.base/java.lang.Class.forName(Class.java:421)
at java.base/java.lang.Class.forName(Class.java:412)
at com.intellij.junit5.JUnit5TestRunnerUtil.loadMethodByReflection(JUnit5TestRunnerUtil.java:126)
at com.intellij.junit5.JUnit5TestRunnerUtil.buildRequest(JUnit5TestRunnerUtil.java:102)
at com.intellij.junit5.JUnit5IdeaTestRunner.startRunnerWithArgs(JUnit5IdeaTestRunner.java:43)
at com.intellij.rt.junit.IdeaTestRunner$Repeater$1.execute(IdeaTestRunner.java:38)
at com.intellij.rt.execution.junit.TestsRepeater.repeat(TestsRepeater.java:11)
at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:35)
at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:232)
at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:55)
Caused by: java.lang.IllegalStateException: Could not find a valid Docker environment. Please see logs and check configuration
at org.testcontainers.dockerclient.DockerClientProviderStrategy.lambda$getFirstValidStrategy$7(DockerClientProviderStrategy.java:274)
at java.base/java.util.Optional.orElseThrow(Optional.java:403)
at org.testcontainers.dockerclient.DockerClientProviderStrategy.getFirstValidStrategy(DockerClientProviderStrategy.java:265)
at org.testcontainers.DockerClientFactory.getOrInitializeStrategy(DockerClientFactory.java:154)
at org.testcontainers.DockerClientFactory.client(DockerClientFactory.java:196)
at org.testcontainers.DockerClientFactory$1.getDockerClient(DockerClientFactory.java:108)
at com.github.dockerjava.api.DockerClientDelegate.authConfig(DockerClientDelegate.java:109)
at org.testcontainers.containers.GenericContainer.start(GenericContainer.java:321)
at com.project.beauty_care.DataBaseConnectionSupport.<clinit>(DataBaseConnectionSupport.java:27)
... 11 more