테스트 격리(Test Isolation)

ljinsk3·2020년 9월 4일
2
post-thumbnail

테스트 격리란?

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

Therefore I find it's really important to focus on keeping tests isolated. Properly isolated tests can be run in any sequence. As you get to larger operational scope of functional tests, it gets progressively harder to keep tests isolated. When you are tracking down a non-determinism, lack of isolation is a common and frustrating cause.
...
Keep your tests isolated from each other, so that execution of one test will not affect any others.
...

따라서 테스트를 격리하는 데 집중하는 것이 정말 중요합니다.
적절하게 격리 된 테스트는 어떤 순서로든 실행할 수 있습니다.
기능 테스트의 운영 범위가 더 넓어짐에 따라 테스트를 격리하는 것은 점점 더 어려워집니다.
비결정적인 상황을 만드는 대표적인 원인은 바로 격리 부족입니다.
...
테스트를 서로 격리하여 한 테스트를 실행해도 다른 테스트에 영향을주지 않도록 해야 합니다.
...

정리하면 테스트 격리란 테스트가 순서에 상관없이 독립적으로 실행되며 결정적으로 수행되어야 함을 의미한다.
여기서 결정적으로 수행된다는 것의 의미는 "같은 입력 값이면 언제나 어떤 순서로나 같은 결과를 반환"한다는 것을 의미한다. 멱등하다는 의미도 포함하고 있는 셈이다.

생각해보면 우리는 테스트 코드를 작성하면서 테스트 격리를 크게 고민하지 않았던 것 같다. 그럴 수 있었던 이유는 바로 우리가 사용하는 JUnit과 Spring Boot가 @BeforeEach, @Transactional 등과 같은 어노테이션 기반의 격리를 지원하는 도구를 제공하기 때문이다.

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

그 동안 우리는 어떻게 테스트를 격리하고 있었을까? 계층별로 테스트 방식과 격리 방식을 간단하게 살펴보자.

계층 별 테스트 격리 방식

테스트 케이스 작성부터 테스트 흐름을 구조도로 한번 그려보았다. 오른쪽에 있는 컨트롤러 서비스 레파지토리 도메인 영역이 바로 우리가 직접 프로덕션 코드와, 테스트 코드를 작성하여 테스트하는 타겟 영역이다.

테스트 격리가 필요한 근본적인 원인은, 각각의 테스트에서 사용되는 데이터가 공유되기 때문이라고 할 수 있다. 이 데이터들이 언제 어떻게 얼마나 변화될지 모르기 때문에 테스트가 결정적으로 수행되지 못하고 불안전한 테스트를 작성하게 되는 것이다.

따라서 앞으로 살펴볼 격리했던 방식은 대부분 데이터베이스를 얼마나 의존하지 않고 테스트를 실행시킬 수 있는지 혹은 얼마나 효과적으로 데이터베이스 상태를 테스트 이전으로 돌려놓을것인지에 관한 내용이라는 점을 아시면 좋을 것이다.

Domain 영역


도메인 영역에 있는 POJO입니다. POJO는 순수 자바 객체로 엔티티나, VO, Utils성 객체 등을 의미한다.

엔티티 테스트 코드를 한번 살펴보자.

엔티티 객체의 동작과 내부 로직을 확인하는 용도의 테스트이기 때문에 데이터베이스와 상관없이 JUnit으로 바로 테스트를 할 수 있다.

객체는 new 연산자로 간단히 인스턴스화 될 수 있으며, JVM 인 메모리에서 테스트가 이뤄진다.

공통으로 사용하는 픽스처의 상태가 다른 테스트에 의해 중간에 값이 바뀌었다고 하더라도 @BeforeEach 라이프사이클에서 항상 같은 값으로 객체를 초기화되기 때문에 모든 테스트가 똑같은 초기 환경에서 수행되게 된다. 따라서 우리가 테스트 격리를 걱정할 필요가 없다.

Service 영역

