우테코/요즘카페

테스트 컨테이너를 사용하는 이유 + 테스트 격리

dev_kong 2023. 10. 6. 00:53
728x90
728x90

도커도 불편해

요즘 카페서비스는 MySql을 사용하고 있는데,
테스트 환경와 운영환경을 최대한 일치 시키기 위해 테스트 역시 MySql을 사용하고 있다.

 

처음에는 도커 컨테이너를 띄워서 테스트를 돌렸다.
물론 로컬 MySql을 사용하는 것 보다는 관리포인트가 줄어들지만,

이 또한 몇 가지 불편한 점들이 있다.

  1. 테스트를 돌리려면 DB 컨테이너를 실행 시켜줘야 한다.
    해본 사람은 알겠지만, 이게 은근히 거슬린다.
  2. compose 파일 관리도 비용이다.
    프로젝트 초반에는 여러 이유들 때문에 compose 파일 변경이 잦았는데,
    compose 파일을 관리하는 것 역시 비용이 들어가는 행위라는 것을 깨달았다.

  3. port를 신경 써야 한다.
    맥북을 이틀 간 수리를 맡긴 적이 있었다.
    이틀 동안 놀수는 없어서 여자친구 맥북을 가져다 썼는데, 우리 프로젝트에서 사용하는 테스트DB 포트를 다른 용도로 이미 사용하고 있었다. 컨테이너 마다 port를 계속 신경써야 하겠구나 라는 생각이 들었다.

이런 불-편한 점들을 Testcontainer를 통해 해결할 수 있었다.

Testcontainer

Testcontainer를 간략하게 설명해보면, 도커 컨테이너를 자바 코드로 조작 가능 하다!
다시 말해 자바 코드로 도커 이미지를 실행하고 끌 수 있는데, 이걸로 얻을 수 있는 장점들이 제법 괜찮다.

  1. 실제 운영 DB와 동일한 환경 사용가능
  2. 테스트 실행 시 자동으로 컨테이너를 실행, 테스트 끝나면 자동 종료
  3. 테스트를 위한 compose 파일을 관리하지 않아도 됨
  4. 컨테이너를 매번 랜덤한 포트로 사용하기에 포트도 신경쓰지 않아도 된다!

장점에 비해, 사용법은 생각보다 너무나 간단하다.

우선 의존성을 추가해주자.

testImplementation 'org.testcontainers:junit-jupiter:1.19.0'
testImplementation 'org.testcontainers:mysql'


이러면 사실 준비 끝이다.
직접 적용만 해보면 된다.

class ExampleTc {  
    MySQLContainer mySQLContainer = new MySQLContainer("mysql:8");  

    @BeforeEach  
    void setUp() {  
        mySQLContainer.start();  
    }  

    @AfterEach  
    void tearDown() {  
        mySQLContainer.stop();  
    }  

    @Test  
    void test1() {  
        System.out.println(mySQLContainer.getMappedPort(3306));  
    }  

    @Test  
    void test2() {  
        System.out.println(mySQLContainer.getMappedPort(3306));  
    }
}

일단 Testcontainer는 기본적으로 매 테스트마다 랜덤한 포트에 새로운 컨테이너를 실행하고 종료한다.
그 역할을 해주는게 start()stop() 이다.

실제로 test1, 2에 찍은 sout을 확인해보면 실행된 포트가 다른 것을 확인할 수 있다.



근데, 저 start와 stop을 매번 해야된다는게 번거롭다 생각했는지
@Container 어노테이션을 통해 start()stop()을 대신할 수 있다.

 

class ExampleTc {  
    @Container  
    MySQLContainer mySQLContainer = new MySQLContainer("mysql:8");
}

 

이렇게 번거로움은 해결 했지만, 매 테스트 마다 컨테이너를 실행하고 종료하고를 반복하다 보니,
테스트 실행속도가 현저하게 느려진다.

 

containerstatic 필드로 선언하면, 컨테이너를 한번만 실행한다.

 

class ExampleTc {  

    static MySQLContainer mySQLContainer = new MySQLContainer("mysql:8");  

    static {  
        mySQLContainer.start();  
    }
}

 

아까 처럼 두개의 테스트에서 포트넘버를 출력해보면 이번에는 같은 포트넘버가 출력된다.

 

실제로 우리 서비스에서는

