TestContainers 도입기(DataInitializer를 곁들인)

·2023년 10월 3일

프로젝트-요즘카페

목록 보기
10/12

로컬 환경에서 테스트 할 때 어떤 DB를 사용하시나요??

제 경우를 말씀드리자면,
요즘카페 서비스의 운영 환경에서는 MySQL을 사용하고 있습니다.
테스트 환경 또한 운영 환경과 최대한 유사하게 만들기 위해서 MySQL을 사용하려고 합니다.

근데 불편한 점들이 좀 있더라구요…

  • 테스트를 돌리는 로컬에 MySQL을 다 설치해줘야 함
    • 이미 다른 프로젝트에서 쓰고 있던 MySQL이 있다면? 포트 변경? 디비 공유?
    • username, password에 더해서 character set이나 TZ 등의 환경 변수도 맞춰줘야 함
    • 테스트 돌릴 때만 쓰고 싶은데 그때마다 키고 꺼야 하나…?
  • Docker Compose를 써서 MySQL을 띄운다면??
    • VCS를 통해서 컴포즈 파일을 관리할 수 있으니 위의 경우보다 훨씬 편함
    • 하지만 이 또한 컨테이너를 직접 CLI나 GUI로 키고 꺼줘야 함

TestContainers를 도입해서 위의 불편함을 해소해보려고 합니다.

TestContainers는 DSL로 간단하게 테스트에 필요한 의존 환경들을 구동시킬 수 있는 소프트웨어입니다.
레디스, 메세지큐, NoSQL 등 여러 의존이 필요해도 코드로 간단하게 테스트 때만 구동되도록 할 수 있죠!

이 글에서는 아래의 내용들을 기술합니다.

  • TestContainers 도입해서 MySQL 테스트 환경 구축하기
  • 테스트 간 DB Truncate 쉽게 하기

바로 도입해보죠!

TestContainers 도입해서 MySQL 테스트 환경 구축하기

1. Gradle 의존성 추가

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

위와 같이 build.gradle에 추가합니다.
저는 MySQL에 특화된 DSL을 쓰기 위해서 전용 의존성을 추가해줬습니다.

만약 다른 컨테이너들을 쓰고 싶다면
org.testcontainers:mysql 대신 org.testcontainers:testcontainers:1.19.0를 추가하면 됩니다.
그 후에 GenericContainer<> 타입으로 다른 모든 컨테이너를 다 쓸 수
있습니다!

이번 예제에서는 MySQLContainer<> 타입으로 사용하기 위해서 위와 같이 의존성을 추가했습니다.

좀 더 MySQL에 맞춤형 메서드를 사용할 수 있거든요😃
예를 들면 getJdbcUrl() 같은 메서드는 GenericContainer의 인터페이스에 없습니다.

2. Test 작성

@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class MemberRepositoryTest {

    @Container
    private static final MySQLContainer<?> container = new MySQLContainer<>("mysql:latest")
            .withDatabaseName("sandbox");

    @Autowired
    private MemberRepository memberRepository;

    @DynamicPropertySource
    static void setUpDataSource(final DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", container::getJdbcUrl);
        registry.add("spring.datasource.username", container::getUsername);
        registry.add("spring.datasource.password", container::getPassword);
    }

    @Test
    void 회원_저장() {
        //given
        final Member member = new Member();

        //when
        memberRepository.save(member);

        //then
        assertThat(member.getId()).isNotNull();
    }
}

위와 같이 간단히 회원을 저장하는 테스트를 작성했습니다.

어노테이션부터 하나씩 살펴볼게요!

  1. @TestContainer 어노테이션은 테스트 컨테이너를 자동으로 시작하고 종료하도록 하는 어노테이션입니다. container#start 등을 직접 @BeforeAll로 사용할 필요가 없습니다.
  2. @AutoConfigureTestDatabase 어노테이션은 인메모리 DB를 사용하지 않기 위함입니다. 스프링은 테스트 환경에서 DataSource를 인메모리 DB로 대체하는 것이 기본 정책입니다.
  3. @Container 어노테이션은 테스트 컨테이너라고 마킹하는 용도입니다. 해당 컨테이너를 @TestContainer 어노테이션이 자동으로 실행하고 종료하게 됩니다.
  4. @DynamicPropertySource 어노테이션은 스프링에서 제공하는 것입니다. application.properties 등으로 설정해둔 Environment 들 중 원하는 환경 설정만 덮어씌우기 위함입니다. 테스트 컨테이너를 DataSource로 사용하기 위해서 썼습니다.

테스트 컨테이너를 생성하는 방법은 간단합니다.

위와 같이 생성자로 인스턴스를 생성한 뒤 메서드 체이닝으로 여러 가지 설정을 할 수 있습니다.
DB의 아이디, 비밀번호, 권한 등등…

MySQLContainer<> 타입을 사용한 이유가 사실 여기 있습니다.

GenericContainer<> 타입으로 만들면 도커 관련 범용적인 DSL만 사용할 수 있습니다.

3. 테스트 시작

스프링이 시작되기 전에 도커 컨테이너가 먼저 시작되는 모습입니다.
포트 설정을 안해줬기 때문에 로컬 호스트의 사용 가능한 포트 중 랜덤으로 매핑됐습니다.

HikariCP도 도커로 뜬 MySQL과 커넥션을 맺었습니다.

테스트도 잘 성공하는 군요!!

4. 추가 정보

