프로젝트에서 테스트는 in-memory DB인 H2로 하고 운영은 MySQL로 했는데, MySQL에서 제공하지만 H2에서는 제공하지 않는 기능들이 꽤 있었고, 사용하는 쿼리 문법도 조금씩 달라서 온전하게 운영 DB 환경에서 테스트를 진행할 수 없다는 문제점들이 있었다.
또한 테스트 DB에 이전 테스트의 데이터가 삭제되지 않고 남아있으면, 이전의 테스트가 다음 테스트에 영향을 주어서 항상 일정한 테스트 결과를 기대할 수 없다. 즉, "멱등성"을 만족하는 테스트를 작성할 수 없다는 것이다.
어떻게 하면 1) 테스트 DB와 운영 DB의 환경을 동일하게 유지하고, 2) 테스트 시에 데이터베이스의 멱등성을 유지할 수 있을지 찾아보던 중 TestContainer
라는 라이브러리를 발견했다. TestContainer란 무엇이고 어떻게 사용할 수 있는지 작성해본다.
멱등성이란?
멱등성이란 같은 요청을 여러 번 수행해도 결과가 변하지 않는 성질을 의미한다. 단순히 돌아온 값이 같은 뿐 아니라, 서버의 데이터 상태 (DB)에도 영향을 미치지 않는다.
⭕ 멱등성을 만족하는 HTTP method
GET
데이터를 한번 조회하든, 여러번 조회하든 같은 결과가 조회되므로 멱등하다.
(멱등적이지 않은 설계)
게시글을 조회하는 GET 메서드 API에 조회수 데이터를 증가하는 로직이 추가되어 있다면, 이것은 HTTP 스펙에 부합하지 않게 API를 구현한 것이다. 왜냐하면 서버의 데이터 상태가 변경되기 때문이다.
PUT
PUT 메서드는 대상 리소스를 덮어씌워 변경하거나, 대상 리소스가 없다면 새로 추가한다. 그래서 만일 대상 리소스가 없다면 PUT이 POST와 같은 동작을 하게 되는데, POST는 매번 새로운 자원을 만드는 반면, PUT은 해당 자원이 이미 있다면 데이터만 덮어쓴다. 따라서 요청을 한번하든 여러번하든 결국 서버의 상태는 같아지니 PUT은 멱등하다.
DELETE
DELETE를 처음 요청하면, 서버에서 해당 리소스는 삭제가 된다. 이후 DELETE를 여러번 요청하더라도 해당 리소스는 삭제된 상태로 그대로 일 것이니 서버의 상태는 변하지 않는다.
(멱등적이지 않은 설계)
정확한 식별자를 통해 리소스를 지정한 것이 아니고, 게시글을 삭제할 때 정확한 ID 값이 아닌 last 라는 구조로 엔드포인트를 설계했다고 가정하자.
DELETE /posts/last
이런 경우 DELETE 요청을 여러번 보내게 되면 매번 마지막 게시글을 삭제하기 때문에 매번 서버의 상태가 변하게 되어서, 멱등성을 만족하지 않는다. 이런 경우 멱등성을 가지지 않는 POST를 사용하는게 스펙 상으로 올바르다.
❌ 멱등성을 만족하지 않는 HTTP method
POST
요청을 여러번 보내는 경우 매번 새로운 리소스가 생겨나고, 이는 서버의 상태가 변경되는 것을 의미한다.
PATCH
PUT이 리소스 전체 교체라면, PATCH는 리소스의 부분적인 수정을 할 때 사용된다. PATCH의 특이한 점은 기본적으로 멱등성을 가지지 않는 메서드인데, 그 구현을 PUT과 동일한 방식으로 할 경우 멱등성을 가지게 되는 특성을 가지고 있다.
(멱등적인 설계)
수정할 리소스의 일부분만 담아서 보내는 경우에는 멱등성이 보장된다. 왜냐하면 한번 요청을 보낸후 같은 요청을 여러번 하더라도 변경된 같은 결과가 나오기 때문이다.
(멱등적이지 않은 설계)
PATCH의 동작을 데이터를 또 다른 데이터로 ‘대체’하는 것이 아닌 ‘증가’를 통한 변경이라고 하면 멱등성을 가지지 않는다. 예를 들어 매 요청마다 age가 1씩 증가하도록 PATCH api 를 설계하였을 경우에는 요청마다 age의 값이 달라지기 때문에, 멱등성을 가지지 않는다.
테스트 시 가능한 데이터베이스 환경
로컬 환경에서 실제 DB를 띄우고, 해당 DB에 연결해서 테스트를 진행하는 방법
운영 DB와 동일한 DB를 설치해서 띄우면 환경은 일치시킬 수 있지만, 테스트가 끝났음에도 테스트용 데이터가 남아있어서 멱등성을 유지하지 어렵다. 만약 멱등성을 유지하고 싶다면 매 테스트가 끝날 때마다 DB를 초기화하는 작업을 직접 수행해야한다.
다른 사람과 동시에 테스트를 진행하면, 동일한 DB를 사용하기 때문에 예상하지 못한 문제들이 생길 수 있다.
in-memory DB 를 사용하는 방법
H2와 같은 in-memory DB를 사용하면 빠르게 테스트를 진행할 수 있고, 외부 DB 커넥션이 없고, 테스트 후 데이터 관리도 따로 신경쓰지 않아도 된다는 장점이 있다.
하지만, 테스트 DB와 운영 DB가 달라서 일부 기능을 테스트 하지 못하거나, 운영DB와는 다른 SQL 을 사용해서 테스트를 작성해야할 수 있다.
docker-compose로 container 띄우기
운영DB와 동일한 환경을 만들 수 있지만, 테스트 시작 전후로 컨테이너를 올렸다가 내렸다가 하는게 귀찮을 수도 있다. 만약, 컨테이너를 내리는 것을 까먹으면 해당 컨테이너가 백그라운드에 남아서 리소스를 잡아먹을 수 있다.
TestContainer 사용하기
어떤 DB를 테스트시 사용할건지 직접 선택할 수 있다.
그리고, 테스트 시작시 자동으로 DB 컨테이너를 생성해서 실행하고, 종료시 자동으로 컨테이너를 삭제한다. 테스트 마다 새로운 DB 컨테이너가 생성되기 때문에 멱등성도 유지할 수 있다.
TestContainer
TestContainer란
https://testcontainers.com/getting-started/
"Testcontainers is a testing library that provides easy and lightweight APIs for bootstrapping integration tests with real services wrapped in Docker containers. Using Testcontainers, you can write tests talking to the same type of services you use in production without mocks or in-memory services."
"도커 컨테이너로 래핑된 실제 서비스를 제공해서, 로컬 테스트 시에도 mocking이나 in-memory 서비스들을 사용하지 않고 운영환경에서 사용하는 실제 서비스에 종속되는 테스트를 작성할 수 있게 해주는 오픈 소스 라이브러리입니다."
어떤 DB를 테스트시 사용할건지 직접 선택할 수 있다.
그리고, 테스트 시작시 자동으로 DB 컨테이너를 생성해서 실행하고, 종료시 자동으로 컨테이너를 삭제한다. 테스트 마다 새로운 DB 컨테이너가 생성되기 때문에 멱등성도 유지할 수 있다.
TestContainer 라이브러리의 장점은 다음과 같다.
On-demand isolated infrastructure provisioning
사전에 프로비저닝된 통합 테스트 인프라가 필요하지 않다. Testcontainers는 테스트를 실행하기 전에 필요한 서비스를 제공한다. 여러 빌드 파이프라인이 병렬로 실행되는 경우에도 각 파이프라인은 격리된 서비스 집합으로 실행되기 때문에 테스트 데이터 오염이 없다.
Consistent experience on both local and CI environments
(로컬 및 CI 환경 모두에서 일관된 경험)단위 테스트를 실행하는 것처럼 IDE에서 바로 통합 테스트를 실행할 수 있다. 변경 사항을 푸시하고 CI 파이프라인이 완료될 때까지 기다릴 필요가 없다.
Reliable test setup using wait strategies
(대기 전략을 사용한 신뢰할 수 있는 테스트 설정)Docker 컨테이너는 테스트에서 사용하기 전에 시작하고 완전히 초기화해야 한다. Testcontainers 라이브러리는 컨테이너(및 내부 애플리케이션)가 완전히 초기화되었는지 확인하기 위한 여러 가지 기본 대기 전략 구현을 제공한다. Testcontainers 모듈은 이미 특정 기술에 대한 관련 대기 전략을 구현하고 있으며, 필요한 경우 언제든지 자체적으로 구현하거나 복합 전략을 만들 수 있다.
Advanced networking capabilities
(고급 네트워킹 기능)Testcontainers 라이브러리는 호스트 컴퓨터에서 사용할 수 있는 임의의 포트에 컨테이너의 포트를 매핑하여 테스트가 해당 서비스에 안정적으로 연결되도록 한다. 또한 (Docker) 네트워크를 생성하고 여러 컨테이너를 함께 연결하여 정적 Docker 네트워크 별칭을 통해 서로 통신할 수도 있다.
Automatic clean up
(자동 정리)Testcontainers 라이브러리는 Ryuk 사이드카 컨테이너를 사용하여 테스트 실행이 완료된 후 생성된 리소스(컨테이너, 볼륨, 네트워크 등)를 자동으로 제거한다. 필요한 컨테이너를 시작하는 동안 테스트 컨테이너는 생성된 리소스(컨테이너, 볼륨, 네트워크 등)에 라벨 세트를 부착하고, Ryuk은 해당 라벨을 매칭하여 리소스 정리를 자동으로 수행한다. 이는 테스트 프로세스가 비정상적으로 종료되는 경우(예: SIGKILL 전송)에도 안정적으로 작동한다.
테스트를 실행하면, TestContainer가 2개의 컨테이너를 자동으로 생성 및 실행한다.
testcontainers-ryuk-XXX
라는 docker container가 생성 및 실행되고, Ryuk
는 코드에 설정한 DB(e.g., silly_faraday
)를 생성 및 실행한다. 테스트가 종료되면 Ryuk
가 인스턴스를 정리하고 마지막으로 종료된다.

