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

TestContainers는 DSL로 간단하게 테스트에 필요한 의존 환경들을 구동시킬 수 있는 소프트웨어입니다.
레디스, 메세지큐, NoSQL 등 여러 의존이 필요해도 코드로 간단하게 테스트 때만 구동되도록 할 수 있죠!
바로 도입해보죠!
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의 인터페이스에 없습니다.
@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();
}
}
위와 같이 간단히 회원을 저장하는 테스트를 작성했습니다.
@TestContainer 어노테이션은 테스트 컨테이너를 자동으로 시작하고 종료하도록 하는 어노테이션입니다. container#start 등을 직접 @BeforeAll로 사용할 필요가 없습니다.@AutoConfigureTestDatabase 어노테이션은 인메모리 DB를 사용하지 않기 위함입니다. 스프링은 테스트 환경에서 DataSource를 인메모리 DB로 대체하는 것이 기본 정책입니다.@Container 어노테이션은 테스트 컨테이너라고 마킹하는 용도입니다. 해당 컨테이너를 @TestContainer 어노테이션이 자동으로 실행하고 종료하게 됩니다.@DynamicPropertySource 어노테이션은 스프링에서 제공하는 것입니다. application.properties 등으로 설정해둔 Environment 들 중 원하는 환경 설정만 덮어씌우기 위함입니다. 테스트 컨테이너를 DataSource로 사용하기 위해서 썼습니다.
위와 같이 생성자로 인스턴스를 생성한 뒤 메서드 체이닝으로 여러 가지 설정을 할 수 있습니다.
DB의 아이디, 비밀번호, 권한 등등…
MySQLContainer<> 타입을 사용한 이유가 사실 여기 있습니다.

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

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

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

테스트도 잘 성공하는 군요!!
Getting Started Guides
공식 문서에 친절하게 다양한 예시가 작성돼있습니다.
@TestContainers 어노테이션이 관리하도록 했는데 직접 start()와 stop() 메서드를 통해 독립시킬 수 있습니다. 각 테스트별로 새로운 컨테이너를 사용할 수도 있고, 싱글톤으로 전체 테스트를 돌릴 수도 있죠!DB의 데이터를 테스트가 끝날 때마다 초기화해주는 방법은 여러 가지가 있습니다.
단점과 함께 보자면,
@Transactional 사용@Sql 사용해서 끝날 때마다 삭제용 SQL 실행@BeforeEach 사용해서 JPARepository 등으로 직접 삭제start()위의 방법들 대신 자동으로 테이블을 Truncate 시켜주는 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를 초기화해주도록 했습니다.@Transactional은 EntityManager가 동작하게 하기 위함입니다. value는 테스트 트랜잭션 내에서 호출하더라도 독립적으로 커밋되게 하기 위해서 달아뒀습니다.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();
}
}
요즘카페 의 코드 중 일부분입니다.
저희 프로젝트에서는 통합 테스트용 추상 클래스를 만들었습니다.
테스트용 컨테이너를 띄우고, 각 테스트마다 데이터를 초기화합니다.
감사합니다😃