Test-Container으로 테스트 코드 작성하기

devty·2024년 5월 9일
1

SpringBoot

목록 보기
4/11

테스트 코드와 멱등성의 상관관계

  • 테스트 코드에서 멱등성(idempotence)은 테스트가 얼마나 신뢰할 수 있고 재현 가능한지를 결정하는 중요한 요소이다.
  • 멱등성을 가진 테스트는 동일한 조건과 입력 값으로 여러 번 수행되어도 동일한 결과를 보장한다.
  • 이는 테스트의 신뢰성을 높이고, 다양한 환경에서의 안정적인 동작을 보장하는 데 필수적이다.

멱등성의 중요성

  1. 신뢰성: 테스트가 멱등성을 보장하면, 테스트 결과는 일관되고 예측 가능한다. 이는 개발자가 테스트 결과에 의존할 수 있게 하며, 코드 변경에 따른 영향을 정확히 이해하는 데 도움을 준다.
  2. 재현성: 멱등한 테스트는 다른 개발자의 환경이나 CI/CD 파이프라인에서도 동일한 결과를 생성한다. 이는 환경 구성 오류나 의존성 문제로 인한 잘못된 실패를 방지하는 데 중요하다.
  3. 디버깅 용이성: 멱등성이 보장되면 테스트 실패의 원인을 찾기가 더 쉬워진다. 테스트가 실패할 경우, 환경 설정이나 외부 요인보다는 코드 자체에 문제가 있을 가능성이 높아서 디버깅이 더 직관적이고 간결해진다.

DB 통합 테스트 전략

  1. Local에 실제 DB를 띄우기
    • 정의
      • 개발자의 로컬 컴퓨터나 공유된 서버에 데이터베이스 인스턴스를 직접 설치하여 테스트 환경을 구성하는 방법이다.
    • 장점
      • 실제 운영 환경과 가장 유사한 테스트 환경을 제공한다.
      • 특정 데이터베이스의 모든 기능과 성능을 정확하게 평가할 수 있다.
    • 단점
      • 설치와 설정이 번거롭고 시간이 많이 소요될 수 있다.
      • 데이터베이스 관리와 테스트 데이터의 초기화 및 복원이 필요하며, 여러 테스트 간 격리가 어려울 수 있다.
    • 예시
      • 팀에 새로운 개발자가 합류할 때마다, 해당 개발자의 로컬 머신에 데이터베이스를 설치하고, 필요한 모든 설정을 일일이 수행해야 한다.
      • 이 과정에는 데이터베이스의 설치, 초기 데이터 세팅, 네트워크 구성 등이 포함된다.
      • 설정 과정에서 발생할 수 있는 오류나 호환성 문제로 인해 개발자가 실제 작업을 시작하기까지 상당한 시간이 소요될 수 있다.
  2. in-memory DB 활용하기
    • 정의
      • 메모리 내에서 실행되는 데이터베이스 (예: H2, SQLite)를 사용하여 테스트를 수행하는 방법이다. 이 방법은 데이터를 디스크가 아닌 RAM에 저장한다.
    • 장점
      • 설치가 필요 없고 구성이 간단하여 빠르게 테스트 환경을 구축할 수 있다.
      • 테스트가 빠르게 실행된다.
    • 단점
      • 실제 운영 환경의 데이터베이스와 기능이나 성능 면에서 차이가 있을 수 있다.
      • 복잡한 트랜잭션이나 특정 데이터베이스 기능을 지원하지 않을 수 있다.
    • 예시
      • 특정 금융 서비스 개발 중, 복잡한 트랜잭션을 관리해야 하는 경우, in-memory 데이터베이스는 제한된 트랜잭션 관리 기능으로 인해 실제 데이터베이스에서 발생할 수 있는 잠금(locking)이나 동시성(concurrency) 이슈를 정확히 시뮬레이션하지 못할 수 있다.
      • 이로 인해 개발 환경에서는 문제가 없어 보였지만, 실제 운영 환경으로 배포했을 때 예상치 못한 버그와 성능 문제가 발생할 수 있다.
  3. 사용하고자 하는 DB의 Embedded Library 사용하기
    • 정의
      • 실제 데이터베이스의 경량화된 버전을 임베디드 라이브러리 형태로 사용하는 방법이다.
    • 장점
      • 실제 데이터베이스의 동작을 상당 부분 재현할 수 있다.
      • 테스트 환경 구성이 비교적 간단하며 데이터 격리가 용이하다.
    • 단점
      • 모든 데이터베이스가 이러한 라이브러리를 제공하지는 않다.
      • 운영 환경의 데이터베이스와 완전히 동일하지 않을 수 있으며, 일부 기능이 제한적일 수 있다.
    • 예시
      • 프로젝트에서 특정 데이터베이스의 임베디드 라이브러리를 활용하여 개발을 진행하고 있었지만, 라이브러리 개발사가 갑작스럽게 지원을 중단하거나 업데이트를 중단할 경우
      • 이로 인해 새로운 데이터베이스 버전에 대한 테스트가 불가능해지고, 프로젝트는 구버전에 묶여 기술적 부채가 증가하며, 보안 취약점에 대한 우려가 커질 수 있다.
  4. TestContainer 활용하기
    • 정의
      • Docker 컨테이너를 사용하여 테스트 전용 데이터베이스 인스턴스를 동적으로 생성하고 관리하는 방법입니다. 테스트가 완료되면 컨테이너를 손쉽게 제거할 수 있다.
    • 장점
      • 실제 운영 환경과 유사한 테스트 환경을 제공한다.
      • 각 테스트마다 독립된 환경을 구성할 수 있어, 데이터 격리와 환경 충돌을 방지한다.
      • 다양한 버전의 데이터베이스를 손쉽게 테스트할 수 있다.
    • 단점
      • Docker와 TestContainers 사용에 대한 초기 설정과 학습이 필요하다.
      • 테스트의 실행 시간이 컨테이너 시작 시간에 의존적일 수 있으며, 리소스 사용량이 많을 수 있다.
  • 결론
    • 각각 모든 도입에는 장단점이 있다. 그 중에서 프로젝트에 맞게 사용하면 될텐데 나는 이번에 TestContainer를 활용하기로 마음 먹었다.
    • 이유는 테스트 실행 시간이 좀 길어져도 어느정도 파훼할 방법들은 몇가지 존재하는 것 같다.
    • 그래서 단점을 최대한 보안할 수 있는 TestContainer를 사용하도록 하겠다.

