스프링에서 데이터에베이스에 연결해 테스트를 진행할 때는 로컬에 설치하거나 H2 인메모리 데이터베이스를 테스트 코드와 같이 실행시키는 등 다양한 방법이 있다. 이번에는 TestContainer를 이용해서 테스트 코드를 실행할 떄 MySQL 컨테이너를 실행시키고 연결해 통합테스트코드를 수행하는 방법에 대해서 알아보자. 완성된 프로젝트 코드는 링크에서 찾을 수 있다.

준비

도커 설치

MySQL 컨테이너를 실행시키기 위해선 당연히 컨테이너 실행 도구가 필요하다. 이 중 가장 대표적인 도커를 설치해보자. 링크를 통해 OS에 맞는 도커를 설치하도록 한다.

환경 변수 설정

To run Testcontainers-based tests, you need a Docker-API compatible container runtime, such as using Testcontainers Cloud or installing Docker locally. During development, Testcontainers is actively tested against recent versions of Docker on Linux, as well as against Docker Desktop on Mac and Windows. These Docker environments are automatically detected and used by Testcontainers without any additional configuration being necessary.

TestContainer가 설치된 컨테이너 실행도구를 찾기 위해서는 정보를 알려주어야 한다. TestContainer는 기본적으로 도커를 알아서 찾아 연결하려고 한다. 도커 설치 후 따로 설정을 하지 않는 이상 테스트 컨테이너를 위해 추가적으로 설정해야할 일은 없다. 만약 컨테이너 실행 도구를 도커로 사용하지 않거나 추가 설정이 필요하다면, 링크를 참고하자.

Gradle 종속성 추가

스프링 프로젝트에서 테스트 코드 실행시 도커를 활용하기 위해서는 TestContainer 모듈이 필요하다. 아래 종속성 코드를 참고해 필요한 모듈을 추가하도록 한다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web' // 예시 웹 서비스 용
    runtimeOnly 'com.mysql:mysql-connector-j'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.testcontainers:junit-jupiter' //테스트 컨테이너를 Junit 테스트 라이프사이클에 활용하기 위한 모듈
    testImplementation "org.testcontainers:mysql:1.19.1" //MySQL 컨테이너를 테스트에 활용하기 위한 모듈
    testImplementation 'io.rest-assured:rest-assured' //API 테스트를 위한 모듈
}

예시 웹 서비스 구현

테스트를 위해서 고객 정보를 저장하고 조회하는 서비스를 간단하게 구현해보자

학생 스키마 정의

resources/schema.sql에 MySQL 에 정의할 학생 테이블 스키마 DDL을 작성하자

CREATE TABLE students
(
    id    BIGINT AUTO_INCREMENT NOT NULL,
    name  VARCHAR(255)          NULL,
    age   INT                   NOT NULL,
    email VARCHAR(255)          NULL,
    CONSTRAINT pk_students PRIMARY KEY (id)
);

학생 엔티티 정의

학생의 이름과 이메일을 저장하는 엔티티를 아래와 같이 정의하자.

package com.example.testcontainermysql.domain;  

import jakarta.persistence.*;  

@Entity  
@Table(name = "students")  
public class Student {  

    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  

    private String name;  
    private int age;  
    private String email;  

    // 기본 생성자  
    public Student() {  
    }  
    // 모든 필드를 포함한 생성자  
    public Student(String name, int age, String email) {  
        this.name = name;  
        this.age = age;  
        this.email = email;  
    }  

    // getter와 setter 메소드  
    public Long getId() {  
        return id;  
    }  

    public void setId(Long id) {  
        this.id = id;  
    }  

    public String getName() {  
        return name;  
    }  

    public void setName(String name) {  
        this.name = name;  
    }  

    public int getAge() {  
        return age;  
    }  

    public void setAge(int age) {  
        this.age = age;  
    }  

    public String getEmail() {  
        return email;  
    }  

    public void setEmail(String email) {  
        this.email = email;  
    }  

    // toString 메소드  
    @Override  
    public String toString() {  
        return "Student{" +  
                "id=" + id +  
                ", name='" + name + '\'' +  
                ", age=" + age +  
                ", email='" + email + '\'' +  
                '}';  
    }  
}

학생 레포지토리 정의

학생 정보를 저장, 조회하는 레포지토리를 정의하자.

package com.example.testcontainermysql.domain;  

import org.springframework.data.jpa.repository.JpaRepository;  

public interface StudentRepository extends JpaRepository<Student, Long> {  
}

학생 컨트롤러 정의

학생 정보를 조회하는 컨트롤러를 정의하자.

package com.example.testcontainermysql.api;

import com.example.testcontainermysql.domain.Student;
import com.example.testcontainermysql.domain.StudentRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
public class StudentController {
    private final StudentRepository studentRepository;

    public StudentController(StudentRepository studentRepository) {
        this.studentRepository = studentRepository;
    }

    @GetMapping("/student/all")
    List<Student> findAll(){
        return studentRepository.findAll();
    }
}

테스트

테스트 리소스 설정

test/resources/spring.yaml에 테스트시 사용할 스프링 설정을 정의하자. 아래와 같이 정의하면 이전에 정의한 학생 스키마를 MySQL에 자동으로 전달해 학생 테이블을 생성한다.

spring:  
  sql:  
    init:  
      mode: always

테스트 코드 작성

이제 MySQL 컨테이너를 실행시키고 예시 학생 데이터를 생성한 다음 API를 통해 학생 데이터를 조회하는 테스트 코드를 작성해보자. 테스트 코드의 역할을 주석을 참고해보자.