서비스 계층은 실질적으로 비즈니스 로직을 수행하고 실제 데이터베이스를 사용한다.
그렇기 때문에 트랜잭션이 끝나면 데이터베이스 상태가 변하게 되고, 테스트간 격리가 본격적으로 필요한 계층이라고 할 수 있다.

서비스 객체의 메서드를 실행하고 테스트하려면, 당연히 서비스 빈을 주입받아야 수행할 수 있다. 빈을 주입받기 위해서는 Spring IoC Container가 필요하고 @SpringBootTest 어노테이션으로 빈을 띄울 수 있게된다.

@SpringBootTest는 모든 빈을 생성한다. 데이터베이스의 상태를 변화시킬 때 까지 Service 계층 이하의 빈들은 모두 컨테이너에 필요하게되고 계층 구조상 통합테스트가 이뤄진다.

다시 격리 이야기로 넘어가보자.
실제 데이터베이스 상태를 변화시킨다면 테스트를 어떻게 격리할 수 있을까?

스프링이 제공하는 @Transactional 어노테이션을 사용해서 격리를 관리할 수 있다. 이 어노테이션은 기본전략이 트랜잭션을 rollback 하기 때문에 실제로는 데이터베이스의 상태를 변화시키지 않고 격리된 상황에서 테스트를 진행할 수 있는 것이다.

그러나..!!

생각해보면 지금까지 우리는 이런방식으로 테스트하지 않았다. TDD를 사용하는 우리는 처음부터 대규모의 통합테스트를 설계하고 개발할 여력이 되지 않습니다. 그래서 단위테스트로 TDD를 해야하는데 우리는 Mockito 프레임워크를 이용해서 문제를 그 해결할 수 있었다. 이렇게 Mock 프레임워크를 사용해서 테스트를 하게되면, 실제 데이터베이스 근처에는 가지도 않기 때문에 테스트 격리를 고민할 필요없이 서비스 계층에만 집중해서 단위 테스트를 할 수 있다.

Controller 테스트

다음은 컨트롤러 테스트 입니다. 이 계층도 서비스 계층과 비슷한 방식으로 테스트를 진행한다. 원래는 빈을 주입받아서 테스트 로직을 수행해야하기 때문에 @SpringBootTest 어노테이션을 이용해서 IoC Container를 띄우게 된다. 이 역시 실제 데이터베이스를 사용한다면 계층 구조상 통합테스트가 되어버린다. (TDD 방식으로는 테스트 할 수 없다.ㅠㅠ)

Service 계층과 마찬가지로 TDD를 위해 Mock 프레임워크를 사용할 수 있다. 요청과 응답을 테스트하는 컨트롤러는 MockMvc라는 RestAPI 클라이언트 도구를 사용할 수 있다. 역시 Mock 객체를 사용하기 때문에 데이터베이스를 사용하지 않고, 따라서 테스트 격리도 따로 신경 쓸 필요가 없다.

참고로 여기서는 컨트롤러 이하의 빈들은 모두 필요가 없기때문에 @WebMvcTest 어노테이션을 사용해서 SpringMvc에 필요한 빈들만 사용하는 slice test를 진행할 수 있다.

Repository 테스트

역시 @DataJpaTest라는 어노테이션을 사용해서 데이터 계층에 관련된 빈들만 가지고 slice테스트를 할 수 있다. InMemeory에서 실행되며 자동으로 @Transactional 어노테이션이 적용된다는 특징이 있어서 역시나 @DataJpaTest 어노테이션을 사용한다면 테스트 격리를 크게 걱정할 필요가 없다.


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

인수 테스트란 사용자 시나리오에 맞춰 서비스가 실제 운영 환경에서 사용될 준비가 되었는지를 통합적으로 확인하는 테스트이다. 앞에서 언급한 계층들은 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를 확인하고 컨텍스트를 새로 로드하게 된다. 즉 테이블도 다시 새로 만드는 것이다.

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

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

결론

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

profile
Spring, Java, Vue, Docker 풀스택 개발로그 입니다.

0개의 댓글