@SpringBootTest  
@ActiveProfiles("test")  
public abstract class BaseTest {  

    private static final String ROOT = "root";  
    private static final String ROOT_PASSWORD = "test";  

    @Autowired  
    private DataInitializer dataInitializer;  
    @Container  
    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();  
    }
}

이런 방식으로 사용하고 있다.


BaseTest 라는 추상 클래스 내에서 컨테이너를 만들고 실행시킨 뒤,
이를 상속한 테스트 클래스에서 테스트를 진행한다.

 

이렇게 하면, 모든 테스트가 진행되는 동한 최초 한번만 컨테이너가 실행된다.

테스트 격리는?

만약 모든 테스트마다 테스트 컨테이너가 새로 실행되고 종료된다면,
테스트 격리도 자동으로 해결된다.


하지만 이러면 전체 테스트를 돌리면 시간이 어마무시하게 걸릴 거다.

테스트 격리 까짓거 내가 직접 지워 주면 된다.

 

테스트를 격리하는 방법에는 여러가지가 있다.

 

  • @Transactional
  • @Sql
    • 스키마가 변경되면 함께 수정해줘야 한다.
  • @BeforeEach
    • 마찬가지로 스키마 변경되면 함께 수정해야 한다.
    • DB Constraints를 고려해서 순서대로 삭제해줘야 한다.
    • 사용되는 여러 테이블을 모두 삭제해줘야 하므로 내부 구현에 강하게 결합됨

뭐 하나 마음에 드는게 없다.

아까 실제 우리 서비스에 적용한 BaseTest를 다시 보면 dataInitializer라는 친구가 있다.

 

    @BeforeEach  
    void delete() {  
        dataInitializer.deleteAll();  
    }

 

@BeforEach를 통해 deleteAll()을 실행시키고 있다.

 

@Component  
@Profile("test")  
public class DataInitializer {  

    private static final int OFF = 0;  
    private static final int ON = 1;  
    private static final int FIRST_COLUMN = 1;  
    private static final String FLYWAY = "flyway";  

    @Autowired  
    private DataSource dataSource;  
    @PersistenceContext  
    private EntityManager em;  

    private final List<String> deleteDMLs = new ArrayList<>();  

    @Transactional    
    public void deleteAll() {  
        if (deleteDMLs.isEmpty()) {  
            init();  
        }  
        setForeignKeyEnabled(OFF);  
        truncateAllTables();  
        setForeignKeyEnabled(ON);  
    }  

    private void setForeignKeyEnabled(final int enabled) {  
        em.createNativeQuery("SET foreign_key_checks = " + enabled).executeUpdate();  
    }  

    private void truncateAllTables() {  
        deleteDMLs.stream()  
                .map(em::createNativeQuery)  
                .forEach(Query::executeUpdate);  
    }  

    private void init() {  
        try (final Statement statement = dataSource.getConnection().createStatement()) {  
            final ResultSet resultSet = statement.executeQuery("SHOW TABLES ");  

            while (resultSet.next()) {  
                final String tableName = resultSet.getString(FIRST_COLUMN);  
                if (tableName.contains(FLYWAY)) {  
                    continue;  
                }  
                deleteDMLs.add("TRUNCATE " + tableName);  
            }        } catch (Exception e) {  
            e.printStackTrace();  
        }    
    }
}

 

  1. deleteAll()이 실행되면, deleteDMLs를 확인하고 비어있다면 init()해준다.
  2. init()SHOW TABLES 쿼리를 통해 모든 테이블 네임을 가져오는 데, 이때 flyway history 관련 테이블은 제외한다. 가져온 테이블 네임들에 TRUNCATE 를 붙여, deleteDMLs에 저장한다.
  3. foreign_key_check 옵션을 끄고, deleteDMLs에 저장 된 TRUNCATE 문을 모두 실행한다.
    다시 foreign_key_check 옵션을 켜준다.

이 과정이 @BeforEach 를 통해 반복 되는데, 테이블이 추가/삭제 되는 경우에도 코드의 변경이 필요 없고,
foreign_key_check 옵션을 설정 하여, 연관관계로부터 자유롭게 TRUNCATE를 사용할 수 있다.

 

TestcontainerDataInitializer 로 아주 스마트하게 테스트 환경을 구축할 수 있었다.

728x90
728x90