회사에서 재고 서비스의 재고 차감, 복구 로직에 기능을 추가하며 기존 로직이 복잡해 리더분과 이야기 후 리팩토링을 진행하게 되었다.
리택토링을 하면서 중요한 것은 좋은 테스트들이 있어 안심하고 리팩토링을 할 수 있는가이다.
기존 테스트의 문제점
그런데 기존 재고 서비스 테스트에는 아래와 같은 문제가 존재했다.
H2 데이터 베이스 사용 관련
- MySQL의 DDL을 그대로 사용할 수 없어 테스트를 위한 H2 전용 DDL 관리 필요
- H2와 MySQL의 다른 동작으로 인해 테스트는 통과하지만 실제 환경에서 이슈 발생 가능성 존재
테스트 데이터 관련
- 모든 테스트가 하나의 데이터 셋을 공통으로 사용
- 이로 인해 테스트와 데이터 간의 연관관계 파악이 어려움
- Insert문으로 작성되어 있고 컬럼이 많아 테스트 데이터의 특징을 직관적으로 파악하기 어려움
문제점 해결을 위한 TestConatienr, Flyway, DB Rider 적용
리팩토링을 위해 재고 차감, 복구 Integration 테스트가 많이 추가되어야 하는 상황에서 문제점을 해소하는 것이 낫다고 판단했다.
이를 위해 TestConatiner, Flyway, DB Rider 도입하였다.
- TestContainer를 통해 테스트 환경에서도 운영환경과 동일한 버전의 MySQL 사용
- 로컬 또는 CI/CD 환경에 Docker만 설치되어 있으면 간단한 설정만으로 테스트에 필요한 Container 사용 가능
- MySQL DDL만 관리하면 되며 Flyway로 쉽게 테스트 환경에 해당 스키마 적용 가능
- DB Rider를 활용해 테스트마다 적절한 데이터셋 적용
- 기본값을 정의해두고 테스트마다 달라져야 하는 값만 선언
- 테스트 데이터를 쉽게 준비하고 특징을 직관적으로 파악 가능
TestContainer DB Rider 사용법
Flyway는 이미 많이 알려져 있기 때문에 TestConatiner, DB Rider를 어떻게 사용했는지만 공유한다.
환경 정보
- Spring 버전: Spring Boot 2.6
- 운영 DB: MySQL 8.0
- 테스트 프레임워크: Spock
TestConatiner - 통합 테스트를 위한 상위 클래스 상속하여 테스트 작성
build.gradle
dependencies {
// testcontainer
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'
testImplementation 'mysql:mysql-connector-java'
}
mysql:mysql-connector-java 도 함께 추가되어 있어야 정상 동작한다.
(org.testcontainers.containers.MySQLContainer#getDriverClassName에 com.mysql.jdbc.Driver가 하드코딩 되어 있다)
통합 테스트 시 상속 받아 사용하는 용도로 설계된 클래스
import com.github.database.rider.core.api.configuration.DBUnit
import com.github.database.rider.spring.api.DBRider
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.testcontainers.containers.MySQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import spock.lang.Specification;
/**
* 통합 테스트 공통 설정을 위한 클래스로 통합 테스트 클래스에서 상속받아 사용한다.
*/
@DBRider
@DBUnit(caseSensitiveTableNames = true, allowEmptyFields = true, replacers = [BooleanToTinyintReplacer])
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class IntegrationTestSpecification extends Specification{
private static final String PROD_MYSQL_VERSION = "8.0"
@Container
public static MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:" + PROD_MYSQL_VERSION)
.withReuse(true)
.withUsername("testuser")
.withPassword("testpassword")
}
DB Rider 설정에 사용되는 어노테이션들(@DBRider, @DBUnit)도 함께 적어 두었다.
IntegrationTestSpecification를 활용한 테스트 예시
class IntegrationTestExampleSpec extends IntegrationTestSpecification {
@Autowired
FooRepository fooRepository
@DataSet(provider = TestDataSetProvider.class, cleanBefore = true)
def "DB에 저장된 Foo를 가져온다"() {
when:
def foo = fooRepository.findById(1L).orElse(null)
then:
foo != null
}
}
DB Rider - 중복을 최소화하는 방법으로 사용
build.gradle
dependencies {
// database rider
testImplementation 'com.github.database-rider:rider-spring:1.44.0'
}
DB Rider는 yml, json, csv 등으로 테스트 데이터를 쉽게 준비할 수 있게 해주지면 컬럼이 많은 경우 어쩔 수 없이 중복이 많아진다.
이를 해소하기 위해 코드에서 중복되는 부분에 대해 default value를 설정하고 각 테스트에서는 다른 부분만 재정의해서 데이터를 준비하는 방법을 사용했다.
import com.github.database.rider.core.api.dataset.DataSetProvider
import com.github.database.rider.core.configuration.DBUnitConfig
import com.github.database.rider.core.dataset.builder.DataSetBuilder
/**
* 테스트 데이터셋을 생성하는 기본 클래스로 각 컬럼에 대한 기본값 설정을 한다.
*/
abstract class DefaultDataSetProviderBase implements DataSetProvider {
protected final DEFAULT_PROPERTY_ID = 101
protected final DEFAULT_ROOM_TYPE_ID = 201
protected final DEFAULT_RATE_PLAN_ID = 301
protected final DEFAULT_RULESET_ID = 401
protected getBuilder() {
def builder = new DataSetBuilder(getDbUnitConfig())
setFooDefaultValue(builder)
setBarDefaultValue(builder)
return builder;
}
private getDbUnitConfig() {
def config = new DBUnitConfig()
config.addDBUnitProperty("caseSensitiveTableNames", true)
config.addDBUnitProperty("allowEmptyFields", true)
def replacers = (List)config.getProperties().get("replacers")
replacers.add(new BooleanToTinyintReplacer())
return config
};
private setFooDefaultValue(DataSetBuilder builder) {
builder.table("foo")
.defaultValue("id", 1)
.defaultValue("foo_name", "foo")
}
private setBarDefaultValue(DataSetBuilder builder) {
builder.table("bar")
.defaultValue("id", 2)
.defaultValue("name", "bar")
.defaultValue("foo_id", 1)
}
}
위 설정의 BooleanToTinyintReplacer는 boolean을 tinyint로 자동으로 변환하기 위해 직접 선언하여 추가하였다.
import com.github.database.rider.core.replacers.Replacer
import org.dbunit.dataset.ReplacementDataSet
class BooleanToTinyintReplacer implements Replacer {
@Override
void addReplacements(ReplacementDataSet replacementDataSet) {
replacementDataSet.addReplacementObject(true, 1)
replacementDataSet.addReplacementObject(false, 0)
}
}
테스트 코드예시
class IntegrationTestExampleSpec extends IntegrationTestSpecification {
@Autowired
FooRepository fooRepository
@DataSet(provider = TestDataSetProvider.class, cleanBefore = true)
def "DB에 저장된 Foo를 가져온다"() {
when:
def foo = fooRepository.findById(1L).orElse(null)
then:
foo != null
}
static class TestDataSetProvider extends DefaultDataSetProviderBase {
@Override
IDataSet provide() throws DataSetException {
def builder = getBuilder()
builder.table('foo')
// 필요한 컬럼만 선언
.columns('id', 'name', 'field1', 'field2')
.values(1L, "name1", true, false)
.values(2L, "name2", fale, true)
.values(3L, "name3", true, true)
.values(4L, "name4", flase, false)
return builder.build()
}
}
}
----
TestContainer, DB Rider는 통합 테스트를 위한 좋은 도구이고 이를 통해 아래와 같은 장점을 얻었다.
- 운영 환경와 테스트 환경의 불일치로 인한 이슈 완화
- 테스트마다 테스트 데이터의 특징을 쉽게 파악
- 테스트 코드에서 불필요한 Mock 제거
통합 테스트를 작성한다면 사용을 고려해보면 좋을 것 같다.
'제안&정리' 카테고리의 다른 글
Spring Boot 버전별 AWS Secret Manager 적용 방법 (0) | 2024.12.01 |
---|---|
[JAVA] ZoneId는 Java 실행 옵션으로 설정해보자 (0) | 2024.08.17 |
AWS Aurora 3 (MySQL 8 호환) BEFORE/AFTER 구문 등 테이블 리빌드 필요 시 리더에서 테이블 인식불가 버그 (0) | 2024.05.26 |
이제야 해보는 맥북 설정 자동화 (1) | 2024.04.28 |
[Git Strategy] 팀의 속도를 올리는 방법 - Ship Show Ask (0) | 2024.03.03 |