우리는 소프트웨어가 어떻게 작동할지 코드를 작성하고 단위 테스트를 작성한다. 단위 테스트가 무엇인지 알아보기 전에 우리는 왜 단위 테스트를 작성할까?
지속 가능한 성장이 단위 테스트를 작성하는 목표이자 이유이다.
테스트 작성에는 상당한 시간이 필요하기 때문에 테스트가 없는 초기 프로젝트는 빠른 속도로 성장할 수 있다. 시간이 흘러 기능을 확장, 추가하면서 기존 코드를 수정하게 되는데 이때 테스트가 보험이자 안전망의 역할을 수행한다.
단위 테스트의 작성은 좋은 품질의 코드베이스의 유지라는 작은 부가적인 이점을 제공한다. 테스트하기 어려운 코드는 좋은 설계가 아니기 때문이다. 그렇다고 단위 테스트하기 좋은 코드라고해서 꼭 좋은 설계는 아니다.
단순히 테스트가 존재하기만 한다고 해서 지속 가능하게 성장할 수 있는 건 아니다. 잘못된 테스트라면 테스트가 없는 프로젝트 만큼 개발 속도가 느려진다. 좋지 않은 테스트를 작성할 바엔 차라리 작성하지 않는 것이 낫다고 주장하는 사람이 있기도 하다. 그 이유에 대해서는 다음 포스팅에 자세히 기술할 예정이다.
우리는 흔히 제품 코드와 테스트 코드를 분리해서 여기는 경우가 있다. 테스트 코드는 서비스 코드를 작성하면서 딸려오는 부속물정도로 말이다. 하지만 이 둘을 다르게 여기면 안된다. 테스트 코드도 우리 코드베이스의 일부이며 서비스 코드와 같이 다루어주어야 한다. 즉
테스트 케이스들의 집합을 테스트 suite라고 한다. 성공적인 테스트 suite의 특성은 다음과 같다.
각 특성을 간단히 살펴보면 다음과 같다.
개발과 함께 항상 실행되는 테스트들을 의미한다. 이상적으로는 제품 코드가 변경될 때 마다 테스트들이 수행되어야 한다.
가장 중요한 부분, 즉 제품의 비즈니스 비즈니스 로직들에 대해 단위 테스트를 작성하는 것이 좋다.
또한 높은 테스트 커버리지를 목표로 삼는 것은 좋지 않다. 테스트 커버리지가 높은 것이 테스트의 목적을 달성하고 있음을 의미하지 않는다. 또한 높은 커버리지를 채우기 위해 질 낮은 테스트들이 만들어질 가능성이 높기 때문이다. 물론 매우 낮은 커버리지는 확실한 문제의 징후이다.
두번째 특성을 포함하는 가장 중요한 특성이다. 모든 코드는 관리가 필요하다. 즉 코드의 양이 많아질수록 유지비용이 커진다. 테스트가 주는 이점이 유지비용보다 큰 가치있는 것들만 유지해야한다는 의미이다.
이를 위해서는 2가지가 필요하다.
책을 읽고 직접 적용하려 노력해본 결과 책의 내용대로 가치있는 테스트를 작성하는것이 식별하는 것 보다 훨씬 어렵다. 제품 코드를 잘 설계하는 능력이 포함되어 있기 때문이다.
이제 단위 테스트에 대해 알아보자.
다음과 같은 조건을 만족하는 테스트를 단위 테스트라고 한다.
작은 코드 조각을 검증한다.
빠르게 수행한다.
격리된 방식으로 자동화되어 처리된다.
단위 테스트에는 런던파, 고전파 두 가지의 분파가 존재한다. 분파에 따라 1번과 3번에 대한 해석이 달라진다.
런던파는 쉽게 말해 테스트 대역, 즉 ‘목’을 적극적으로 사용하는 분파이다. 우리 회사 테스트 스위트가 런던파에 가깝다고 볼 수 있다. 런던파는 단위 테스트의 정의를 다음과 같이 해석한다.
협력자라는 단어의 의미를 알기 위해서는 의존성 관련 단어들의 먼저 알아야 한다.
우리가 의존성이라고 부르는 것들은 다음과 같이 분류된다.
우선 크게 공유 의존성과 비공개 의존성으로 나누어 진다.
공유 의존성과 변경 가능한 의존성이 협력자이며, 런던파 스타일의 단위 테스트는 이들을 테스트 대역으로 대체한다.
다음은 런던파 스타일로 작성된 단위 테스트 예시다.
@Test
@DisplayName("창고에 충분한 물량이 없으면 구매 실패")
void puchaseTest01() {
// given
var storeMock = mock(Store.class);
given(storeMock.hasEnoughInventory(Product.SHAMPOO, 5)).willReturn(false);
var customer = new Customer();
// when
boolean success = customer.purchase(storeMock, Product.SHAMPOO, 5);
// then
assertFalse(success);
verify(storeMock, never()).removeInventory(Product.SHAMPOO, 5);
}
Customer 클래스의 비공개 의존성인 Store 클래스 객체를 목으로 대체하였다. 샴푸 5개가 있는지 확인하는 메소드가 실행되었을때 false를 반환하도록 테스트 대역을 설정하는 것을 확인할 수 있다. 그리고 검증 단계에서는 물건이 팔려 창고에서 샴푸 5개를 제거하는 메소드가 실행되지 않았음을 검증하고 있다.
이러한 방식으로 테스트를 작성하면 어떤 장점이 있을까?
앞선 그림에서 살짝 확인했겠지만, 고전파는 반대로 테스트 대역을 최소한으로 사용하는 방식을 추구한다. 데이터베이스와 같은 공유 의존성만 테스트 대역으로 대체하고 나머지 의존성은 실제 인스턴스를 사용하여 테스트를 작성한다. 이에 따라 단위 테스트의 정의는 다음과 같이 해석된다.
이전에 보았던 테스트 예제를 고전파 스타일로 작성하면 다음과 같다.
@Test
@DisplayName("창고에 충분한 물량이 없으면 구매 실패")
void puchaseTest01() {
// given
var store = new Store();
store.addInventory(Product.SHAMPOO, 5);
var customer = new Customer();
// when
boolean success = customer.purchase(store, Product.SHAMPOO, 10);
// then
assertFalse(success);
assertEquals(5, store.getInventory(Porduct.SHAMPOO));
}
Store 객체를 테스트 대역으로 만들지 않고 실제 인스턴스를 사용하는 것을 확인할 수 있다. 또한 테스트 상황을 mock 프레임워크의 메소드로 설정해주는 것이 아닌 실제로 값을 넣어주고 있다. 테스트 수행 시 실제 모든 코드가 작동하게 된다.
만약 내가 강아지를 한마리 키운다고 하자. 강아지 이름을 부르면 나에게 달려오도록 훈련시켰고 잘 작동하는지 테스트 코드로 작성하였다.
만약 고전파 형식으로 작성하였다면 테스트명은 다음과 같을 것이다.
우리 집 강아지를 부르면, 바로 나에게 달려온다.
반면 런던파가 작성하였다면 다음과 같은 테스트들이 만들어 졌을 것이다.
우리 집 강아지를 부른다.
왼쪽 앞다리를 움직인다.
오른쪽 앞다리를 움직인다.
왼족 뒷다리를 움직인다.
오른쪽 뒷다리를 움직인다.
머리를 돌린다.
꼬리를 흔든다.
…
전자는 테스트를 ‘동작의 단위’로 나누었고 후자는 ‘코드의 단위’로 나누었다고 볼 수 있다. 어떤 테스트 방식이 더 좋아보이는가?
런던파와 고전파가 받아들이는 단위 테스트의 정의가 다르기 때문에 통합 테스트 역시 달라진다. 각 분파에 따른 단위 테스트 정의를 다시 살펴보자.
통합 테스트의 정의는 단위 테스트의 정의 중 하나라도 만족하지 못하는 테스트이다.
따라서 런던파에게 고전파 스타일의 단위 테스트는 통합 테스트로 받아들여진다. 여러 클래스를 검증하게 되고 협력자들로부터 격리되지 않기 때문이다.
그렇다면 고전파에겐 어떤 테스트가 통합 테스트일까? 고전파에겐 공유 의존성을 mocking하지 않은 테스트가 통합 테스트이다. 예를 들어 실제 데이터베이스를 사용하여 테스트를 작성한다고 가정하자. 그렇다면 단위 테스트의 정의 중 2번과 3번이 충족되지 않는다.
우선 실제 데이터베이스에 값을 저장하고 조회하기 시작한다면 테스트의 속도가 확연히 느려질 것이다.또한 실제 데이터베이스에 저장되어 있는 값은 여러 단위 테스트가 공유할 수 있게 된다. 따라서 테스트들을 병렬로 실행하게 되면 다른 결과가 도출될 수 있다.
단위 테스트 구조에 대해서는 테스트 메소드 명명법, 메소드 분리 방법 등 다양한 내용이 있으나 해당 글에서는 테스트 픽스처 재사용에 대한 것만 소개하고자 한다.
영어 사전에 따르면 픽스처(fixture)는 아파트나 집에 고정적으로 붙어 있는 물체를 지칭한다. 다른 의미로는 날짜가 확정된 대회 경기 등이 있다.
해당 책에서 정의하는 테스트 픽스처는 다음과 같다.
테스트 픽스처는 테스트 실행 대상 객체다. 이 객체는 sut로 전달되는 인수다. DB에 있는 데이터나 하드디스크의 파일일 수도 있다.
이러한 객체는 각 테스트 실행 전에 알려진 고정 상태로 유지하기 때문에 동일한 결과를 생성한다. 따라서 픽스처라는 단어가 나왔다.
책 해석이 약간 난해한 느낌이 있는데, 간단히 말해 테스트를 수행하기 위해 해주어야 하는 모든 것이다.
필요한 인스턴스를 생성하고 값을 채워 넣어주고 mocking 하는 모든 것이라고 볼 수 있다. 이러한 코드는 양이 많거나 테스트마다 반복되는 경우가 많기 때문에 @BeforeEach annotation이나 메소드로 분리하여 재사용한다. 하지만 다음과 같이 재사용하는 것은 피해야 한다.
다음은 잘못된 테스트 픽스처 재사용 예시다.
public class CustomerTest {
private final Store store;
private final Customer sut;
@BeforeEach
public init() {
store = new Store();
store.addInventory(Product.SHAMPOO, 10);
sut = new Customer();
}
@Test
@DisplayName("창고에 충분한 재고가 있으면 구매 성공")
void purchaseTest01() {
boolean success = sut.purchase(store, Product.SHAMPOO, 5);
assertTrue(success);
assertEquals(5, store.getInventory(Product.SHAMPOO));
}
@Test
@DisplayName("창고에 재고가 부족하면 구매 실패")
void purchaseTest02() {
boolean success = sut.purchase(store, Product.SHAMPOO, 15);
assertFalse(success);
assertEquals(10, store.getInventory(Product.SHAMPOO));
}
}
Store와 테스트 대상 시스템인 Customer의 인스턴스를 만들고 테스트에 필요한 샴푸의 개수를 설정한다. 이 부부분을 메소드로 분리하고 BeforeEach annotation을 통해 매 테스트 이전에 실행될 수 있도록 하였다. 이로서 각 테스트 메소드가 매우 간결해졌다. 보기에도 깔끔해 보인다. 하지만 무엇이 문제일까?
테스트 간 결합도가 높아진다.
테스트 메소드를 보고 어떤 테스트인지 이해하기 힘들다.
@Test
@DisplayName("창고에 재고가 부족하면 구매 실패")
void purchaseTest02() {
boolean success = sut.purchase(store, Product.SHAMPOO, 15);
assertFalse(success);
assertEquals(10, store.getInventory(Product.SHAMPOO));
}
그렇다면 테스트 픽스처를 어떻게 재사용해야 할까?
public class CustomerTest {
private static Store createStoreWithInventory(Product product, int quantity) {
Store store = new Store();
store.addInventory(product, quantity);
return store;
}
private static Customer createCustomer() {
return new Customer();
}
@Test
@DisplayName("창고에 충분한 재고가 있으면 구매 성공")
void purchaseTest01() {
Store store = createStoreWithInventory(Product.SHAMPOO, 10);
Customer sut = createCustomer();
boolean success = sut.purchase(store, Product.SHAMPOO, 5);
assertTrue(success);
assertEquals(5, store.getInventory(Product.SHAMPOO));
}
@Test
@DisplayName("창고에 재고가 부족하면 구매 실패")
void purchaseTest02() {
Store store = createStoreWithInventory(Product.SHAMPOO, 10);
Customer sut = createCustomer();
boolean success = sut.purchase(store, Product.SHAMPOO, 15);
assertFalse(success);
assertEquals(10, store.getInventory(Product.SHAMPOO));
}
}
값으로 설정하고자 하는 Product와 개수를 parameter로 받는 메소드를 만들었다. 테스트마다 다르게 product와 개수를 설정할 수 있는 것이다.