Getting Started Guides
공식 문서에 친절하게 다양한 예시가 작성돼있습니다.

  • AWS S3 같은 것은 보안 정책을 열어둘 수도 없고, 로컬에서 테스트하기 매우 힘듭니다.
    하지만 Testing AWS service integrations using LocalStack 이렇게 가짜 AWS 서비스를 사용해서 테스트할 수 있죠.
  • 컨테이너의 생명 주기를 직접 관리할 수 있습니다. 위의 예시에서는 @TestContainers 어노테이션이 관리하도록 했는데 직접 start()stop() 메서드를 통해 독립시킬 수 있습니다. 각 테스트별로 새로운 컨테이너를 사용할 수도 있고, 싱글톤으로 전체 테스트를 돌릴 수도 있죠!
    Testcontainers container lifecycle management using JUnit 5

테스트간 DB Truncate 쉽게 하는 법

DB의 데이터를 테스트가 끝날 때마다 초기화해주는 방법은 여러 가지가 있습니다.
단점과 함께 보자면,

  • @Transactional 사용
    • 의도치 않은 잘못된 테스트가 될 수 있음
  • @Sql 사용해서 끝날 때마다 삭제용 SQL 실행
    • 테이블이 추가될 때는 삭제용 SQL에 잊지 않고 추가해줘야 함
  • @BeforeEach 사용해서 JPARepository 등으로 직접 삭제
    • DB Constraints를 고려해서 순서대로 삭제해줘야 함
    • 사용되는 여러 테이블을 모두 삭제해줘야 하므로 내부 구현에 강하게 결합됨
  • 테스트 컨테이너를 사용한다면 각 메서드별로 새로 컨테이너 start()
    • 도커 컨테이너를 만들고 종료시키는 비용은 매우 큼

위의 방법들 대신 자동으로 테이블을 Truncate 시켜주는 DataInitializer를 구현해보려고 합니다.

DataInitializer

테이블명들을 얻어서 모두 TRUNCATE 해주는 책임을 지닌 DataInitializer를 구현해보겠습니다.

@Profile("test")
@Component
public class DataInitializer {

    private static final String OFF_FOREIGN_CONSTRAINTS = "SET foreign_key_checks = false";
    private static final String ON_FOREIGN_CONSTRAINTS = "SET foreign_key_checks = true";
    private static final String TRUNCATE_SQL_FORMAT = "TRUNCATE %s";

    private static final List<String> truncationDMLs = new ArrayList<>();

    protected DataInitializer() {
    }

    @PersistenceContext
    private EntityManager em;

    @Transactional(value = TxType.REQUIRES_NEW)
    public void deleteAll() {
        if (truncationDMLs.isEmpty()) {
            init();
        }

        em.createNativeQuery(OFF_FOREIGN_CONSTRAINTS).executeUpdate();
        truncationDMLs.stream()
                .map(em::createNativeQuery)
                .forEach(Query::executeUpdate);
        em.createNativeQuery(ON_FOREIGN_CONSTRAINTS).executeUpdate();
    }

    private void init() {
        final List<String> tableNames = em.createNativeQuery("SHOW TABLES ").getResultList();

        tableNames.stream()
                .map(tableName -> String.format(TRUNCATE_SQL_FORMAT, tableName))
                .forEach(truncationDMLs::add);
    }
}
  • 다른 곳에서 주입 받아서 쓰기 위해 스프링 빈으로 등록했습니다.
    • 테스트 때만 쓰기 위해 @Profile로 프로파일을 지정해줬습니다.
  • 인터페이스는 deleteAll()만 존재합니다. 모든 테이블의 데이터를 제거하는 메서드입니다.
    • 런타임 때 테이블이 추가되거나 변경되는 경우는 없다고 생각해서 최초 한 번만 SHOW TABLES문으로 truncationDMLs를 초기화해주도록 했습니다.
    • @TransactionalEntityManager가 동작하게 하기 위함입니다. value는 테스트 트랜잭션 내에서 호출하더라도 독립적으로 커밋되게 하기 위해서 달아뒀습니다.
  • MySQL 기준으로 Foreign Constraints를 잠시 꺼주고 제거해줬습니다.
    • 연관 관계의 순서대로 지워줄 필요가 없도록 하기 위함입니다.

Native Query로 모든 테이블들의 이름을 얻고, Truncate 해주기 때문에 DB 스키마가 바뀌어도 이 객체는 수정될 필요가 없습니다.

@Autowired
private DataInitializer dataInitializer;

@BeforeEach
void deleteAll() {
    dataInitializer.deleteAll();
}

사용하고자 하는 테스트 클래스에서 위와 같이 사용하면 됩니다.

위와 같이 잘 동작합니다!

현재 프로젝트에서는…

/**
 * Application Context들이 필요할 때 상속하면 됩니다.
 * Repository, Service 레이어 통합 테스트용으로 쓰면 됩니다.
 */
@SpringBootTest
@ActiveProfiles("test")
public abstract class BaseTest {

    private static final String ROOT = "root";
    private static final String ROOT_PASSWORD = "test";

    @Autowired
    private DataInitializer dataInitializer;
    protected static MySQLContainer container;

    static {
        container = (MySQLContainer) new MySQLContainer("mysql:8.0")
                .withDatabaseName("yozm-cafe")
                .withEnv("MYSQL_ROOT_PASSWORD", ROOT_PASSWORD);

        container.start();
    }

    @DynamicPropertySource
    static void configureProperties(final DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", container::getJdbcUrl);
        registry.add("spring.datasource.username", () -> ROOT);
        registry.add("spring.datasource.password", () -> ROOT_PASSWORD);
    }

    @BeforeEach
    void delete() {
        dataInitializer.deleteAll();
    }
}

요즘카페 의 코드 중 일부분입니다.

저희 프로젝트에서는 통합 테스트용 추상 클래스를 만들었습니다.
테스트용 컨테이너를 띄우고, 각 테스트마다 데이터를 초기화합니다.

감사합니다😃

profile
渽晛

0개의 댓글