도입 방법

  • Build.gradle
    
    dependencies {
        // MariaDB (test 전용) -> 우리는 일단 이것만 확인 할 예정.
        testImplementation 'org.testcontainers:mariadb:1.17.6'
    
        // Redis (test 전용)
        testImplementation 'org.testcontainers:redis:1.17.6'
    
        // Kafka (test 전용)
        testImplementation "org.testcontainers:kafka:1.17.6"
    }
    
    • 필요한 부분에 대해 각 테스트 컨테이너를 dependencies에 넣어준다.
  • IntegrationTestSupport
    @Testcontainers
    @SpringBootTest
    @ActiveProfiles("test")
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    public class IntegrationTestSupport {
    
        // MariaDB 컨테이너 설정
        @Container
        static MariaDBContainer<?> MARIADB_CONTAINER = new MariaDBContainer<>("mariadb:10.11");
    }
    
    • @Testcontainers
      • 이 클래스는 Testcontainers 라이브러리를 사용하여 테스트 동안 Docker 컨테이너를 관리하도록 지시한다.
      • Testcontainers는 테스트가 실행될 때 컨테이너를 자동으로 시작하고, 테스트가 완료된 후에 컨테이너를 정리합니다.
    • @SpringBootTest
      • Spring Boot 기반의 애플리케이션을 테스트하기 위해 필요한 애노테이션이다.
      • 이 애노테이션은 전체 Spring 애플리케이션 컨텍스트를 로드하여, 실제 애플리케이션 환경에서 실행되는 것처럼 테스트 환경을 구성한다.
    • @ActiveProfiles("test")
      • test 프로필을 활성화하는데 사용됩니다.
      • Spring 환경에서 프로필은 애플리케이션의 설정을 환경별로 구분할 때 사용됩니다. 예를 들어, 테스트 환경에서는 특정한 데이터베이스 설정이나 특정 로깅 레벨을 적용할 수 있다.
    • @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
      • 이 애노테이션은 Spring Boot 테스트에서 제공하는 내장형 데이터베이스 설정을 사용하지 않고, 테스트에서 정의한 데이터베이스 설정을 그대로 사용하도록 설정한다.
      • 여기서는 Replace.NONE 옵션을 통해 어떠한 자동 구성도 대체하지 않고, 테스트에서 지정한 MariaDB 컨테이너를 그대로 사용하게 한다.
    • @Container
      • Testcontainers 라이브러리에 의해 관리되는 컨테이너임을 나타내는 애노테이션이다.
      • 이 애노테이션을 사용함으로써 해당 필드에 선언된 컨테이너는 테스트 수명 주기에 맞춰 자동으로 시작하고 정지된다.
    • static MariaDBContainer<?> MARIADB_CONTAINER = new MariaDBContainer<>("mariadb:10.11");
      • MariaDB의 Docker 이미지를 사용하여 컨테이너를 생성합니다. mariadb:10.11은 사용할 MariaDB의 버전을 지정한다. → 각 프로젝트 버전에 맞춰서 설명하면 됨.
      • 이 컨테이너는 테스트가 실행될 때 시작되어, 테스트에 필요한 데이터베이스 서비스를 제공하게 된다.
      • static으로 둔 이유는 통합 테스트를 수행하는 테스트 클래스마다 컨테이너를 새로 실행하기 때문에 모든 테스트 메서드에서 컨테이너를 실행하는 비용을 줄일수 있었음.
    • 테스트 데이터를 삽입하고 싶다면?
      @Container
      static MariaDBContainer<?> MARIADB_CONTAINER = new MariaDBContainer<>("mariadb:10.11")
        .withInitScript("db/init.sql");
      • 이렇게 스크립트를 실행시킬수 있다.
  • 그럼 이제 어떤 계층에서 사용할지 정해야한다. 그건 밑에서 정리하겠다.

