[단위 테스트] 단위 테스트란 무엇인가
안녕하세요. 황진성입니다.
이번 글에서는 "단위 테스트 2장"을 읽고 내용 정리와 제 생각을 정리해보겠습니다.
1. 단위 테스트의 정의
이 책에서는 다음 조건들을 모두 만족할 때, 단위 테스트라고 주장합니다.
단위 테스트는,
- 작은 코드 조각을 검증하고,
- 빠르게 수행하고,
- 격리된 방식으로 처리하는 자동화된 테스트다.
1, 2번은 논란의 여지가 없지만, 3번은 논란의 여지가 있습니다.
“격리가 무엇인가?” 에서 비롯된 논쟁입니다.
이 논쟁에서 고전파와 런던파가 구분됩니다.
런던파
- 테스트 대상과 의존성을 분리한다. 그렇기 때문에, 테스트가 실패할 경우 어디서 실패했는지 명확하게 알 수 있다.
- 테스트 대상을 협력자로부터 격리하는 것을 의미한다.
- 즉, 모든 의존성을 테스트 대역(test double)로 대체해야 한다.
- 객체 그래프를 분할할 수 있다. 결국 위와 같은 말이다.
고전파
- 한 번에 여러 개의 테스트를 진행할 수 있다. (하지만 한 번에 하나의 클래스를 테스트 하려고 노력해야 한다)
- 고전파 방식은 코드를 격리하지 않아도 된다. 하지만 단위 테스트는 서로 격리해서 실행해야 한다.
각자의 단점은 vice versa.
용어 정리
- SUT(System Under Test) : 테스트 대상 시스템
- 아래 예제에서는
Customer
에 해당된다.
- 아래 예제에서는
- MUT(Method Under Test) : 테스트 대상 메서드
- 아래 예제에서는
Customer
가 호출한 메서드에 해당된다. 일반적으로 SUT와 MUT는 동의어로 사용하지만, MUT는 메서드를 가리키는 데 반해, SUT는 클래스 전체를 가리킨다.
- 아래 예제에서는
- 협력자(Collaborator) : 의존성
- 아래 예제에서는
Store
에 해당된다.
- 아래 예제에서는
고전파 예제 (Java + JUnit5)
public class Example2_1 {
@Test
void Purchase_succeeds_when_enough_inventory() throws Exception {
// Given
final Store store = new Store();
store.addInventory(Product.Shampoo, 10);
final Customer customer = new Customer();
// When
final boolean success = customer.purchase(store, Product.Shampoo, 5);
// Then
assertTrue(success);
assertSame(5, store.getInventory(Product.Shampoo));
}
@Test
void Purchase_fails_when_not_enough_inventory() throws Exception {
// Given
final Store store = new Store();
store.addInventory(Product.Shampoo, 10);
final Customer customer = new Customer();
// When
final boolean success = customer.purchase(store, Product.Shampoo, 15);
// Then
assertFalse(success);
assertSame(10, store.getInventory(Product.Shampoo));
}
}
- 고전파 방식은 협력자를 대체하지 않고, 운영용 인스턴스를 사용합니다.
- Customer와 Store를 동시에 테스트 할 수 있지만, 정작 테스트 대상인 Customer가 정상적으로 동작하더라도 Store가 정상적으로 동작하지 않으면 테스트가 실패한다.
고전파 방식으로 작성하면, 테스트 대상 밖인 Store에 오류가 있는 경우에도 테스트가 실패하게 된다는 단점이 있다.
이를 런던파 방식으로 작성하면 Mock 프레임워크를 사용해서 해결할 수 있다.
이 글에서는 Java 진영에서 가장 많이 사용되는 Mock 프레임워크인 Mockito를 사용한다.
런던파 예제 (Java + JUnit5)
@ExtendWith(MockitoExtension.class)
public class Example2_2 {
@Mock
Store storeMock;
@Test
public void Purchase_succeeds_when_enough_inventory() {
// Given
given(storeMock.hasEnoughInventory(Product.Shampoo, 5)).willReturn(true);
final Customer customer = new Customer();
// When
final boolean success = customer.purchase(storeMock, Product.Shampoo, 5);
// Then
assertTrue(success);
then(storeMock).should(times(1)).removeInventory(Product.Shampoo, 5);
}
@Test
public void Purchase_fails_when_not_enough_inventory() {
// Given
given(storeMock.hasEnoughInventory(Product.Shampoo, 5)).willReturn(false);
final Customer customer = new Customer();
// When
final boolean success = customer.purchase(storeMock, Product.Shampoo, 5);
// Then
assertFalse(success);
then(storeMock).should(never()).removeInventory(Product.Shampoo, 5);
}
}
Store
에 mocking을 사용하지 않으면, store의 상태를 직접 검증한다.- 하지만
Store
에 mocking을 사용하면,Customer
와Store
의 상호 작용을 검사한다. - mock 객체 내부의 메서드 실행 횟수까지
then().should()
로 검증할 수 있다.
의존성에 관해
공유 의존성
- 테스트 간 공유되고, 서로의 결과에 영향을 미칠 수 있는 수단을 제공하는 의존성
static …
같은 상황- 거의 항상 프로세스 외부에 존재함.
비공개 의존성
- 테스트 간 공유되지 않는 의존성
- 거의 항상 프로세스 범위를 벗어나지 않음.
프로세스 외부 의존성
- 프로세스 외부에서 실행되는 의존성이다.
- DBMS는 프로세스 외부이며, 공유 의존성이다.
- 도커 컨테이너의 DBMS는 프로세스 외부이며, 공유하지 않는 의존성이다. Testcontainers 라는 좋은 라이브러리가 존재한다.
(아래 링크에 접속하면 예제가 있습니다. 추후 다른 글에서 자세하게 언급하도록 하겠습니다.)
휘발성 의존성
- 예를 들어 난수 생성기가 있다.
2. 단위 테스트의 런던파와 고전파
격리 주체 | 단위의 크기 | 테스트 대역 사용 대상 | |
---|---|---|---|
런던파 | 단위 | 단일 클래스 | 불변 의존성 외 모든 의존성 |
고전파 | 단위 테스트 | 단일 클래스 또는 클래스 세트 | 공유 의존성 |
- 런던파는 테스트 대상 시스템(SUT)에서 협력자(Collaborator)를 격리한다.
- 고전파는 단위 테스트끼리 격리한다.
3. 고전파와 런던파의 비교
이 책의 저자인 블라디미르는 “고전파"를 선호한다고 한다.
그 이유는 “런던파”는 “고전파”보다 불안정한 경향이 있기 때문이라고 한다.
한 번에 한 클래스만 테스트하기
객체지향 개발자들은 자연스럽게 클래스를 테스트에서 검증할 원자 단위로 취급하게 된다.
어느 정도 이해는 되지만, 오해의 소지가 있다.
테스트는 무엇을 검증하는지 정확히 이해할 수 있어야 한다.
프로그래머가 아닌 일반 사람들에게도 응집도가 높고 의미가 있어야 한다.
# Case 1
🐶 우리집 강아지를 부르면, 바로 나에게 온다.
# Case 2
🐕 우리집 강아지를 부르면 먼저 왼쪽 앞다리를 움직이고, 이어서 오른쪽 앞다리를 움직이고, 머리를 돌리고, 꼬리를 흔들기 시작한다…
첫번째 예제는 동작을 명확히 이해할 수 있다. -> 응집도가 높음
하지만 두번째 예제는 동작을 명확이 이해할 수 없을 뿐더러, 강아지가 나에게 오는지도 판단하기 힘들다. -> 응집도가 낮음
응집도가 높고/낮음을 와닿게 표현한 예제이다.
상호 연결된 클래스의 큰 그래프를 단위 테스트하기
고전파
- 테스트 대상을 설정할 때 전체 객체 그래프를 다시 생성해야 한다.
- 전체 객체 그래프를 생성하다 보면 작업량이 많이 늘어날 수 있는데, 이는 production 코드가 잘못 설계된 것을 알려주는 징후일 수 있다.
런던파
- mock을 사용해 테스트 대상을 협력자로부터 격리할 수 있어서 그래프 분리가 가능하다.
- mock을 사용하면 작업량이 적어지지만, production 코드의 잘못된 설계를 잠깐 숨기는 것 뿐이다.
버그 위치 정확히 찾아내기
런던파
- 테스트가 있는 시스템에 버그가 생기면, 테스트 대상을 포함한 테스트만 실패한다.
고전파
- 하나의 버그가 전체 시스템에 걸쳐서 테스트를 실패를 야기하는 파급 효과를 초래할 수 있다.
- 이 때문에 문제를 해결하는 것이 더 어렵고, 디버깅도 오래 걸린다.
- 하지만 테스트를 정기적으로 실행하고, 코드가 변경될 때마다 실행된다면 최근에 변경된 부분만 추적하면 된다.
- 테스트 스위트 전체에 걸쳐 계단식으로 실패하는 것에도 가치가 있다. 방금 고장 낸 코드가 얼마나 큰 영향력을 미치는 지도 알 수 있기 때문이다.
고전파와 런던파 사이의 다른 차이점
TDD를 통한 시스템 설계 방식
- 런던파는 하향식 TDD로 구성된다. 이는 mocking이 가능하기 때문이다.
- 하향식이란 테스트를 구성하기 위해서 미리 준비를 다 해놓고 짜는게 아니라 해당 테스트만 구성하는 것을 얘기한다.
- 테스트를 하기전에 구성해야할 요소는 mocking 을 하면 되기 때문이다.
- 고전파는 상향식 TDD로 구성된다.
- 상향식이란 테스트를 구성하기 위해 미리 준비를 다하고 점점 위로 올라가는것을 얘기한다.
- 테스트를 하기전에 구성을 모두 해야하기 때문이다.
- 일반적으로 이렇다는 것이지, 항상 그런 것은 아니다.
과도한명세 문제
- 테스트가 테스트 대상의 구현 세부 사항에 너무 자주 결합되는 문제이다.
- 런던 스타일과 mock을 아무데나 쓰는 것에 대해 이의가 제기되는 편이다.
4. 두 분파의 통합 테스트
다시 언급하자면, 단위 테스트는 다음과 같은 특징을 가진 자동화된 테스트다.
- 작은 코드 조각을 검증하고,
- 빠르게 수행하고,
- 격리된 방식으로 처리한다.
1, 3은 명확해졌으니, 고전파 관점에서 이를 다시 작성해보자.
- 단일 동작 단위를 검증하고,
- 빠르게 수행하고,
- 다른 테스트와 별도로 처리한다.
하지만 통합 테스트는 이들 중 하나를 만족하지 못한다.
단일 동작 단위를 검증하고, 를 만족하지 못하는 경우
- 여러 테스트에서 사용하는 공유 의존성(ex 데이터베이스)에 변화가 생기면 의존하는 모든 테스트에 영향을 미친다.
- 공유 의존성(ex 데이터베이스)을 사용하는 테스트는 별도의 테스트로 분리할 수 없다.
빠르게 수행하고, 를 만족하지 못하는 경우
- 테스트를 통해 운영 환경과 동일한 동작을 기대하는 것이라면, 공유 의존성을 분리할 수 없다.
- 데이터베이스에 저장하는 것은, 메모리에 객체를 저장하는 것보다 훨씬 느리다.
- 테스트 1개를 실행할 때는 “고작 1초"정도 느려지겠지만, 테스트 스위트가 커질수록 이는 큰 영향을 미친다.
다른 테스트와 별도로 처리한다. 를 만족하지 못하는 경우
- 다른 팀에서 개발한 모듈과 연동해서 통합 테스트를 진행해야 하는 경우도 있다.
엔드 투 엔드 테스트(E2E Test)
통합 테스트 중 일부로, 엔드 투 엔드 테스트라는 것이 존재한다.
엔트 투 엔드 테스트(이하 E2E 테스트)는 코드가 프로세스 외부 종속성과 함께 어떻게 동작하는지 검증한다.
일반적인 통합 테스트와 E2E 테스트의 차이점은, E2E 테스트가 일반적으로 의존성을 더 많이 포함한다는 것이다.
통합 테스트는 보통 1~2개의 외부 의존성을 가지는데 반면, E2E 테스트는 전부 또는 대다수의 의존성을 가진다.
따라서 E2E 테스트라는 명칭은 모든 외부 애플리케이션을 포함해 시스템을 최종 사용자의 관점에서 검증하는 것을 의미한다.
만약 데이터베이스, 파일시스템, 결제 게이트웨이 라는 3가지 프로세스 외부 의존성으로 동작한다고 가정하자.
- 의존성 포함 : 데이터베이스, 파일시스템 → 완전히 제어 가능
- 테스트 대역 : 결제 게이트웨이 → 완전한 제어가 힘듦
E2E 테스트는 유지 보수 측면에서 가장 비용이 비싸기 때문에, 모든 단위 테스트와 통합 테스트가 끝난 후 진행하는 것이 좋다.
5. 결론, 그리고 내 생각
정답은 없는 것 같다. 런던파와 고전파를 잘 섞어 써야할 것 같다.
이 책의 저자, 그리고 켄트 백은 고전파를 옹호하지만 고전파가 꼭 좋은 것만은 아닌 것 같다.
내가 이렇게 주장하는 2가지 이유가 있는데,
너무 느린 테스트
고전파를 따르게 되면, MUT 1개를 실행할 때마다 @SpringBootTest
로 부팅해줘야 할 것 같다.
Spring Container 내에서는 Bean을 공유하는 형식이기 때문에, 공유 의존 비율을 낮추기 위해서는 매번 SpringBoot를 다시 띄워줘야 한다.
1장에서 살펴봤듯, 테스트 스위트를 운영하는 목적 중 하나가 개발 속도 향상이었다.
하지만 빌드 속도(?)가 느려지게 되면, 개발 속도 역시 느려진다. 딜레마에 빠지게 되는 것이다.
+ 테스트 속도를 따져야지, 빌드 속도를 왜 언급하냐? 무슨 상관이냐? 라고 의문을 가지는 분들도 있을 것 같다. 좋은 의문이다. 하지만 이렇게 표현한 이유는 저자가 고전파로 테스트를 진행할 경우 코드를 변경할 때마다 테스트를 실행해야 한다고 주장한다. 코드를 수정하고 빌드를 하지 않으면 무용지물이다. 빌드 과정에서 테스트를 포함시키는 것은 당연한 수순이기 때문이다. 실제로 Java 진영에서 많이 사용되는 빌드 도구 중 Gradle은 기본 빌드 옵션에 테스트를 포함한다.
일부만 테스트하고 싶다면 ?
위 강아지 예제를 다시 살펴보자.
# Case 1 (고전파)
🐶 우리집 강아지를 부르면, 바로 나에게 온다.
# Case 2 (런던파)
🐕 우리집 강아지를 부르면 먼저 왼쪽 앞다리를 움직이고, 이어서 오른쪽 앞다리를 움직이고, 머리를 돌리고, 꼬리를 흔들기 시작한다…
여기서 만약 고전파 방식으로 구현한다고 했을 때, 꼬리만 잘 움직이는지 테스트하고 싶다면 어떻게 해야할까?
명확한 해답이 떠오르지 않는다.
따라서 내 결론은,
정답은 없는 것 같다. 런던파와 고전파를 잘 섞어 써야할 것 같다.
이 글에서 사용된 모든 코드는 여기에서 확인하실 수 있습니다.
더불어, 책에서 사용된 C# 코드를 Java+JUnit5 로 수정하는 작업 진행 중이니 계속 follow up 해주세요 😊
감사합니다.