package com.example.testcontainermysql;  

import static io.restassured.RestAssured.given;  
import static org.hamcrest.Matchers.hasSize;  

import com.example.testcontainermysql.domain.Student;  
import com.example.testcontainermysql.domain.StudentRepository;  
import io.restassured.RestAssured;  
import io.restassured.http.ContentType;  
import java.util.List;  
import org.junit.jupiter.api.AfterAll;  
import org.junit.jupiter.api.BeforeAll;  
import org.junit.jupiter.api.BeforeEach;  
import org.junit.jupiter.api.Test;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.boot.test.context.SpringBootTest;  
import org.springframework.boot.test.web.server.LocalServerPort;  
import org.springframework.test.context.DynamicPropertyRegistry;  
import org.springframework.test.context.DynamicPropertySource;  
import org.testcontainers.containers.MySQLContainer;  

/**  
 * 1. 통합 테스트를 위해 임의의 포트 번호를 할당한 웹 어플리케이션을 실행합니다.  
 */@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)  
class StudentControllerTest {  

    //현재 서버의 포트 번호  
    @LocalServerPort  
    private Integer port;  

    /**  
     * 2. 도커 이미지 명을 명시해 컨테이너를 정의합니다.  
     */    static MySQLContainer<?> mysql = new MySQLContainer<>(  
            "mysql:8.0.35"  
    );  

    /**  
     * 3. 테스트 전, 컨테이너를 실행합니다, 이미지가 없다면 풀링을 먼저 수행합니다.  
     */    @BeforeAll  
    static void beforeAll() {  
        mysql.start();  
    }  

    /**  
     * 4. 테스트 후, 컨테이너를 종료합니다.  
     */    @AfterAll  
    static void afterAll() {  
        mysql.stop();  
    }  

    /**  
     * 5. 통합 테스트에서 동적으로 데이터베이스 관련 프로퍼티를 정의합니다. 이를 통해, JPA가 어떤 데이터베이스 연결 정보를 알 수 있습니다.  
     * @param registry  
     */  
    @DynamicPropertySource  
    static void configureProperties(DynamicPropertyRegistry registry) {  
        registry.add("spring.datasource.url", mysql::getJdbcUrl);  
        registry.add("spring.datasource.username", mysql::getUsername);  
        registry.add("spring.datasource.password", mysql::getPassword);  
    }  

    @Autowired  
    StudentRepository studentRepository;  

    /**  
     * 6. 각 테스트 수행 전, 학생 테이블을 초기화합니다.  
     */    @BeforeEach  
    void setUp() {  
        RestAssured.baseURI = "http://localhost:" + port;  
        studentRepository.deleteAll();  
    }  

    /**  
     * 7. 예시 데이터를 입력 후, API 테스트를 수행합니다.  
     */    @Test  
    void shouldGetAllStudents() {  
        List<Student> customers = List.of(  
                new Student("foo", 11, "john@mail.com"),  
                new Student("bar", 12, "dennis@mail.com")  
        );  
        studentRepository.saveAll(customers);  

        given()  
                .contentType(ContentType.JSON)  
                .when()  
                .get("/student/all")  
                .then()  
                .statusCode(200)  
                .body(".", hasSize(2));  
    }  
}

테스트 코드 실행

이제 테스트 코드를 실행하면 아래와 같이 컨테이너가 실행되는 모습을 볼 수 있다.

컨테이너 재활용하기

만약 위와 같은 테스트 코드가 여러개라면 어떨까? 각 테스트 클래스가 실행될 때마다 컨테이너를 시작하고 종료하기를 반복할 것이다. 이는 테스트의 멱등성을 보장하지만, 똑같은 환경에서 읽기만 수행하는 테스트가 여러개 있을 경우에는 하나의 컨테이너만 사용해도 된다. 하나의 컨테이너만 사용하면 컨테이너를 시작하고 종료하는 시간을 줄일 수 있기 때문에 매우 효과적이다.

설정

컨테이너 재사용을 위해서는 .testcontainers.properties파일 설정이 필요하다. 이 파일은 MAC OS 기준으로 /Users/<사용자명>/.testcontainers.properties에 위치해 있다. 여기에 testcontainers.reuse.enable=true를 추가해주면 된다.

코드 수정

테스트를 위해 StudentControllerTest와 똑같은 클래스 StudentControllerTest2, StudentControllerTest3을 만들어보자.

새로 만든 코드에서 afterAll 메서드를 삭제하고 컨테이너를 생성하는 코드를 다음과 같이 수정하자.

static MySQLContainer<?> mysql = new MySQLContainer<>(  
        "mysql:8.0.35"  
).withReuse(true);

이렇게하고 테스트 코드를 수행하면 아래 로그처럼 컨테이너를 재활용하는 것을 확인할 수 있다!

2023-11-14T15:44:34.462+09:00  INFO 69374 --- [    Test worker] tc.mysql:8.0.35                          : Reusing container with ID: 81a0dea4b23a4239dfad1b4d2c587d118a98d2bcf463155479563ebfc6c2e608 and hash: 97a15af525be79af393ee3c604797b631872c409
2023-11-14T15:44:34.463+09:00  INFO 69374 --- [    Test worker] tc.mysql:8.0.35                          : Reusing existing container (81a0dea4b23a4239dfad1b4d2c587d118a98d2bcf463155479563ebfc6c2e608) and not creating a new one

Reference

  1. 시작 가이드, https://testcontainers.com/guides/testing-spring-boot-rest-api-using-testcontainers/
  2. General Container runtime requirements, https://java.testcontainers.org/supported_docker_environment/

+ Recent posts