[Spring Boot] TestContainers 환경 구축

송영호·2025년 3월 12일

Spring Boot

목록 보기
5/8
post-thumbnail

개요

실무에서 테스트 코드를 작성하는 것은 선택이 아닌 필수가 되어가고 있다.
테스트 코드의 중요성이 높아지면서, 백엔드 개발자들은 DB를 연동한 테스트를 어떻게 할 것인지에 대해 많은 고민을 해왔다.

그러나 기존의 인메모리 데이터베이스(H2 등)나 로컬 DB를 사용하는 방식은 운영 환경과 차이가 발생하거나, 테스트 간 데이터 충돌이 발생하는 문제점을 가지고 있었다.

이러한 문제를 해결하기 위해 Testcontainers가 등장했다. Testcontainers는 Docker 컨테이너를 활용하여 테스트 환경에서 실제 운영과 유사한 DB나 외부 서비스를 실행할 수 있도록 지원하는 라이브러리다. 이를 통해 테스트의 신뢰성을 높이고, CI/CD 환경에서도 일관된 테스트 환경을 유지할 수 있다.

이번 글에서는 Testcontainers가 무엇인지, 왜 필요한지, 그리고 Spring Boot 환경에서 어떻게 활용할 수 있는지에 대해 자세히 알아본다.


주요 특징 및 장점

Docker 기반 동작

  • Docker 컨테이너를 활용하여 외부 서비스(예: MySQL, PostgreSQL, Redis 등)를 실행한다.

테스트 격리

  • 각 테스트마다 새로운 컨테이너를 생성하여 테스트 간 독립성 유지(테스트마다 컨테이너 생성 ➡️ 테스트 시간이 너무 오래 걸리기에, 하나의 컨테이너만 생성해 각 테스트마다 @Transactional 적용)

유연성

  • 다양한 환경 구성 가능(테스트마다 다른 설정을 적용할 수 있다.)

실제 환경과 동일한 환경으로 테스트 가능

  • 자주 사용하는 테스트 DB(H2와 같은 인메모리 DB)들은 제약조건이나, 트랜잭션 격리 수준 등 실제 Production 환경과 다른 경우가 많다.
    하지만, TestContainers는 MySQL, PostgreSQL, Redis, Kafka 등 운영 환경에서 사용하는 서비스를 그대로 컨테이너로 띄워 동일한 환경에서 테스트할 수 있다는 장점이 있다.

테스트 종료 후 컨테이너 자동 종료

  • TestContainers는 테스트 종료 시, 자동으로 컨테이너를 종료하여, 리소스 낭비를 줄인다.

단점

테스트 속도

  • 테스트를 실행할 때마다 새로운 컨테이너를 생성 ➡️ 속도 느림
    • 인메모리 DB(H2 등)를 활용한 테스트에 비해 실행 속도가 느리다.
    • 특히, 여러 개의 컨테이너를 동시에 실행하는 경우, 테스트 실행 시간이 증가한다.
    • @Testcontainers와 static 컨테이너를 활용하면 테스트 실행 간 컨테이너를 재사용할 수 있어 속도 개선 가능
    • 로컬 개발 환경에서 인메모리 DB로 테스트하고, Production 환경에서만 TestContainers를 적용하는 전략 고려

Docker 사용 강제로 인한 추가 리소스 필요

  • TestContainers는 Docker를 기반으로 동작한다. 따라서, 테스트를 실행하는 서버에 반드시 Docker 설치 강제
  • Docker 컨테이너 실행으로 인해 CPU 및 메모리 사용량 증가
    • 특히, 여러 개의 컨테이너를 동시에 실행할 경우, 성능에 영향을 줄 수 있다.
    • 저사양 서버에서는, 테스트 실행 속도가 느릴 수 있다.

요약

✅Docker 컨테이너 기반으로 동작
✅실제 Production 환경과 유사하게 테스트 가능
✅자동 리소스 정리 지원
🚨테스트 속도 느림
🚨Docker 설치 강제됨


설정 방법(mysql 기준)

Jenkins 연동 -> TestContainers 적용 시, Jenkins 환경에서 Docker 실행 가능한 환경이어야 합니다.
ex) 젠킨스 컨테이너 실행 시, host의 /var/run/docker.sock 볼륨 설정 및 호스트(EC2) Docker 설치
로컬에서 TestContainers 구축 -> docker desktop 실행 후 진행

1️⃣ 의존성 추가

build.gradle📜

testImplementation 'org.testcontainers:mysql:1.20.5'
testImplementation 'org.testcontainers:testcontainers:1.20.5'
testImplementation 'org.testcontainers:junit-jupiter:1.20.5'

2️⃣ DB 연동을 위한, TestContainers 설정

DataBaseConnectionSupport.class📜
✅ container 재사용을 위해, @Testcontainers와 static 컨테이너 설정
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)

  • 외부 DB(mysql) 사용을 위한 설정
  • 해당 설정이 없을 경우, 자동으로 인메모리 DB를 사용한다.
@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);
    }
}

3️⃣ 테스트 클래스에서, DataBaseConnectionSupport.class 상속

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);
    }
}

4️⃣ 테스트 실행 및 확인

테스트 실행 시, 아래와 같이 도커 컨테이너가 실행되는 로그가 나타나고, 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
profile
BACKEND 개발자

0개의 댓글