ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Java] testcontainers를 활용해 통합 테스트 수행하기
    Today I Learned 2023. 5. 20. 03:57

    통합 테스트란 무엇인가?

    일반적으로 코드를 작성하는 과정에서 가장 빠르게 작성할 수 있는 테스트는 단위 테스트이다. 기능을 구성하는 가장 작은 식이나 메서드 단위에서부터 빠르게 테스트를 작성하고 검증할 수 있기 때문이다. 그러나 단위 테스트만으로 전체적인 동작이 올바르게 이루어지는지 검증하기에는 부족할 수 있다.

     

    이를테면 서버와 데이터베이스가 상호작용해 수행하는 기능에 대해 서버 범위 내에서만 동작을 검증할 경우에는, 실제로 동작이 실행되었을 때 서버 범위 바깥의 모듈과 상호작용하는 과정을 포함해서 모든 영역에서 문제 없이 완전하게 수행할 것이라고 확신하기는 쉽지 않을 것이다.

     

    그래서 특정 기능에 관여하는 둘 이상의 모듈들을 함께 동작시켰을 때 요구되는 동작을 정상적으로 수행하는지 검증하는 방식으로도 테스트를 수행할 수 있으며, 이를 통합 테스트라고 한다.

     

     

    문제 상황

    이전에 개인 프로젝트를 하던 때에 했었던 통합 테스트와 가장 유사한 형태의 테스트는 E2E 테스트였다. 당시에는 프론트엔드 애플리케이션도 같이 개발했기 때문에, 특정 기능의 전체적인 흐름을 테스트하기 위해 프론트엔드에서 E2E 테스팅 프레임워크 CodeceptJS를 이용했었다. 먼저 Backdoor API를 호출해 로컬 서버에 실제로 데이터를 세팅하고, 사용자가 정해진 시나리오대로 행동할 때 기능이 의도한 대로 수행되는지를 검증하는 방식으로 프론트엔드와 백엔드, 로컬 데이터베이스 서버의 동작을 한 번에 전체적으로 테스트했었다.

     

    그러나 과제에서는 서버 애플리케이션만을 개발하고 있었기 때문에, 다른 방법으로 동작의 전체적인 흐름을 검증할 방법이 필요했다. 그리고 개인 프로젝트에서 진행했던 E2E 테스트에서는 로컬에서 직접 서버를 실행시켜놓는 준비 과정이 필요했고, Backdoor API를 호출하는 과정에서 로컬 데이터베이스 서버의 데이터가 덮어씌워지는 문제점이 존재했기 때문에, 이 문제에 대해서도 해결할 수 있어야 했다.

     

    과제 요구사항 중 이러한 문제를 해결할 수 있을 것으로 보이는 Testcontainers가 있어 해당 툴에 대해 알아보았고, 문제를 해결하는 과정에 적용해보았다.

     

     

    Testcontainers

     

    Testcontainers for Java

    Testcontainers Not using Java? Here are other supported languages! About Testcontainers for Java Testcontainers for Java is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or

    www.testcontainers.org

    Testcontainers는 각 프로그래밍 언어의 테스트 환경에서 Docker 컨테이너를 실행시킬 수 있는 라이브러리이다. 백엔드 서버와 상호작용하는 모듈을 실행시키기 위해 Docker 컨테이너를 활용하므로, 어느 환경에서든 동일하게 테스트에 필요한 다른 모듈을 실행시킬 수 있다는 이점이 있다.

     

    본 글에서는 Spring 2.7 서버 애플리케이션과 PostgreSQL 데이터베이스 서버의 상호작용을 통해 수행하는 기능을 테스트하기 위해 Testcontainers를 간단하게 활용하는 과정을 다뤘다.

     

     

    build.gradle 세팅

    build.gradle에 다음과 같이 testcontainers에서 사용할 테스팅 라이브러리와 데이터베이스에 대한 의존성을 추가했다.

    // build.gradle
    
    dependencies {
        // dependencies에 다음의 내용을 추가한다.
        testImplementation 'org.testcontainers:junit-jupiter:1.18.1'
        testImplementation 'org.testcontainers:postgresql:1.18.1'
    }

     

    테스트 코드 작성

    이번에도 수강신청 애플리케이션을 들고 왔다. 검증해볼 기능은 관리자가 과목 Entity를 생성하는 기능이다. 기능을 수행하는 과정에서는 다음의 Entity 정보들이 활용될 것이다.

    - Administrator: 관리자
        - 식별자 필드로 구성
        - 인가를 위해 API 요청 시 Header에 식별자를 전달
    - Entity: 과목
        - 식별자, 필수/선택 과목 여부, 이름 필드로 구성

     

    가장 먼저 다음과 같이 테스트 클래스를 정의한다. 테스트에서 사용될 데이터베이스 모듈이 띄워질 컨테이너를 다음과 같이 정의한다.

    @SpringBootTest
    @Testcontainers
    public class CourseCreationTest {
        @Container
        private static GenericContainer<?> container = new PostgreSQLContainer<>("postgres:latest");
    }

     

    해당 기능을 수행하는 과정에서 전달받은 관리자 식별자를 식별하는 과정이 존재하므로 다음과 같이 미리 준비되어야 할 관리자 데이터를 세팅해준다. 이때 컨테이너로 PostgreSQLContainer를 사용하는 경우, 테스트가 종료되고 다시 테스트가 실행되어도 기존의 데이터가 남아있는 문제가 발생하기 때문에 다음과 같이 테스트를 수행하기 전에 이전 테스트 과정에서 생성된 Tuple들을 비워준다.

    public class CourseCreationTest {
        @Autowired
        private JdbcTemplate jdbcTemplate;
    
        @BeforeEach
        void setUp() {
            // 먼저 다음과 같이 사용될 테이블을 초기화시킨다.
        	jdbcTemplate.update("DELETE FROM administrator");
            jdbcTemplate.update("DELETE FROM course");
            
            // 이후 필요하다면 다음과 같이 기능을 수행하는 데 필요한 Tuple들을 미리 삽입한다.
            Long administratorId = 100000001L;
            jdbcTemplate.update(
                "INSERT INTO administrator(id) " +
                    "VALUES(?)",
                administratorId
            );
        }
    }

     

    테스트를 시작할 지점으로는 Controller에서 REST API 요청을 받는 시점을 택했다. 백엔드 서버와 데이터베이스 모듈이 상호작용하는 것을 포함해 특정 기능의 전체 흐름을 정상적으로 수행하는지 파악하는 것을 목표로 했기 때문이다. 따라서 API 요청을 생성시키기 위해 MockMvc를 활용해 테스트를 진행했다.

     

    다음과 같이 과목 생성을 위한 REST API를 발생시키면서 인가를 위한 Header, Request Body에 해당하는 JSON 문자열을 정의해 요청에 같이 포함해 전달했을 때, 의도한 Status Code와 Content를 반환하는지 확인하는 방식으로 검증을 진행했다.

    // 테스트 클래스에 다음의 어노테이션을 추가한다.
    @AutoConfigureMockMvc
    public class CourseCreationTest {
        @Autowired
        private MockMvc mockMvc;
        
        @Test
        void createCourseAndCreateLecture() throws Exception {
            String authorization = "Bearer Administrator " + administratorId;
            
            mockMvc.perform(MockMvcRequestBuilders.post("/course")
                .header("Authorization", authorization)
                .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON)
                .content("{" +
                    "\"courseName\":\"세계의기후와문화\"," +
                    "\"courseRequirement\":\"OPTIONAL\"" +
                    "}"))
            .andExpect(MockMvcResultMatchers.status().isCreated());
        }
    }

     

     

    최종적으로 작성한 테스트 코드는 다음과 같다.

    @SpringBootTest
    @AutoConfigureMockMvc
    @Testcontainers
    public class CourseCreationTest {
        @Autowired
        private MockMvc mockMvc;
    
        @Autowired
        private JdbcTemplate jdbcTemplate;
    
        @Container
        private static GenericContainer<?> container = new PostgreSQLContainer<>("postgres:latest");
    
        @BeforeEach
        void setUp() {
        	jdbcTemplate.update("DELETE FROM administrator");
            jdbcTemplate.update("DELETE FROM course");
            
            Long administratorId = 100000001L;
            jdbcTemplate.update(
                "INSERT INTO administrator(id) " +
                    "VALUES(?)",
                administratorId
            );
        }
        
        @Test
        void createCourseAndCreateLecture() throws Exception {
            String authorization = "Bearer Administrator " + administratorId;
            
            mockMvc.perform(MockMvcRequestBuilders.post("/course")
                .header("Authorization", authorization)
                .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON)
                .content("{" +
                    "\"courseName\":\"세계의기후와문화\"," +
                    "\"courseRequirement\":\"OPTIONAL\"" +
                    "}"))
            .andExpect(MockMvcResultMatchers.status().isCreated());
        }
    }

     

    만약 각 모듈 간의 상호작용만을 파악하는 것을 목적으로 하고자 한다면, DAO나 ORM의 인터페이스를 통해 데이터베이스와 상호작용하는 영역에 대해서만 테스트하는 방식으로 테스트의 범위를 좁혀볼 수도 있을 것이다.

     

     

    회고

    기능의 전체 흐름을 테스트하기 위해 Spring의 ApplicationContext 전체를 생성해 테스트하는 방식인 @SpringBootTest를 적용하기도 했고, 가상의 컨테이너가 생성되고 종료되는 시간이 필요했기 때문에 Testcontainers를 이용한 통합 테스트에는 일반적인 단위 테스트보다는 확실히 테스트를 수행하는 시간이 좀 더 소요되었다. 그렇기에 테스트 실패 시 오류가 발생하는 경우에는 동작에 관여하는 전체 로직을 모두 살펴봐야 할 것이므로 통합 테스트에서는 테스트를 통한 피드백 루프가 단위 테스트만큼 즉각적이지는 못할 것이라는 생각이 들었다.

     

    따라서 통합 테스트에서 너무 모든 경우를 테스트하기보다는, 복수 가짓수의 조건을 충족하는 일반적인 성공 케이스를 검증하거나 특정 기능을 수행한 뒤 해당 결과를 이용해 다른 기능을 이어서 테스트하는 경우에는 통합 테스트를 활용하고, 예외처리의 발생과 관련된 테스트는 단위 테스트에서 중점적으로 시도해보는 식으로 각 테스트에서 주로 검증할 목표를 나눈다면 다양한 관점에서 테스트를 활용하면서 애플리케이션의 신뢰성을 높일 수 있지 않을까 싶은 생각이다.

     

     

    References

    - Testcontainers

    - Testcontainers JUnit5 Quickstart

    - TestContainers로 유저시나리오와 비슷한 통합테스트 만들어 보기

    - JUnit - TestContainers 사용하는 방법! (+ 장단점 비교)

    - ChatGPT

     

     

     

     

    댓글

Designed by Tistory.