#test, #isolation

인수테스트에서 테스트 격리하기

테스트 격리란?

우리는 테스트들이 서로 순서에 상관없이 독립적으로 수행되어야 한다는 것을 알고 있다. 마틴 파울러도 자신의 블로그에 비결정적 테스트의 문제점에 대해서 언급하며 그 원인으로 테스트 격리가 부족하게 될 때 비결정적 테스트가 된다고 했다.

여기서 비결정적 테스트란 같은 입력값에 대해 항상 같은 결과를 출력하지 않는 테스트를 말한다.

테스트 격리가 안 되는 근본적인 원인은 각각의 테스트가 하나의 자원을 공유하기 때문인데, 이를 방지하기 위해 JUnit 과 Spring Boot에서는 @BeforeEach, @Transactional 등과 같은 어노테이션 기반의 격리를 지원하는 도구를 제공한다. 단, 이 글에서는 공유 자원의 대표 주자라 할 수 있는 데이터베이스를 기준으로 글을 작성한다.

JUnit과 Spring Boot에서 격리를 지원하고 있지만, 많은 개발자들이 그보다 Mock 프레임워크를 사용해서 테스트를 작성하기도 한다. 특히 TDD를 사용한다면 이 방식이 효과적이라고 할 수 있는데, 실제 데이터베이스를 사용하지 않기 때문에 테스트 격리를 신경쓸 필요가 없을 뿐만아니라, 계층 구조에서 통합테스트가 아닌 단위테스트를 할 수 있다는 장점이 있기 때문이다.

인수테스트에서 테스트 격리하기

인수 테스트란 사용자 시나리오에 맞춰 서비스가 실제 운영 환경에서 사용될 준비가 되었는지를 통합적으로 확인하는 테스트이다. 앞에서 언급한 계층들은 JUnit과 Spring Boot가 제공하는 도구들을 혹은 Mock 프레임워크를 사용하면 테스트 격리를 크게 신경 쓰지 않고 개발할 수 있지만 인수 테스트에서는 경우가 조금 다르다.

인수 테스트의 목적 자체가 실제 운영 환경과 같은 조건에서 테스트하는 것을 기대하기 때문에 Mock 프레임워크를 사용하지 않고, 실제 데이터베이스를 사용해야 그 조건을 충족할 수 있다. 그러므로 테스트가 진행됨에 따라서 데이터베이스의 상태는 당연하게도 계속 변하게 될 것이고, 테스트마다 초기 상태가 달라지기 때문에 테스트가 잘 격리되고 있다고 말하기도 힘들다.

아마 많은 개발자들이 실제로 인수 테스트를 작성하면서 똑같은 테스트임에도 불구하고 테스트 격리가 잘되지 않아서 실행할 때마다 성공 여부가 달라지는 경험을 해본 적이 있을 것이다. 이는 테스트가 진행되는 순서와 데이터베이스 초기 상태가 보장되지 않기 때문에 발생한 것이다.

필자도 테스트가 실패하는 경우 테스트 간의 데이터가 겹치지 않도록 의도적으로 다른 데이터를 만들어서 테스트를 통과시키곤 했다.

그렇다면 인수 테스트에서 효과적으로 테스트를 격리하는 방법은 어떤 것이 있는지 한번 알아보자.

1. @Transactional

결론부터 말하면 @Transactional 어노테이션을 사용해서 트랜잭션을 롤백하는 전략은 인수 테스트에서는 사용할 수 없다. 아마 많은 사람들이 테스트 프레임워크에서 관리하는 @Transactional 어노테이션을 붙이면 트랜잭션이 끝난 뒤 롤백된다고 알고 있다. 물론 틀린 말은 아니다.

하지만, 인수 테스트의 경우 @SpringBootTest 어노테이션에 port를 지정하여 서버를 띄우게 되는 데 이때, HTTP 클라이언트와 서버는 각각 다른 스레드에서 실행된다. 따라서 아무리 테스트 코드에 @Transactional 어노테이션이 있다고 하더라도 호출되는 쪽은 다른 스레드에서 새로운 트랜잭션으로 커밋하기 때문에 롤백 전략이 무의미해지는 것이다.

2. 매 테스트 수행 이후 생성한 픽스처 및 데이터 직접 삭제

테스트에 필요한 데이터를 JUnit 생성주기인 @BeforeEach나 테스트 안에서 생성한 뒤, 테스트가 종료되는 시점에 @AfterEach를 사용하여 데이터를 삭제하는 요청을 보내고 이로써 데이터베이스를 이전과 같은 상태로 맞추는 방법이다.

아마 테스트에 필요한 데이터가 적은 경우, 간단하게 수행할 수 있는 방법이기 때문에 많은 사람들이 사용하는 방식이기도 할 것이다. 그러나 이 방법은 생성해야 할 데이터가 많거나, 연관 관계 맵핑이 있으면 굉장한 비효율이 발생할 수 있다.

위 예시 코드를 다시 살펴보자. Question과 Hashtag가 다대다 연관 관계를 가지고 있다.

이 때 Question 객체를 생성하는 요청을 보낼 때, Hashtag도 함께 입력을 받아서 생성하는 경우, 명시적으로는 Question만 생성했지만 부수적으로 Hashtag까지 테이블에 저장 된다.