각 계층별 Testcontainers 사용 여부

테스트 계층Testcontainers 사용적합한 테스트 시나리오대안적 테스트 방법
Controller일반적으로 부적합N/A@WebMvcTest, @MockMvc를 통한 HTTP 요청 및 응답 검증
Service조건적으로 적합외부 시스템과의 복잡한 통합을 포함한 비즈니스 로직 검증@MockBean 사용하여 서비스 의존성 모의
Repository적합실제 데이터베이스 인터랙션을 통한 데이터 접근 로직 검증@DataJpaTest 사용, 인-메모리 데이터베이스(H2) 활용
  • Controller 계층
    • 주로 HTTP 요청 처리와 관련된 로직을 테스트하기 때문에, Testcontainers의 사용은 과도할 수 있다.
    • 대신, MockMvc를 사용하여 컨트롤러의 요청 경로, 매개변수 처리, 응답 형식을 효과적으로 검증할 수 있다.
  • Service 계층
    • 외부 시스템(Redis, ElasticSearch, Kafka등)과의 통합이 중요한 비즈니스 로직을 포함할 때 Testcontainers를 사용하는 것이 유익하다.
    • 단순히 내부 로직을 검증하는 경우에는 서비스 의존성을 모의화하여 충분히 테스트할 수 있다.
  • Repository 계층
    • 실제 데이터베이스와의 상호작용을 검증하기 때문에 Testcontainers가 매우 유용하다.
    • 데이터베이스 스키마, SQL 쿼리 등이 실제 운영 환경과 같은 방식으로 동작하는지 확인할 수 있다.
  • 결론
    • Service 계층은 외부 시스템이 없을 경우는 굳이 안 해줘도 되지만, Repository 계층은 거의 필수적으로 해주는게 좋아보인다.
    • 그렇다면, Repository 계층은 Testcontainers로 통합적인 테스트를 하게 되는데 이런 경우 따로 단위 테스트를 해줘야하는가? 의문이 든다.
    • 그건 바로 밑에서 설명해 보도록 하겠다.