프로젝트에 도입하기
build.gradle
testImplementation 'org.testcontainers:testcontainers:1.20.1'
testImplementation 'org.testcontainers:junit-jupiter:1.20.1'
testImplementation 'org.testcontainers:mysql:1.20.1'
환경 세팅하기 (자동으로 라이프사이클 관리)
@Testcontainers
public abstract class RepositoryTest {
private static final String USERNAME = "testUser";
private static final String PASSWORD = "testPassword";
private static final String DATABASE_NAME = "testDB";
@Container
public static org.testcontainers.containers.MySQLContainer<?> mySQLContainer = new org.testcontainers.containers.MySQLContainer<>("mysql:8.0.35")
.withDatabaseName(DATABASE_NAME)
.withUsername(USERNAME)
.withPassword(PASSWORD)
;
@DynamicPropertySource
public static void overrideProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mySQLContainer::getJdbcUrl);
registry.add("spring.datasource.username", mySQLContainer::getUsername);
registry.add("spring.datasource.password", mySQLContainer::getPassword);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
}
@Container
어노테이션을 붙이고 MySQL 컨테이너를 정의한다.static이 아니면 test 함수 마다 컨테이너가 생성된다. static이면 하나의 클래스에서 1개의 컨테이너가 실행된다.
위의 코드는 컨테이너 설정 값을 하드코딩 했지만, application.yml 을 활용해서 관리하는 방법도 있다.
port를 따로 설정하지 않았기 때문에, 호스트 컴퓨터에서 사용하지 않는 포트 중 하나와 랜덤으로 매핑한다.
⚠ 위의 코드처럼 자동으로 컨테이너 라이프 사이클을 관리하는 경우, 테스트 컨테이너가 싱글톤으로 동작하지 않고, 하나의 클래스마다 1개의 컨테이너가 뜨는 문제점이 있다. 추후 해결하고, 추가로 포스팅할 예정이다.
@DynamicPropertySource
어노테이션을 사용하여 환경변수를 적용한다.@Testcontainers
어노테이션을 클래스 상위에 작성한다.⚠
@Testcontainers
을 클래스 상위에 작성하지 않고 실행하면 커넥션 문제가 발생한다. 구글링을 해보니@Testcontainers
와@Container
를 같이 사용하지 않으면 컨테이너 초기화 시점이 테스트 실행 시점과 맞지 않아 커넥션 문제가 발생할 수 있다고 한다. 컨테이너가 테스트 실행 전에 완전히 준비되지 않으면, Hibernate가 DB 커넥션을 시도할 때 오류 발생한다. 이 둘을 같이 사용해야지 컨테이너가 테스트 실행 전에 자동으로 시작되고 준비 완료될 때까지 대기하므로 커넥션 오류가 사라진다.testcontainers, hikari and Failed to validate connection org.postgresql.jdbc.PgConnectionI have a spring boot app. I'm testing it with testcontainers to ensure that the DB (postgres) and the Repository implementation do what they are supposed to do. I initialise the container with thehttps://stackoverflow.com/questions/59372048/testcontainers-hikari-and-failed-to-validate-connection-org-postgresql-jdbc-pgc
📖 참고


![[사내 TestContainer 적용] Spring boot 통합테스트 도입기](https://img1.daumcdn.net/thumb/R800x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc5M2e6%2FbtrVtS7vubL%2FU9dQgKeW1sKv0D15mqxP31%2Fimg.png)