만약 도메인에 대한 지식이 없는 사람이 테스트를 작성한다면, 연관 관계 맵핑으로 생성되는 엔티티를 추적하기 어렵게 만들고 구현과 유지 비용이 증가할 것이다.

또 다른 문제는 @AfterEach에서 삭제 “요청”을 보낼 때, 삭제해야 할 데이터가 많다고 가정해보자. 테스트 격리만을 위해서 반대 요청을 생성할 때 보낸 요청의 개수만큼 추가로 더 보내야 하는데 배보다 배꼽이 더 큰 정도의 비용이 발생하게 될 수 있다.

3. 매 테스트 이후 Truncate 쿼리로 모든 테이블 초기화

TRUNCATE 쿼리는 앞서 살펴본 DELTE로 테이블을 초기화하는 방법보다는 상당히 괜찮은 방법이다. API 요청도 필요 없고, DELTE를 하기 위해서 하나씩 SELECT(조회)를 할 필요도 없다.

JPA의 경우 deleteAll, deleteById메서드를 호출하면 곧 바로 DELETE 쿼리가 수행되는 것이아니라 SELECT로 조회한 뒤에 DELETE가 나간다.

그뿐만 아니라 삭제를 수행할 때 트랜잭션 로그 공간을 적게 사용하고, DELETE는 행마다 락(lock)을거는데 비해 TRUNCATE은 락(lock)을 거는 수가 상대적으로 적은 시간에 테이블 초기화를 할 수 있는 장점이 있다.

TRUNCATE 쿼리를 수행하는 방법은 두 가지 방식으로 구분 지어 볼 수 있다.

1) @Sql 어노테이션 활용

스프링 부트에서 제공하는 어노테이션이다. 클래스 테스트가 실행되기 전에 @Sql이 가리키는 경로에 있는 SQL 실행이 먼저 일어난다. 따라서 이 파일안에 모든 테이블에 대한 TRUNCATE SQL을 미리 작성해 놓으면, 파일하나와 어노테이션만으로 테스트 격리를 이뤄낼 수 있으니 꽤나 획기적인 방식이라고 볼 수 있다.

하지만 한가지 단점은 엔티티 혹은 연관관계 테이블이 추가될 때마다 테스트 격리를 위해서 파일을 수정해주어야 한다는 점이다. 매번 추가하는 것도 번거롭지만, 엔티티가 많은 경우 무엇을 빼먹었는지 수정하고 찾는 과정에서 약간의 비효율을 예상해 볼 수 있다.

2) EntityManager로 직접 TRUNCATE 쿼리 실행

이 방식은 SQL 파일을 직접 실행시키기보다 JPA에서 쿼리를 직접 만들 수 있는 EntityManager를 빈으로 주입받고, 모든 테이블 이름을 조사해서 각각의 인수테스트가 시작할 때, TRUNCATE 쿼리를 실행시키는 방식이다.

이는 한번 만들어 놓으면 엔티티가 얼마나 추가 더 추가되고 삭제되는지와 상관없이 인수테스트에서 테스트를 효과적으로 격리할 수 있다.

EntityManager는 JPA에서만 제공하는 빈이지만, 만약 다른 기술을 사용한다고 해도 전체 테이블 이름만 받아올 수 있다면 테스트 실행되기 전에 TRUNCATE 쿼리를 행할 수 있으니 꼭 JPA에만 국한되는 방식은 아니라고 할 수 있다.

4. DirtiesContext로 Spring Bean Reload 하기

위 방식보다 더 간편하게 테스트를 격리하는 방법이 있다.

바로 다음과 @DirtiesContext 어노테이션을 사용하는 것이다.

이 @DirtiesContext 어노테이션은 현재 테스트가 실행되고자하는 컨텍스트에 이미 빈이 올라가 있으면, Dirties를 확인하고 컨텍스트를 새로 로드하게 된다. 즉 테이블도 다시 새로 만드는 것이다.

이미 눈치 챈 독자도 있겠지만, 이 방식은 사용이 간편한 것에 비해 별로 추천하고 싶지 않은 방식이다. 매번 테스트 하기전에 컨텍스트를 다시 로드한다면 테스트하는데 걸리는 시간이 매우 오래 걸릴 것이다. 테스트는 신속하고 반복적으로 수행되어야 한다.

테스트 하는데 시간이 오래걸리면 누가 테스트를 자주 하고 싶겠는가? 따라서 이 방법은 정말 부득이한 경우가 아니라면 사용하지 않는 것이 좋다.

결론

이렇게 인수 테스트에서 테스트를 격리할 수 있는 방법에 대해 알아보았다. 이 글에 제시된 방법이 테스트를 격리할 수 있는 가장 나은 방법은 아닐 것이다. 따라서 단순히 테크닉적인 내용보다는, 테스트 격리의 정의가 무엇이고, 왜 인수 테스트는 격리를 위한 장치가 필요한지 그 의미를 스스로 생각해보면 앞으로 테스트 코드를 작성하는데에도 많은 도움이 될 것으로 생각한다.