Repository 계층의 단위 테스트

  • 단위 테스트는 각 컴포넌트(여기서는 레포지토리 메소드)가 독립적으로 정확하게 작동하는지 검증하는데 초점을 맞춘다.
  • Repository 계층에 대한 단위 테스트는 일반적으로 인-메모리 데이터베이스(H2, Derby 등)를 사용하거나, Mockito와 같은 모킹 툴을 활용하여 JPA 메소드를 모킹한다.
  • 장점
    • 실행 속도: 인-메모리 데이터베이스를 사용하면 테스트 실행 속도가 매우 빠르다.
    • 환경 설정의 간결성: 복잡한 데이터베이스 설정이 필요 없으며, 스프링 부트가 자동으로 구성을 관리한다.
  • 단점
    • 리얼리티 부족: 실제 운영 데이터베이스의 행동을 완벽하게 모방하지 못할 수 있다. SQL 방언이나 데이터베이스 특유의 동작을 재현하기 어려움이 있다.
    • 커버리지 제한: 데이터베이스 스키마의 변경사항이나 마이그레이션 스크립트 등을 테스트하지 못할 수 있다.

Repository 계층의 통합 테스트 (Testcontainers 사용)

  • Testcontainers를 사용한 통합 테스트는 실제와 유사한 데이터베이스 환경을 설정하여 실제 데이터베이스와의 상호작용을 포함한 테스트를 수행합니다.

장점:

  • 실제 환경과의 일치: 실제 운영 데이터베이스와 동일한 데이터베이스를 사용하여 테스트할 수 있다. 이는 마이그레이션, SQL 쿼리 등의 정확한 검증을 가능하게 한다.
  • 결함 발견 가능성: 데이터베이스와의 상호작용 중 발생할 수 있는 예외나 오류를 초기에 발견할 수 있다.

단점:

  • 설정 복잡성: Testcontainers를 설정하고 관리하는 데 추가적인 러닝커브가 필요하다.
  • 실행 속도 저하: 실제 데이터베이스 인스턴스를 실행하는 데 시간이 소요될 수 있다.

Repository 계층의 단위 테스트 vs 통합테스트 결론

  • 레포지토리 계층의 경우, 테스트의 목적이 실제 데이터베이스와의 상호작용을 포함하여 정확성을 검증하는 것이라면 Testcontainers를 사용한 통합 테스트가 더 적합할 수 있다.
  • 특히, 실제 환경에서 사용되는 데이터베이스 기능이나 복잡한 트랜잭션, 특정 SQL 기능 등을 사용하는 경우에는 이 방식이 훨씬 유리하다.
  • 단위 테스트는 개발 초기 단계에서 빠른 피드백을 제공하고 간단한 CRUD 연산을 검증하는 데 유용하나, 최종적인 통합 테스트를 통해 시스템의 완전성을 확보하는 것이 좋다.

Repository 계층 테스트 코드

  • AccountRepositoryTest
    @DataJpaTest
    class AccountRepositoryTest extends IntegrationTestSupport {
    
        @Autowired
        private TestEntityManager entityManager;
    
        @Autowired
        private AccountRepository accountRepository;
    
        @Test
        public void test() {
            // Given
            Account account1 = new Account(null, "John Doe", 1000.00);
            Account account2 = new Account(null, "John Doe", 500.00);
            entityManager.persist(account1);
            entityManager.persist(account2);
            entityManager.flush();
    
            // When
            List<Account> accounts = accountRepository.findByOwnerAndBalanceGreaterThan("John Doe", 750);
    
            // Then
            assertThat(accounts).hasSize(1);
            assertThat(accounts.get(0).getBalance()).isEqualTo(1000.00);
        }
    }
    • 아까 위에서 구현한 IntegrationTestSupport를 상속받아 사용하였다.
    • 별 특별한 코드 없이 기존에 사용했던 방식에 상속만 받은 코드이다.
  • 잘 돌아가는지 테스트 코드를 돌려보면 다음과 같은 동작을 한다.
    • 테스트가 실행 됨과 동시에 도커 컨테이너 두개가 동작한다.
    • 첫번째로 실행된 컨테이너는 Moby Ryuk로 컨테이너, 네트워크, 볼륨, 이미지를 제거하는데 사용된다.
    • 두번로 실행된 컨테이너는 JdbcDatabaseContainer로 객체 생성 시 생성자의 인수로 할당한 도커 이미지 기반의 컨테이너인 mariadb로 테스트에 사용할 DBMS이다.

