개요
Spring Boot 3.1.0에서는 로컬 개발, 테스트를 간소화하기 위해 Docker container를 사용한 테스트인 Testcontainers에(https://docs.spring.io/spring-boot/reference/testing/testcontainers.html) 대한 지원이 추가되었다. Testcontainers는 Mock객체를 활용한 테스트를 작성하는 대신 실제 종속성을 사용하여 테스트를 작성하는데 도움이 되지만 실제 Docker container를 사용하는 테스트를 실행하므로 Mock을 사용한 테스트보다는 테스트 시간이 증가할 수 있다.
아래 내용 등을 통해 Testcontainers를 사용하면서 테스트 실행 시간을 줄이는 방법과 장단점에 대하여 알아본다.
Pre-requisites
Docker
Container를 사용한 테스트를 위해서는 테스트를 실행할 PC에 Docker 실행 환경이 구성되어야 한다.
Docker Install : https://docs.docker.com/engine/install/
Spring Initializr
Spring Initialzr를 사용하여 Spring Web, Spring Data JPA, MySQL Driver, Testcontainers의 의존성을 추가하여 프로젝트를 생성한다.
Add Code
JPA를 사용하여 사용자와(User) 책(Book) 정보를 관리하는 간단한 코드를 추가하고 생성한 코드들에 대하여 Testcontainers를 사용하여 테스트 코드를 작성한다.
User.java
@Entity
@Table(name = "user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int age;
......
}
Book.java
@Entity
@Table(name = "user")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
......
}
UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findAllOrderById();
}
BookRepository.java
public interface BookRepository extends JpaRepository<Book, Long> {
}
ApiController.java
@RestController
@RequestMapping("/api")
class ApiController {
private final UserRepository userRepository;
private final BookRepository bookRepository;
public ApiController(UserRepository userRepository, BookRepository bookRepository) {
this.userRepository = userRepository;
this.bookRepository = bookRepository;
}
@GetMapping("/users")
List<User> getUsers() {
return this.userRepository.findAllOrderById();
}
@GetMapping("/books")
List<Book> getBooks() {
return this.bookRepository.findAll();
}
}
Wirter Test Code
위 작성한 코드들에 대하여 Testcontainer를 사용한 테스트 코드를 작성하면서 각 접근 방식에 대하여 알아본다.
비효율적인 @ServiceConnection 사용
@ServiceConnection을 사용하여 아래와 같이 테스트 코드를 작성할 수 있다.
각 테스트 코드는 MySQLContainner를 정의하고 @Testcontainers, @Container, @ServiceConnection을 사용하여 MySQL 컨테이너를 시작하고 해당 데이터베이스 컨테이너를 사용하여 DataSource로 사용한다.
UserRepositoryTests.java
@DataJpaTest
@Testcontainers
class UserRepositoryTests {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>( DockerImageName.parse("mysql:8.0"));
@Autowired
UserRepository userRepository;
@BeforeEach
void setup(){
this.userRepository.deleteAll();
}
@Test
void shouldGetAllUsers() {
this.userRepository.save(new User("igooo", 10));
var users = this.userRepository.findAllOrderById();
assertThat(users).hasSize(1);
assertThat(users.get(0).getName()).isEqualTo("igooo");
}
}
BookRepositoryTests.java
@DataJpaTest
@Testcontainers
class BookRepositoryTests {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>( DockerImageName.parse("mysql:8.0"));
@Autowired
BookRepository bookRepository;
@BeforeEach
void setup(){
this.bookRepository.deleteAllInBatch();
}
@Test
void shouldGetAllBooks() {
this.bookRepository.save(new Book("igooo"));
this.bookRepository.save(new Book("test"));
var books = this.bookRepository.findAll();
assertThat(books).hasSize(2);
assertThat(books.get(0).getName()).isEqualTo("igooo");
assertThat(books.get(1).getName()).isEqualTo("test");
}
}
ApiControllerTests.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class ApiControllerTests {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>( DockerImageName.parse("mysql:8.0"));
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
@Autowired
private BookRepository bookRepository;
@BeforeEach
void setUp() {
this.userRepository.deleteAll();
this.bookRepository.deleteAll();
}
@Test
void shouldGetAllUsers(){
this.userRepository.save(new User("igooo", 10));
this.userRepository.save(new User("test", 20));
var users = this.restTemplate.getForObject("/api/users", User[].class);
assertThat(users).hasSize(2);
assertThat(users[0].getName()).isEqualTo("igooo");
assertThat(users[1].getName()).isEqualTo("test");
}
@Test
void shouldGetAllBooks(){
this.bookRepository.save(new Book("igooo"));
var books = this.restTemplate.getForObject("/api/books", Book[].class);
assertThat(books).hasSize(1);
assertThat(books[0].getName()).isEqualTo("igooo");
}
}
gradlew을 사용하여 테스트를 실행하면 아래와 같은 결과가 표시된다.
$> gradlew test
> Task :compileJava UP-TO-DATE
> Task :processResource UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
......
BUILD SUCCESSFUL in 59s
4 actionable tasks: 2 executed, 2 up-to-date
Execution finished 'test'
문제점
- 각 테스트 코드에서 MySQL 컨테이너를 각각 정의하고 있어서 컨테이너 정보 변경이 필요한 경우 모든 테스트 코드에서 수정이 필요하다. (버전을 변경하거나 다른 DB를 사용하는 경우)
- 'Creating container for image: mysq:8.0' 로그를 3번 볼 수 있는데, 이는 실제 각 테스트 코드에서 새로운 MySQL 컨테이너를 생성했음을 의미한다. 컨테이너를 사용한 테스트 코드가 많아질수록 테스트 실행에 오랜 시간이 필요할 수 있게 된다.
Testcontainer를 재사용하고 Spring Test Context Caching을 활용하는 방법
준비사항에서 Spring initialzr를 사용하여 프로젝트를 생성하면 src/test/java 아래 TestcontanerConfiguration 클래스도 자동으로 생성됨을 볼 수 있다.
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {
@Bean
@ServiceConnection
MySQLContainer<?> mysqlContainer() {
return new MySQLContainer<>(DockerImageName.parse("mysql:8.0"));
}
}
TestcontanerConfiguration를 사용하여 각 테스트코드에서 컨테이너 정의를 재사용할 수 있다. @Import(TestcontainersConfiguration.class)를 사용하여 동일한 TestcontainersConfiguration을 가져와 중복으로 MySQL 컨테이너를 선언하여 사용하는 것을 방지할 수 있다.
UserRepositoryTests.java
@DataJpaTest
@Import(TestcontainersConfiguration.class)
class UserRepositoryTests {
@Autowired
UserRepository userRepository;
BookRepositoryTests.java
@DataJpaTest
@Import(TestcontainersConfiguration.class)
class BookRepositoryTests {
@Autowired
BookRepository bookRepository;
ApiControllerTests.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TestcontainersConfiguration.class)
class ApiControllerTests {
@Autowired
private TestRestTemplate restTemplate;
위에 코드처럼 3개의 테스트 코드를 변경하고 Test를 실행해 보면 처음 테스트했을 때와 다르게 'Creating container for image: mysq:8.0' 로그가 2번만 출력되는 것을 볼 수 있다. 처음 테스트와는 다르게 두 번만 출력되는 것은 Spring Test Context Caching이(https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/ctx-management/caching.html) 사용되기 때문이다.
위에서 작성한 3개의 테스트 중 Repository를 테스트하는 두 개의 테스트는 JPA Entity와 Repository를 로드하는 컨테스트를 생성하여 두개의 테스트에서 같은 테스트 컨텍스트를 사용하기 때문에 1번의 컨테이너 생성이 줄어들게 되어 'Creating container for image: mysq:8.0'의
다시 간단하게 BookRepositoryTests.java의 코드를 UserRepositoryTests.java 와 다른 컨텍스트를 사용하도록 변경하고 테스트를 실행해 보면 다시 'Creating container for image: mysq:8.0' 로그가 3번 출력되는 것을 볼 수 있다.
@DataJpaTest
@Import(TestcontainersConfiguration.class)
class BookRepositoryTests {
@Autowired
BookRepository bookRepository;
@MockitoBean
private UserRepository userRepository;
@MockitoBean 객체를 코드에 추가하여 3개의 테스트가 모든 다른 컨텍스트를 사용하도록 변경했다.
Testcontainers Singleton 컨테이너 패턴 사용
Spring Test Context Caching을 사용하면 생성되는 컨텍스트 수를 감소시킬 수 있었다. 하지만 Singletone Container Pattern을 사용하면 생성되는 컨테이너를 하나만 생성되도록할 수 있다.
TestcontainersConfiguration.java
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {
static MySQLContainer<?> mysqlContainer = new MySQLContainer<>(DockerImageName.parse("mysql:8.0"));
@Bean
@ServiceConnection
MySQLContainer<?> mysqlContainer() {
return mysqlContainer;
}
}
MySQLContainer를 static으로 선언하고 @ServiceConnection의 @Bean 설정에서 static으로 선언한 객체를 반환하게 되면 모든 테스트에서 동일한 컨테이너를 사용하게 된다.
테스트를 실행해 보면 'Creating container for image: mysq:8.0' 로그가 한 번만 출력되는 것을 볼 수 있다.
문제점
- 병렬 테스트 실행에 Singleton 컨테이너를 사용하면 불안정한 테스트 동작이 발생할 수 있다. 동일한 컨테이너가 여러 테스트에서 병렬로 사용되므로 병렬로 실행되는 다른 테스트로 인해 테스트 데이터의 오염이 발생할 수도 있다.
- 테스트는 적절한 테스트 데이터 설정 및 정리를 통해 구현되어야 하며, 컨테이너는 예측이 가능한 상태로 유지되어야 하는데, Singleton 컨테이너 패턴을 사용하든 사용하지 않던 테스트 작성 시 따라야 할 접근 방식이다.
요약
Testcontainers는 실제 서비스 환경과 동일한 테스트를 빠르게 작성할 수 있도록 도와주지만 실제 Docker 컨테이너를 스핀업해야 하므로 테스트 시간이 증가한다. 테스트에 약간 시간이 더 걸리더라도 구현에 확신을 주는 방법으로 테스트를 작성하는 것이 좋은 방법이라 생각한다.
전체 소스코드는 GitHub에서 확인할 수 있습니다.
'dev > spring' 카테고리의 다른 글
Spring gRPC (0) | 2025.01.11 |
---|---|
[Spring] Redisson으로 분산락 구현하기(Distrubuted Lock) (1) | 2024.11.21 |
Spring Batch MongoDB 빠르게 수집하기 (MongoCursorItemReader) (4) | 2024.11.08 |
Spring AI를 사용한 AI 어시스턴트 구현 RAG - Part 2 (with OpenAI) (1) | 2024.10.03 |
Spring AI를 사용한 AI 어시스턴트 구현 - Part 1 (with OpenAI) (1) | 2024.10.01 |