테스트할 로직 중간에 데이터베이스가 끼어있는 경우가 많다. 이번 장에서는 단위 테스트와 통합 테스트 각각에서 데이터베이스를 어떻게 다루면 좋을지 학습해 보자.
단위 테스트에서의 데이터베이스
먼저 프로세스 외부 의존성의 유형을 짚고 넘어가자. 모든 프로세스 외부 의존성은 두 가지 범주로 나뉜다.
관리 의존성 : 외부 의존성이지만 애플리케이션을 통해서만 접근할 수 있다. 데이터베이스가 대표적인 예이며, 외부 시스템은 보통 애플리케이션에서 제공하는 api를 통해서 데이터베이스에 간접 접근한다.
비관리 의존성 : 애플리케이션과의 상호 작용을 외부에서 볼 수 있다. 예를 들어 SMTP 서버와 메시지 버스 등이 있다.
다시 본론으로 돌아오자. 이전 글인코드 유형과 테스트의 관계글에서 언급했듯이, 단위 테스트는 도메인 도델과 알고리즘을 대상으로 하고 통합 테스트는 컨트롤러를 대상으로 한다. 만약 도메인 모델과 알고리즘에 데이터베이스라는 외부 의존성이 존재한다면, 리팩터링을 먼저 한 다음에 단위 테스트를 진행해야 한다.
물론 스텁을 사용하여 해결할 수는 있다. 하지만 db는 보통 관리 의존성이기 때문에 테스트가 구현 세부 사항에 의존하게 된다. 테스트 용이성과 가독성 측면에서도 좋지 않으므로 이를 지양하자. 예외적으로 db 또는 db의 일부가 비관리 의존성인 경우가 있다. 그럴 때는 목이나 스텁을 사용해도 무방하다.
통합 테스트에서의 데이터베이스
이메일이나 푸시와 같은 비관리 의존성은 목으로 대체하고 상호작용을 검증한다. 하지만 관리 의존성을 목으로 대체하면 리팩터링 내성이 저하되고 회귀 방지도 떨어진다. 따라서 데이터베이스를 그대로 테스트하는 것이 바람직하다.
데이터베이스는 실제 운영 환경과 동일하게 하는 게 좋다. 인메모리 데이터베이스는 기능적으로 일관성이 없기 때문에 회귀 방지 측면에서 좋지 않다.
쓰기는 위험성이 높기 때문에 철저히 테스트해야 한다. 읽기는 보통 위험성이 없기 때문에 복잡하거나 중요한 작업만 테스트하고, 나머지는 무시해도 된다.
리포지토리 테스트는 유지비가 높고 회귀 방지가 떨어지기 때문에 이점이 별로 없다. 직접 테스트하기보다는 포괄적인 통합 테스트 스위트의 일부로 취급하라.
단위 테스트와 기반 코드를 서로 얽혀있다. 따라서 코드 베이스에 노력을 기울이지 않고서는 가치 있는 테스트를 만들 수 없다. 이번 장에서는 코드의 네 가지 유형을 살펴보고 어떤 유형이 테스트하기 적합한지 알아보자.
코드의 네 가지 유형
모든 제품 코드는 2차원으로 분류할 수 있다.
복잡도 또는 도메인 유의성
코드의 복잡도는 코드 내 의사 결정(분기) 지점 수로 정의한다. 이 숫자가 클수록 복잡도는 더 높아진다.
도메인 유의성은 코드가 프로젝트의 문제 도메인에 대해 얼마나 의미 있는지를 나타낸다. 일반적으로 도메인 계층의 모든 코드는 최종 사용자의 목표와 직접적인 연관성이 있으므로 도메인 유의성이 높다.
협력자 수
협력자는 가변 의존성이거나 프로세스 외부 의존성이다.
협력자가 많은 코드는 테스트 비용이 많이 든다. 상호 작용 확인을 위한 코드(의존 객체 세팅, 목 등)가 필요하기 때문이다.
코드 복잡도/도메인 유의성과 협력자를 고려하면 제품 코드를 네 가지로 구분할 수 있다.
도메인 모델과 알고리즘
보통 복잡한 모델은 도메인 모델이다. 그러나 문제 도메인과 직접적으로 관련이 없는 복잡한 알고리즘이 있는 경우가 있다.
단위 테스트에 가장 적합한 유형이다.
간단한 코드
getter, setter와 같은 단순한 로직이나 매개 변수가 없는 생성자 등이 해당된다.
테스트할 필요가 없다.
컨트롤러
여기서는 복잡하거나 비즈니스에 중요한 작업을 하지 않는다. 도메인 클래스와 외부 애플리케이션 같은 다른 구성 요소의 작업을 조정하는 역할만 수행한다.
application 영역에 해당되며, 통합 테스트에 적합하다.
지나치게 복잡한 코드
덩치가 큰 서비스 로직과 같이, 협력자가 많으며 복잡하거나 중요하다.
협력자가 많아 단위 테스트가 어렵기 때문에 도메인 모델/알고리즘과 컨트롤러라는 두 부분으로 나누어야 한다.
컨트롤러(application 서비스)에서 조건부 로직 처리
비즈니스 로직과 오케스트레이션의 분리는 다음과 같이 비즈니스 연산이 세 단계로 있을 때 가장 효과적이다.
db에서 데이터 조회
비즈니스 로직 실행
db에 데이터 저장
하지만 비즈니스 로직 중간에 외부에서 추가 데이터를 조회하는 등의 작업이 있다면 어떻게 할까? 크게 세 가지 방법이 있다.
외부에 대한 모든 읽기와 쓰기를 비즈니스 연산 가장자리로 밀어내기 : 컨트롤러 단순화를 유지하고 프로세스 외부 의존성과 도메인 모델을 분리할 수 있다. 하지만 필요 없는 경우에도 외부 의존성을 호출하기 때문에 성능이 저하된다.
도메인 모델에 프로세스 외부 의존성을 주입하기 : 성능을 유지하면서 컨트롤러를 단순화할 수 있다. 하지만 도메인 모델의 테스트 유의성이 떨어진다.
의사 결정 프로세스 단계를 더 세분화 하기 : 성능과 도메인 모델 테스트 유의성에 도움을 준다. 하지만 컨트롤러에 의사 결정 지점이 생기므로 복잡해진다.
대부분의 소프트웨어 프로젝트는 성능이 매우 중요하다. 도메인 모델에 외부 의존성을 주입하면 테스트와 유지보수가 어려워진다. 따라서 의사 결정 프로세스 단계를 더 세분화하는 방법만 남는다. 컨트롤러가 복잡해지긴 하지만 이를 완화하는 방법이 있다.
컨트롤러 복잡도를 완화하는 방법
canExecute/execute 패턴 사용 : 복잡한 코드를 최대한 도메인 모델에 집어 넣고, 애플리케이션 서비스에서는 canExecute가 true일 때만 실행하도록 단순화 할 수 있다.
도메인 이벤트 사용 : 비즈니스 로직 중간에 외부 시스템에 알려야 하는 상황이 있을 수 있다. 도메인 이벤트를 사용하면 비즈니스 로직에서는 그저 이벤트를 던지기만 하고, 애플리케이션 서비스에서 일괄적으로 처리하도록 할 수 있다. 자세한 내용은 다른 페이지에서 다루도록 하겠다.
개인적인 견해로 코틀린에서는 파라미터로 함수를 넘기는게 가능하기 때문에, 해당 방법을 사용하면 외부 의존성과 의사 결정이 효과적으로 분리가 가능하다고 생각한다.
단위 테스트는 아래와 같이 세 가지 스타일이 있다. 하나의 테스트에서 하나 이상의 스타일이 사용될 수 있다.
1. 출력 기반 테스트
테스트 대상 시스템(SUT)에 입력을 넣고 생성되는 출력을 점검하는 방식이다. 해당 단위 테스트 스타일은 전역 상태가 내부 상태를 변경하지 않는 코드에만 적용되므로 반환 값만 검증하면 된다.
2. 상태 기반 테스트
상태 기반 테스트는 작업이 완료된 후 시스템 상태를 확인한다. 여기서 상태란 SUT나 협력자, 또는 데이터베이스나 파일 시스템과 같은 프로세스 외부 의존성의 상태를 의미한다.
다음은 상태 기반 테스트의 예제다. 클라이언트가 Order를 통해 상품을 주문하고 products 컬렉션을 검증한다.
public class Order {
private List<Product> products = new ArrayList();
public void addProduct(Product product) {
products.add(product);
}
public void getProductOfIndex(int index) {
return products.get(index);
}
public int getProductsSize() {
return products.size()
}
}
---
@Test
public void add_a_product_to_an_order() {
Product product = new Product("Hand wash");
Order sut = new Order();
sut.addProduct(product);
assertThat(sut.getProductOfIndex(0)).isEqualTo(product);
assertThat(sut.getProductsSize).isEqualTo(1);
}
3. 통신 기반 테스트
이 스타일은 목을 사용해 테스트 대상 시스템과 협력자 간의 통신을 검증한다. 가급적이면 애플리케이션 경계를 넘는 상호 작용을 확인하고 해당 상호 작용의 사이드 이펙트가 외부 환경에 보이는 경우에만 사용하는 것이 권장된다. 외부 환경에서 보이지 않거나 프로세스 내부 상호작용의 경우는 구현 세부 사항에 해당되기 때문에 부적합하다.
스타일 비교
출력 기반
상태 기반
통신 기반
리팩터링 내성을 지키기 위해 필요한 노력
낮음
중간
중간
유지비
낮음
중간
높음
세 스타일 중에 출력 기반 테스트가 가장 좋다. 그 이유는 다음과 같다.
구현 세부 사항과 거의 결합되지 않기 때문에 리팩터링 내성을 쉽게 유지할 수 있다.
테스트가 간결하고 프로세스 외부 의존성이 없기 때문에 유지 보수도 쉽다.
그러므로 출력 기반 테스트를 우선적으로 고려하고, 필요한 경우에만 다른 스타일을 사용하자.
XUnit Test Patterns의 저자인 Gerard Meszaros가 정의한 용어로, SUT가 의존하는 구성 요소의 대역을 말한다. 실행 결과를 관측(ex. 이메일 발송 메서드의 호출 여부 검증)하거나 구성 요소가 제공하는 입력값을 세팅(ex. 데이터베이스 응답값 세팅)하기 용이하게 때문에 사용한다. 빠른 테스트를 위해 프로세스 외부 의존성을 대역으로 대체하기도 한다.
스텁 : sut의 동작이 구성 요소가 반환하는 값의 영향을 받는 경우, 해당 값을 간접 입력이라고 부른다. 스텁은 sut가 의존하는 실제 구성 요소를 대체하여, 테스트가 sut의 간접 입력에 대한 제어점을 가지게 한다.즉, 데이터베이스 데이터와 같이 sut 내부로 들어오는 입력 데이터를 모방하기 위해 사용한다.
더미 : null이나 임의의 문자열 같이 하드코딩된 값을 의미한다. sut가 의존하는 구성 요소 중 관심 없는 요소를 더미로 처리할 수 있다.
페이크 : sut가 의존하는 실제 구성 요소의 기능을 대체하기 위해 사용한다. 간접 입력이나 간접 출력을 검증하지 않기 때문에, 간단한 방식으로 구현한다.
목 : sut 동작에 다른 시스템이나 애플리케이션 구성 요소에서 인지할 수 있는 작업이 포함된 경우, 해당 작업을 sut의 간접 출력이라고 부른다. 목은 sut가 실행될 때 간접 출력을 확인하기 위해 사용한다.즉, 이메일 발송과 같이 sut 외부로 나가는 상호작용을 모방하기 위해 사용한다.
스파이 : 목과 동일하게 간접 출력을 확인하기 위해 사용하며, 수동으로 작성하기 때문에 직접 작성한 목이라고 부르기도 한다.
목 vs 스텁
테스트 대역은 사실 목과 스텁의 두 가지 유형으로 나눌 수 있다. 보다 심층적으로 알아보자.
목 : 외부로 나가는 상호 작용을 모방하고 검사하는 데 도움이 된다. 이러한 상호 작용은 sut가 상태를 변경하기 위한 의존성을 호출하는 것에 해당한다.
스텁 : 내부로 들어오는 상호 작용을 모방하는 데 도움이 된다. 이러한 상호 작용은 sut가 입력 데이터를 얻기 위한 의존성을 호출하는 것에 해당한다.
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@Mock
private UserRepository userRepository; // 목을 사용해 스텁 생성
@Test
void test() {
when(userRepository.findById(anyLong())).thenReturn(new User(1, "Test User")); // 스텁을 사용해 응답값 세팅
User actual = userService.findById(1);
assertThat(actual.getId()).isEqualTo(1);
assertThat(actual.getName()).isEqualTo("Test User");
}
}
UserRepository 테스트 대역은 사실 목이 아니라 스텁인 것을 알 수 있다. 왜냐하면 내부로 들어오는 상호 작용, 즉 sut(UserService)에 입력 데이터를 제공하는 호출을 모방하기 때문이다. 만약 이메일 발송을 테스트 대역으로 처리했다면 이는 외부로 나가는 상호작용이기 때문에 해당 테스트 대역은 목이다. 무엇을 사용했냐가 아니라 어떻게 사용했냐에 따라 목과 스텁으로 구분되는 게 요점이다.
목과 스텁의 리팩터링 내성
앞서 좋은 단위 테스트 4대 요소에서 배운 리팩터링 내성이라는 관점에서 목과 스텁을 바라보자. 목은 이메일 발송과 같이 외부로 나가는 상호작용이다. 이메일 발송은 실제 사용자가 기대하는, 즉 최종 결과물에 속하기 때문에 이를 검증해도 무방하다. 구현 로직을 변경하더라도 이메일 발송이라는 결과는 변하지 않기 때문에 리팩터링 내성에 어긋나지 않는다.
스텁은 어떨까. 데이터베이스에서 데이터를 조회하는 기능이 최종 결과물일까? 그렇지 않다. 스텁은 최종 결과를 산출해내기 위한 중간 단계에 불과하다. 즉, 스텁은 sut가 출력을 생성하도록 입력값을 제공한다. 따라서 우리는 스텁을 검증하면 안 된다. 이는 테스트가 깨지기 쉬운 안티 패턴에 해당된다.
잘 생각해보면 스텁 응답값을 세팅하기 위해 내부 구현 메서드를 직접적으로 의존한다는 사실을 발견할 수 있다. 이는 테스트가 리팩터링 내성이 부족하다는 의미이며, 해당 데이터베이스가 내부적으로 사용하는 경우라면 변경 가능성도 크다. 단위 테스트와 통합 테스트 각각의 상황에서 어떻게 해야 할지는 추후에 다루도록 하겠다.
테스트 코드 역시 코드이기 때문에 지속적으로 관리가 필요하다. 따라서 무분별하게 테스트 코드를 양산하는 것은 좋지 않다.
최소한의 유지비로 최대 가치를 뽑아내기 위해서는, 가치 있는 테스트의 식별과 작성이 가능해야 한다. 이번 장에서는 가치 있는 테스트를 식별하는 방법을 학습해 보자!
좋은 단위 테스트의 4대 요소
1. 회귀 방지
회귀란 소프트웨어 버그를 말한다. 그러니 회귀 방지란, 구현한 로직이나 외부 라이브러리와 같은 프로젝트 요소들이 문제가 없도록 검증하는 것을 의미한다. 테스트가 많은 코드를 실행할수록 회귀 방지 지표가 극대화된다.
2. 리팩터링 내성
리팩터링 내성이란, 테스트를 실패로 바꾸지 않으면서 기존 로직을 리팩터링 할 수 있는지에 대한 척도이다. 기능을 이전과 동일하게 동작하도록 수정을 했지만 테스트가 실패하는 것을 거짓 양성이라고 부른다. 이런 거짓 양성이 빈번하다면, 테스트 코드에 대한 신뢰도 저하와 그로 인해 리팩터링을 꺼리게 되는 부작용을 낳는다.
이를 피하기 위해서는 테스트가 구현 세부 사항과 결합되지 않도록 주의해야 한다. 예를 들어 어떤 사람이 마트에서 과자를 샀다고 생각해 보자. 리팩터링 내성이 좋은 테스트에서는 과자의 재고가 감소했는지만 검증한다. 어떤 방식(또는 알고리즘)으로 재고가 감소했는지, 재고를 감소하는 메서드가 호출 됐는지는 검증하지 않는다. 이는 구현 세부 사항이다.
즉, 리팩터링 내성을 높이는 방법은 테스트의 최종 결과를 목표로 하는 것이다. 이는 곧 테스트를 작성할 때는 블랙박스 테스트가 더 낫다는 것을 의미한다. 유일한 예외는 알고리즘 복잡도가 높은 유틸리티 코드를 다루는 경우다.
3. 빠른 피드백
테스트 속도가 빠를수록 테스트 스위트에서 더 많은 테스트를 수행할 수 있고 더 자주 실행할 수 있다. 이는 단위 테스트의 필수 속성이다.
4. 유지 보수성
유지비를 평가하는 지표로, 다음 두 가지의 주요 요소로 구성된다.
테스트가 얼마나 이해하기 어려운가 : 테스트는 코드 라인이 적을수록 더 읽기 쉽다. 그렇다고 인위적으로 압축하라는 의미는 아니다.
테스트가 얼마나 실행하기 어려운가 : 테스트가 외부 의존성을 가지고 있을 수 있다. 그런 테스트를 실행하려면 데이터베이스 서버를 재부팅하고 네트워크 연결 문제를 해결하는 등의 절차가 필요하다.
이상적인 테스트?
위의 네 가지 특성을 모두 만족시키는 이상적인 테스트는 존재하지 않는다. 왜냐하면 처음 세 가지 특성인 회귀 방지, 리팩터링 내성, 빠른 피드백은 상호 배타적이기 때문이다.(유지 보수성은 독립적으로 극대화할 수 있다!) 여기서 리팩터링 내성은 있거나 없거나 둘 중 하나이기 때문에, 회귀 방지와 빠른 피드백 사이에서 절충해야 한다.
단위 테스트란 1) 작은 코드 조각을 검증하고, 2) 빠르게 수행하고, 3) 격리된 방식으로 처리하는 자동화된 테스트다. 여기서 작은 코드 조각과 격리된 방식을 어떻게 정의하느냐에 따라 런던파와 고전파로 나뉜다.
가령 `친구가 공을 던진다` 라는 행위를 각 분파의 방식으로 테스트 해보겠다. 런던파는 단일 클래스만을 검증한다. 따라서 공을 목으로 처리하고 친구라는 클래스만 검증한다. 반면, 고전파는 단일 동작을 검증하기 때문에, 친구와 공 모두 실제 클래스를 사용하여 행위를 검증한다.
격리 주체
단위의 크기
테스트 대역 사용 대상
런던파
단위
단일 클래스
불변 의존성 외 모든 의존성
고전파
단위 테스트
단일 클래스 또는 클래스 세트
공유 의존성
위 표에 나와 있듯이 격리 방식에도 차이가 있다. 런던파의 격리 주체는 단일 클래스이기 때문에 친구와 공이 서로 격리되어 있다. 하지만 고전파는 클래스가 아니라 테스트 단위로 격리한다. 성공과 실패를 각각 테스트 한다고 하면, 두 테스트는 서로 간섭하지 말아야 한다는 의미이다. 그러기 위해서는 데이터베이스나 싱글톤 같은 요소가 서로 공유되지 말아야 한다.
필자는 고전파 방식이 리팩터링 내성과 테스트 유효성이 높다고 판단하였기 때문에 해당 분파를 지지한다.
통합 테스트
통합 테스트는 단위 테스트 정의 중 하나를 충족하지 않는 테스트다. 데이터베이스와 같은 공유 의존성에 접근하거나, 둘 이상의 동작 단위를 테스트하는 것이 이에 해당된다.
엔드 투 엔드 테스트
엔드 투 엔드 테스트는 통합 테스트의 일부로, 일반적으로 외부 의존성을 더 많이 포함한다. 보통 통합 테스트는 프로세스 외부 의존성을 한두 개만 갖고 동작한다. 반면에 엔드 투 엔드 테스트는 프로세스 외부 의존성을 전부 또는 대다수 갖고 작동한다. 즉, 모든 외부 애플리케이션을 포함해 시스템을 최종 사용자의 관점에서 검증하는 것을 말한다. 동의어로 UI 테스트, GUI 테스트, 기능 테스트가 있다.
테스트 코드가 중요하다고는 하지만 막상 일정에 급급하다 보면 테스트를 생략하기 부지기수다.
이번 장에서는 테스트 코드의 중요성을 학습하고, 오히려 일정 단축에 도움이 된다는 사실을 알아보자!
테스트 코드를 작성해야 하는 이유
1. 작성한 코드의 검증
일정상 기능 개발에 급급해서 검증이 안된 api를 내보내는 경우가 종종 있다. 하지만 외부(클라이언트, qa, 사용자 등)에서 버그를 발견되면 더 큰 대가를 치러야 한다. 치러야 할 대가는 서버 측에만 국한된 것이 아니며, 경험한 바에 따르면 아래와 같다.
서버에서 들어가는 비용
문제 인식을 위한 커뮤니케이션 비용
문제 해결 비용
센트리, 키바나 등을 사용한 원인 추적
로컬에서 문제 재현
로직 수정 및 배포
개선된 로직이 야기하는 새로운 에러의 가능성
외부(클라이언트, qa, 사용자 등)에서 들어가는 비용
문제 전파를 위한 커뮤니케이션 비용
기능에 대한 신뢰 감소
해결되기 전까지 다음 작업이 블로킹
개선된 기능을 재테스트하는 비용
2. 리팩터링의 어려움
테스트 코드가 없으면 검증이 상당히 번거롭기 때문에, 로직을 개선하거나 프로젝트 구조를 변경하기 꺼려진다. 반면, 테스트 코드가 잘 짜여있다면 언제든지 테스트할 수 있기 때문에 코드를 변경하는 데에 망설일 이유가 전혀 없다. 따라서 테스트 코드를 적절하게 작성한다면 리팩터링을 통해 더 나은 코드와 더 나은 구조를 지향할 수 있다.
마치며
따라서 테스트 코드를 작성하는 습관을 들이자. 그렇다고 아무렇게나 테스트 코드를 작성하라는 의미는 아니다. 테스트 코드 역시 지속적으로 관리해야 하는 '코드'이기 때문에, 무분별한 테스트 코드의 양산은 오히려 독이 될 수 있음을 명심하자.