@SpringBootTest는?

  • Spring Boot 애플리케이션에 대한 "완전 통합 테스트"를 설계할 때, Testcontainers를 사용하는 것이 많은 이점을 제공할 수 있다.
  • 완전 통합 테스트는 애플리케이션의 모든 구성 요소와 외부 서비스 및 인프라가 함께 잘 작동하는지를 검증하는 과정이다.
  • 이러한 테스트는 애플리케이션의 실제 운영 환경과 최대한 유사한 환경에서 수행되어야 한다.
  • Testcontainers를 사용하는 이점
    1. 실제 환경과의 일치: Testcontainers를 사용하면 실제 운영 환경에 배포될 데이터베이스, 메시징 큐, 캐시 시스템 등과 동일한 서비스를 테스트 환경에서 구현할 수 있다. 이는 테스트 결과의 신뢰성을 크게 향상시킨다.
    2. 환경 설정의 유연성: Docker 컨테이너를 사용함으로써 필요한 서비스를 동적으로 생성하고 파괴할 수 있다. 이는 테스트 중에 발생할 수 있는 환경 구성 오류를 최소화하고, 각 테스트의 독립성을 보장한다.
    3. 복잡한 시나리오 테스트: 다양한 외부 시스템과의 통합 포인트를 포함하는 복잡한 시나리오를 테스트할 수 있다. 예를 들어, 분산 데이터베이스, 외부 API, 클라우드 기반 서비스 등과의 통합을 검증할 수 있습니다.
  • 결론
    • 테스트 컨테이너 사용의 적합성
      • 복잡한 의존성과 실제 환경을 정확히 재현해야 하는 대규모 어플리케이션의 경우, Testcontainers를 사용하는 완전 통합 테스트가 매우 적합하다.
      • 이 방법은 애플리케이션의 안정성과 배포 전 오류 발견 능력을 크게 향상시칸다.
    • 단순 어플리케이션의 경우
      • 간단한 어플리케이션 또는 몇몇 외부 의존성이 덜 중요한 경우, 기본적인 @SpringBootTest로 충분할 수 있다.
      • 이는 테스트의 복잡성과 실행 시간을 줄일 수 있다.
    • 따라서 애플리케이션의 규모, 복잡도, 필요한 테스트 범위를 고려하여 Testcontainers의 사용을 결정하는 것이 좋다.
    • 각 테스트가 실제 운영 환경을 얼마나 정확하게 반영해야 하는지에 따라 가장 적합한 테스트 전략을 선택해야 한다.

SpringBootTest 코드

  • AccountControllerIntegrationTest
    public class AccountControllerIntegrationTest extends IntegrationTestSupport {
    
        @Autowired
        private TestRestTemplate restTemplate;
    
        private String getBaseUri() {
            return "http://localhost:" + port + "/api/accounts";
        }
    
        @Test
        void testCreateAccount() {
            // 계정 생성 요청
            Account account = Account.builder()
                    .owner("John Doe")
                    .balance(1000.0)
                    .build();
            ResponseEntity<Account> response = restTemplate.postForEntity(getBaseUri(), account, Account.class);
    
            // 결과 검증
            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
            assertThat(response.getBody()).isNotNull();
            assertThat(response.getBody().getOwner()).isEqualTo("John Doe");
            assertThat(response.getBody().getBalance()).isEqualTo(1000.0);
        }
    
        @Test
        void testGetAccountById() {
            // 계정 생성 및 ID 획득
            Account account = Account.builder()
                    .owner("Jane Doe")
                    .balance(500.0)
                    .build();
            Account savedAccount = restTemplate.postForObject(getBaseUri(), account, Account.class);
    
            // 생성된 계정 ID로 조회
            ResponseEntity<Account> response = restTemplate.getForEntity(getBaseUri() + "/" + savedAccount.getId(), Account.class);
    
            // 결과 검증
            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
            assertThat(response.getBody()).isNotNull();
            assertThat(response.getBody().getId()).isEqualTo(savedAccount.getId());
            assertThat(response.getBody().getOwner()).isEqualTo("Jane Doe");
        }
    }
    • Repository 계층에서와 마찬가지로 IntegrationTestSupport를 상속받아서 사용하고 있다.
profile
지나가는 개발자

0개의 댓글