이전 포스팅에서 애플리케이션을 컨테이너화 하기 위해 도커를 사용했습니다.
(이전 포스팅 : 2024.09.13 - [Project] - [예약 대기 시스템] 3. 프로젝트 설정 (어드민 시스템))
테스트 코드를 작성하며 컨테이너 환경에서 테스트를 하는 것이 보통 귀찮은 일이 아님을 느꼈습니다.
매번 테스트를 할 때마다 MySQL 컨테이너를 띄워야 하고, 독립된 테스트를 위해 테스트마다 컨테이너를 내리거나, 테스트한 데이터를 지워야 했습니다.
테스트 환경을 구축하는데 번거로운 점을 해결하기 위해 이번 포스팅에선 컨테이너 환경에서 테스트를 하는 방법에 대해 알아보겠습니다.
컨테이너 환경에서 테스트의 어려움
- 컨테이너 환경 구성: 테스트를 시작하기 전 로컬(또는 테스트 서버) 환경에서 관련 컨테이너들(db, was 등)이 띄워져 있어야 합니다.
- 멱등성: 독립적인 테스트를 위해 테스트가 종료될 때마다 테스트한 내용을 제거해야 합니다.
컨테이너 환경 구성을 쉽게 만들어 주는 Testcontainers 도입
컨테이너 환경에서 테스트를 쉽게 하는 방법을 찾다 Testcontainers 를 발견했습니다.
찾아본 Testcontainer 의 장점은 다음과 같습니다.
- 도커 컨테이너 기술을 사용하므로 동일한 테스트 환경을 보장합니다.
- 여러 RDBMS 뿐만 아니라, 다양한 메시지 브로커 등을 지원하기 때문에 높은 확장성을 기대할 수 있습니다.
- Spring Boot 통합 환경을 제공하기 때문에 간편한 설정으로 테스트 컨테이너를 쉽게 제어할 수 있습니다.
이런 장점들로 인해 JUnit5 환경에서 사용할 Testcontianers 를 도입하기로 결정했습니다.
Testcontainers 를 사용하는 여러 방법들이 있습니다. 그리고 Best Practice 예제도 있습니다. (Testcontainers Best Practices)
여러 기술 블로그들의 예제들은 대부분 테스트 코드를 새로 작성할 때마다 MySQLContainer 를 생성하는 코드를 추가로 작성해야 하는 부분이 있는데, 아래는 제가 생각하는 Best Practice 라고 생각하는 방법입니다.
의존성 추가
https://start.spring.io/ 에서 Testcontainers 를 공식적으로 지원합니다.
하지만 이 의존성 추가만으로는 부족합니다.
만약 처음부터 사용하는 데이터베이스 드라이버를 추가했다면 자동으로 관련 Testcontainers 드라이버도 추가되지만,
저는 나중에 추가했기 때문에 아래와 같이 `build.gradle` 에 의존성을 추가하면 됩니다.
MySQL 드라이버를 사용할 때 의존성 추가
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:mysql'
testImplementation 'org.testcontainers:junit-jupiter'
테스트 환경 설정 파일 작성
테스트할 때는 배포 환경에 사용할 버전과 동일한 환경을 만들어야 합니다.
따라서 프로필 파일(application-test.yaml
)을 만들겠습니다.
spring:
config:
activate:
on-profile: test
datasource:
url: jdbc:tc:mysql:8.0.39:///test_db?TC_INITSCRIPT=file:src/test/resources/scheme.sql
username: test
password: test1234
- 주요 설명
- 이젠 Spring boot 2.3.0 이하 버전을 쓰는 경우가 없으므로 datasource 의 driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver 를 더 이상 입력하지 않아도 됩니다. (공식 사이트 설명)
- url 부분
- jdbc:mysql 가 아닌 jdbc:tc:mysql 를 사용해야 합니다.
- jdbc:tc:mysql:8.0.39: 8.0.39 부분은 프로젝트에서 사용하는 MySQL 이미지 버전입니다. 다른 버전을 사용하신다면 사용하실 버전을 입력하시면 됩니다.
- jdbc:tc:mysql:8.0.39:///test_db?TC_INITSCRIPT=file:src/test/resources/scheme.sql
///
는 host-less URI 를 의미합니다. (host:port 생략) (공식 사이트 설명)
따라서jdbc:tc:mysql:8.0.39:///databasename
는jdbc:tc:mysql:8.0.39://localhost:3306/databasename
와 동일합니다. - jdbc:tc:mysql:8.0.39:///test_db?TC_INITSCRIPT=file:src/test/resources/scheme.sql컨테이너를 시작할 때 sql 스크립트를 실행시킵니다. 실행할 스크립트 파일의 위치를 지정해야 합니다.
-file:
형태로 지정하면 프로젝트의 루트 폴더(애플리케이션이 실행되는 working directory)부터 경로를 지정합니다.
-?TC\_INITSCRIPT=/somepath/scheme.sql
형태로 지정하면 classpath 로 지정합니다.
참고로, jpa (hibernate)에서 제공하는 ddl-auto 옵션은 적용이 되지 않습니다. 스키마를 먼저 실행해서 테이블을 만들어야 합니다.
테스트 코드 작성
JUnit5 환경에서 테스트 코드를 작성할 때 위에서 작성한 프로필 파일을 활성화하기 위해 @ActiveProfiles("test")
애너테이션과 @Testcontainers
애너테이션만 붙이면 됩니다.
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class OfficeTest {
@Autowired
private OfficeRepository officeRepository;
@DisplayName("은행 계좌 2개를 갖는 지점 생성에 성공한다")
@Test
void createOffice() {
// given
Office office = Office.builder()
.name("강남점")
.welcomeMessage("강남점 방문을 환영합니다.")
.build();
Account account1 = Account.of("국민은행", "123-456-78910");
Account account2 = Account.of("신한은행", "987-654-32100");
office.addAccount(account1);
office.addAccount(account2);
// when
Office savedOffice = officeRepository.save(office);
// then
assertThat(savedOffice).isEqualTo(office);
assertThat(savedOffice.getName()).isEqualTo("강남점");
assertThat(savedOffice.getAccounts()).containsOnly(account1, account2);
assertThat(savedOffice.getCreatedAt()).isBefore(LocalDateTime.now());
assertThat(savedOffice.getUpdatedAt()).isBefore(LocalDateTime.now());
}
@DisplayName("2개의 계좌 중 1개를 삭제할 수 있다")
@Test
void removeAccount() {
// given
Office office = Office.builder()
.name("강남점")
.welcomeMessage("강남점 방문을 환영합니다.")
.build();
Account account1 = Account.of("국민은행", "123-456-78910");
Account account2 = Account.of("신한은행", "987-654-32100");
office.addAccount(account1);
office.addAccount(account2);
Office savedOffice = officeRepository.save(office);
// when
savedOffice.removeAccount(Account.of("국민은행", "123-456-78910"));
// then
assertThat(savedOffice.getAccounts()).hasSize(1);
assertThat(savedOffice.getAccounts()).containsOnly(account2);
}
}
테스트 실행
실행 결과를 보면 2번째 테스트는 40ms 가 걸렸습니다.
첫 번째 테스트와 동일한 컨테이너를 사용한 것으로 보입니다.
테스트 로그 확인
테스트가 모두 종료되고 나서 로그를 확인해 보니 mysql 컨테이너는 한 번만 생성되었습니다.
도커 컨테이너 목록도 두 개의 테스트가 진행되는 동안 mysql 컨테이너는 한 개만 띄워졌습니다.
같은 컨테이너를 재사용하면 어느 테스트보다 먼저 실행된 테스트의 결과에 의해 영향을 받을 수 있습니다.
멱등성이 보장되는지 확인해 보겠습니다.
멱등성 보장 확인하기
먼저 레코드를 입력하기 전에 mysql 내에 실행한 쿼리를 로그로 남길 수 있도록 설정하겠습니다.
Testcontainers 가이드에 따르면 mysql 은 root 계정을 사용하는데, 아이디와 패스워드는 아래와 같습니다.
- 데이터베이스 이름: test
- 루트 계정: root
- 루트 계정의 비밀번호: test
순서는 아래대로 진행합니다.
- 컨테이너 내 MySQL 서버에 접속한다.
- 쿼리 실행 이력을 로그로 남기는 설정을 한다.
- 테스트 후 쿼리 실행 로그와 테이블을 조회한다.
1. 컨테이너 내 MySQL 서버 접속
먼저 mysql 컨테이너에 접속합니다.
docker exec -it 7102b7877db1 bash
그리고, mysql 서버에 접속합니다.
mysql -u root -p
# 이후 비밀번호로 test 입력 후 엔터
2. 로그 설정
- general_log 값과 로그 파일이 저장되는 위치 확인
mysql> show variables like 'general%';
- general_log 켜기
mysql> set global general_log=on;
3. 테스트 실행 후 로그 확인
cat /var/lib/mysql/7102b7877db1.log
- 테이블 확인
# 데이터베이스 선택
mysql> select test;
# 테이블들 조회
mysql> show tables;
# office 테이블 조회
mysql> SELECT * FROM office;
멱등성을 보장하게 하기
직접 확인해 본 결과 컨테이너를 재사용하기 때문에 멱등성을 보장하지 않는 것을 눈으로 확인할 수 있었습니다.
컨테이너를 재사용하면 여러 테스트를 실행하는데 빠를지는 몰라도 독립된 테스트임을 보장하기 어렵습니다.
따라서 각 테스트가 독립된 컨테이너에서 테스트될 수 있도록 설정해 보겠습니다.
방법 1. 새로운 MySQLContainer 생성하기
테스트를 실행할 때마다 단일 datasource 에서 생성된 컨테이너가 아닌, 새로운 MySQLContainer 를 띄우는 방법입니다.
이 방법을 이용하면 매번 테스트마다 새로운 컨테이너가 생기는 것을 확인할 수 있습니다.
주의할 점으로는 static 필드로 생성하면 컨테이너를 재사용하기 때문에 static 을 사용하면 안 됩니다. (관련 블로그)
하지만 새로운 문제도 생기는데요,
테스트 실행 시 최초 한 번이긴 하지만 application-test.yaml
에 설정한 datasource 에 의해 MySQL 컨테이너가 생성됩니다.
- 테스트 실행 시 musing_franklin 이름으로 datasource 에 의한 MySQL 컨테이너가 띄워집니다.
- 첫 번째 테스트 때 youthful_elbakyan 이름으로 새 컨테이너가 띄워집니다.
- 두 번째 테스트 때 festive_chebyshev 이름으로 새 컨테이너가 띄워집니다.
yaml 파일 없이 MySQLContainer 를 생성하는 코드를 테스트 코드에 작성하면 각 테스트마다 컨테이너가 생성되지만,
테스트를 작성할 때마다 해당 코드를 집어넣거나, 추상 클래스로 선언 후 상속시켜야 합니다.
그리고 또 다른 문제로 컨테이너를 실행시키고 종료하는 것을 테스트마다 반복하기 때문에 실행속도가 매우 느려집니다.
(기존 500ms 내외 => 14초 내외로 증가)
방법 2. 메서드에 @Transactional 애너테이션 붙이기 (권장)
테스트 클래스의 메서드에 @Transactional
애너테이션을 붙이면 테스트가 종료될 때마다 롤백을 수행합니다.
이 방법이 가장 간편한 방법이면서, 컨테이너를 재사용하기 때문에 실행속도가 빠릅니다.
따라서 해당 방법을 사용해서 멱등성 보장을 해결했습니다.
읽어주셔서 감사합니다.
참고
'Project' 카테고리의 다른 글
[포텐데이 409-1pick] 올려올려 라디오 서비스 개발기 - 2 (2) | 2024.10.08 |
---|---|
[포텐데이 409-1pick] 올려올려 라디오 서비스 개발기 - 1 (4) | 2024.10.07 |
[예약 대기 시스템] 3. 프로젝트 설정 (어드민 시스템) (1) | 2024.09.13 |
[예약 대기 시스템] 2. 어드민 시스템 데이터 모델링 (개체-관계 모델, ERD) (1) | 2024.09.12 |
[예약 대기 시스템] 1. 답답하니까 직접 만들게요 (1) | 2024.09.11 |