Test Double는 테스트 시에 실제 객체가 아닌 가짜 객체를 활용하여 테스트를 할 수 있도록 만들어주는 것을 말한다.
Mock은 호출될거라 예상되는 메서드에 대해서 반환하는 값의 기댓값을 미리 프로그래밍하는데 실제 객체를 활용하기에 어렵거나 비용이 많이 드는 경우 사용하게 되는 가짜 객체를 의미한다.
즉, Mock은 테스트할 때 필요한 실제 객체와 동일한 모의 객체를 만들어 테스트의 효용성을 높이기 위해 사용한다.
ex) Dummy,Fake, Mock, Spy, Stub 등이 있다.
@Test
public void OrderTest() {
// given
Order order = new Order("식빵", "딸기쨈", "우유");
WareHouse wareHouse = new WareHouse("식빵", "딸기쨈", "우유");
// when
order.check(wareHouse);
// then
assertThat(order.isPossible).isTrue();
assertThat(wareHouse.size()).isEqualTo(1);
}
// Mockito를 활용한 창고의 Mock 객체를 만드는 테스트
@Test
public void OrderTestMokito() {
// given
Order order = new Order("식빵", "딸기쨈", "우유");
WareHouse mockWareHouse = mock(WareHouse.class);
// given()으로 창고의 행동에 대한 기댓값을 미리 설정.
given(mockWareHouse.hasInventory("식빵", "딸기쨈", "우유"))
.willReturn(true);
// when
order.check(mockWareHouse);
// then
verify(mockWareHouse).hasInventory("식빵", "딸기쨈", "우유");
verify(mockWareHouse).remove("식빵", "딸기쨈", "우유");
// 창고의 메서드가 호출되었는지를 확인할 수 있다.
}
단위 테스트의 공통적 특징

더블을 사용하여 실제 의존 클래스로부터 격리된 테스트는 Solitary Unit Test, 더블을 사용하지 않는 테스트는 Sociable Unit Test라고 한다.
Sociable한 단위 테스트를 단위 테스트라고 봐야할지 미니 통합 테스트라고 봐야할지 혼란이 있었다.
단위 테스트를 작성할 때 Solitary하게 짜야(더블 O) 진짜 독립적인 테스트를 할 수 있다고 생각하는 사람이 있고 Sociable한 테스트도 충분하다고 생각하는 사람이 있다.
Solitary를 지향하는 사람들을 Mockist, Sociable를 지향하는 사람들을 Classicist라고 부른다.
상태검증이란 Classicist가 주로 사용하는 검증 방법.
// then
assertThat(order.isPossible).isTrue();
assertThat(wareHouse.size()).isEqualTo(1);
검증을 위해서 테스트하는 메서드나 상황이 수행된 다음 객체 내부의 상태를 확인하는 것이다. (김영한님 강의에서 봤던 테스트 코드가 이것이라 보면 된다.)
상태 검증을 사용하게 되면 테스트를 위해서 상태를 드러내야 하는 메서드가 생길 수도 있지만 행위가 끝난 후에 상태를 직접적으로 검증해야 하기 때문에 테스트에 대한 안정감을 증가할 수도 있다.
반면 행위검증이란 Mockist가 주로 사용하는 검증 방법.
// given()으로 창고의 행동에 대한 기댓값을 미리 설정.
given(mockWareHouse.hasInventory("식빵", "딸기쨈", "우유"))
.willReturn(true);
// when
order.check(mockWareHouse);
// then
verify(mockWareHouse).hasInventory("식빵", "딸기쨈", "우유");
verify(mockWareHouse).remove("식빵", "딸기쨈", "우유");
verify()를 통해 테스트하고자 하는 상황이 수행된 후 협력 객체의 특정 메서드가 호출되어 있는지를 검증한다.
만약 메서드가 호출되지 않았다면 테스트는 실패하게 된다.
객체 내부의 상태가 아닌 특정 행동이 이루어졌는지를 확인하는 검증을 행위 검증이라고 한다.
단어 정리
SUT(System Under Test) : 테스트 대상 클래스를 지칭
협력객체 : SUT를 테스트하기 위해 필요한 의존 클래스를 지칭
Classicist 방식
@Test
public void OrderTest() {
// given
List<Item> orderItems = List.of(new Item("식빵"), new Item("딸기쨈"), new Item("우유"));
Order order = new Order(orderItems);
List<Item> inventory = List.of(new Item("식빵"), new Item("딸기쨈"), new Item("우유"), new Item("사과"));
WareHouse wareHouse = new WareHouse(inventory);
order.check(wareHouse);
// when
Delivery delivery = new Delivery(order, 주소);
delivery.start();
// then
assertThat(delivery.isStarted()).isTrue();
}
given 단계에서 각각의 협력 객체들을 실제로 생성해 두었다.
지금은 간단한 예제여서 복잡하지 않지만 실제 테스트가 진행되는 when, then을 보게 되면 사전 준비가 굉장이 무거운 작업이 될 수 있다.
하지만 Fixture를 사용하는 테스트가 많아진다면 이 객체들을 따로 관리해서 재사용할 수 있다는 장점이 있다.
@Test
public void OrderMockTest() {
// given
Order MockOrder = mock(Order.class);
given(mockOrder.isPossible())
.willReturn(true);
//when
Delivery delivery = new Delivery(order, 주소);
// then
verify(mockOrder).isPossible();
}
Mock을 이용하면 SUT와 직접적인 협력을 맺고 있는 객체에 대해서만 사전 준비를 하면 되기 때문에 훨씬 짧아 보인다.
Mockist는 Fixture를 만드는 작업이 Mock 객체를 만드는 작업에 비해 비용이 크다고 생각할 수 있지만 Classicist는 재사용을 할 수 있기 때문에 매번 Mock 객체를 만드는 것이 비용이 더 크다고 말할 수도 있다.
TDD를 진행하면서 SUT와 협력하는 객체가 결정적인 메서드를 가지고 있거나 외부 API가 아닌 경우 쉽게 테스트를 작성할 수 있지만 결제 시스템처럼 외부의 신용카드 정보와 결제 시스템을 활용하는 상황에서는 테스트 진행을 어떻게 해야 할까

주문이 유효한 상태가 되려면 창고의 재고 확인 뿐만 아니라 유효한 결제가 일어났는지도 확인을 해야 한다.
하지만 결제 시스템은 외부 API(카카오페이, 네이버페이)를 이용한 결제 시스템일 수도 있다.
// given
CreditCard creditCard = new CreditCard("실제 카드 번호");
// when
Payment payment = new Payment(new KakaoPayApi());
payment.pay(creditCard, order);
// then
assertThat(payment.isPayed()).isTrue();
실제 객체를 활용해서 테스트를 진행한다면 확률적으로 테스트를 통과하게 되는 좋지 않은 결과를 낼 수도 있다. 이 테스트를 통과시키려면 실제 유효한 카드 번호를 넣어서 결제를 실제로 진행해야 한다.
그럼 매번 테스트를 진행할 때마다 본인의 통장에서 돈이 빠져나가게 된다.
Classicist라고 해서 테스트에서 무조건 실제 객체만을 이용하는 것을 아니다. 테스트를 하기 어려운 상황 속에서는 테스트 더블의 종류와 상관없이 활용할 수 있다.
결론적으로 객체 간의 협력 관계에서 테스트하기 어려운 부분이 있다면 Classicist라도 테스트 더블을 사용할 수 있다.
반대로 이런 외부 API를 사용하더라도 충분히 안정적이라고 판단이 된다면 충분히 안정적이라고 판단이 된다면 더블을 사용하지 않고 테스트를 진행할 수도 있다.
// given
CreditCard creditCard = new CreditCart(0000-0000-0000-0000);
KakaoPayApi mockKakaoPayApi = mock(KakaoPayApi.class);
given(mockKakaoPayApi.isPayable(creditCard))
.willReturn(true);
// when
Payment payment = new Payment(mockKakaoPayApi);
payment
// then
verify(mockKakaoPayApi).isPayable(creditCard);
Classicist라고 해서 테스트에서 무조건 실제 객체만을 이용하는 것을 아니다.
테스트가 구현의 영향을 받아 구현이 바뀌면 깨지게 된다.
주문이 들어오면 창고에서 상품의 재고를 확인하는데 중간에 유통기한 확인하는 메서드가 추가되어 구현 방법이 바뀌게 되면 상태 검증을 하는 Classic 테스트에서는 구현이 변경된다고 해서 테스트 코드가 바뀌지도, 깨지지도 않는다. 테스트 코드를 짜는 사람 입장에서는 check() 메서드 안에서 일어나는 일을 모르기 때문이다. 따라서 이는 구현 테스트가 아니라고 볼 수 있다.
// given
Order order = new Order("식빵", "딸기쨈", "우유");
WareHouse mockWareHouse = mock(WareHouse.class);
given(mockWareHouse.hasInventory("식빵", "딸기쨈", "우유"))
.willReturn(true);
// when
order.check(wareHouse);
// then
verify(mockWareHouse).hasInventory("식빵", "딸기쨈", "우유");
verify(mockWareHouse).remove("식빵", "딸기쨈", "우유");
Mock을 사용한 행위 검증을 하려고 한다면 테스트는 깨진다. 상품의 유통기한을 확인하는 메서드에 대한 기댓값 설정이 안 되어 있기 때문이다.
그래서 테스트를 작성하는 시점부터 내부 구현을 생각해야 하고 내부 구현이 바뀌면 테스트 세팅과 검증 내용이 달라져버린다.
이와 같이 변경을 해줘야 테스트를 통과할 수 있다.
// given
Order order = new Order("식빵", "딸기쨈", "우유");
WareHouse mockWareHouse = mock(WareHouse.class);
given(mockWareHouse.hasInventory("식빵", "딸기쨈", "우유"))
.willReturn(true);
given(mockWareHouse.isExpired("식빵", "딸기쨈", "우유")
.willREturn(true);
// when
order.check(wareHouse);
// then
verify(mockWareHouse).hasInventory("식빵", "딸기쨈", "우유");
verify(mockWareHouse).isExpired("식빵", "딸기쨈", "우유"); // 추가된 구현
verify(mockWareHouse).remove("식빵", "딸기쨈", "우유");
이렇게 실제로 클래스의 내부 구현은 자주 바뀔 수 있으므로 구현이 바뀔 때마다 테스트가 깨져서 바꿔아 한다면 리팩토링하기 두렵고 TDD에 대해 회의적으로 느껴질 수 있다.
여기서 격리란 DB를 공유하는 문제를 해결하기 위해 롤백하거나 컨테이너를 새로 띄우는 격리와는 다르다.
테스트 격리가 되지 않는다면
버그를 가지고 있는 클래스의 테스트 뿐만 아니라 그 클래스를 사용하는 모든 단위에서 테스트가 레드가 되는 경우입니다.

주문, 배달, 결제 테스트가 창고 객체와 직간접적으로 연결이 되는 상황에서 창고에서 버그가 발생한다면 협력 객체로 실제 창고를 이용하는 모든 단위의 테스트 또한 버그의 영향을 받게 된다.
레드란 : 실패하는 테스트 코드
하지만 Mock을 이용한 테스트들에선 버그가 발생한 클래스의 테스트만 실패하게 된다.

Mockist들은 이 상황이 테스트 간에 레드가 전파된다고 생각하여 좋지 않다고 생각하지만 Classicist들은 Mockist들만큼이나 이를 큰 문제로 여기지 않는다.
왜냐하면 전체 테스트를 자주 돌리고 기능 구현이나 리팩토링을 할 때마다 테스트를 돌리게 된다면 마지막에서 테스트를 돌린 부분에서 버그가 발생했다는 점을 쉽게 찾을 수 있기 때문이다.
그렇다고 항상 버그의 뿌리를 쉽게 찾을 수 있는 것은 아니다.

Sociable Unit 중 배달에 대한 테스트는 깨지는데 협력 객체들의 테스트는 모두 통과하는 경우가 있다. (디버그 곤란)
테스트가 꼼꼼히 짜여 있지 않는 경우에 발생하기 쉽다.
결론
테스트 세분화란
Classicist는 Inside-Out, Mockist는 Outside-In 방식으로 개발 진행.
도메인에서부터 출발하여 레드 그린 리팩토링의 과정을 거치면서 개발을 이어나간다.
Inside-Out은 도메인, 클래스 수준에서 시작하여 요구 사항에 맞춰 테스트를 작성하고 해당 테스트를 성공시키기 위해 내부 구현을 하게 되고 리팩토링을 진행하게 된다.
이 단계에서 협력 객체가 도출이 되거나 다른 객체와의 협력이 형성되는 경향이 있다. 기능이 완성되면 마지막으로 사용자와 맞닿아 있는 영역을 구현하게 된다.
이것이 개발이 내부에서 시작하여 바깥으로 향한다는 의미이다.

상품에 대한 재고를 관리하는 창고 도메인을 만들어야 된다고 생각했을 때, 테스트를 작성하기 위해 이미 구현된 상품 객체를 실제로 이용하여 TDD를 진행해야 하고 주문 도메인에 대한 TDD를 진행함에 있어서 실제 창고 객체를 이용해서 TDD를 진행하게 된다.

이런 반복적인 과정을 거친다면 기능 요구사항을 모두 만족하는 도메인 집합이 완성된다. 사용자와 맞닿아 있는 Controller를 구현하며 실제로 서비스가 이루어질 수 있게 된다.
- 내부 도메인에서부터 TDD를 시작하여 어느 객체부터 시작을 해야 할지 갈피를 못 잡는 경우가 있다.
- TDD에서 빠른 피드백이 가능하다.
- 초보자가 선택하기 좋다.
- 객체간의 협력이 어색하지 않다.
Outside-In은 UI와 가장 가까운 계층에서부터 시작하여 도메인 계층까지 내려가는 방식이다.
일반적으로 협력 객체가 구현이 되어 있지 않은 상태에서 시작하기에 구현보다는 객체들 간의 상호작용에 더 신경을 쓰게 된다.


Outside-In은 인수 테스트부터 시작한다.
사용자가 보낼 요청과 받는 응답에 대한 시나리오로 구성되기에 사용자와 가장 맞닿은 영역인 Controller에 대한 테스트부터 진행하게 된다.
아직 기능 구현이 아무것도 되지 않은 상태이기에 요구 사항을 만족시킬 수 있는 협력 객체들에 대해서 먼저 생각해야 한다.

인수 테스트의 시나리오가 배달 요청을 받으면 배달이 시작되었다는 응답을 보내주고 배달이라는 객체는 아직 구현되어 있지 않아 테스트를 통과하려면 Mock으로 처리를 해야 한다.
그 과정에서 배달이라는 객체는 배달 요청을 받으면 배달을 시작해야 한다는 역할과 책임이 부여된다.
그러면 Controller에 대한 테스트는 성공한다. 그럼 자연스럽게 아직 구현되지 않았던 배달 객체에 대한 TDD를 진행하게 된다.

이 그림에서도 주문 객체는 구현된 상태가 아니기에 배달 테스트를 통과하기 위해서는 Mock으로 생성되고 주문의 역할이 주어지게 된다.

주문 객체가 결제와 재고 관리를 모두 하는 것이 너무 많은 역할을 가지고 있다면 결제를 담당하는 객체와 재고 관리를 할 수 있는 창고 객체가 도출되어 이들에게 각자의 역할을 부여한다.

바깥에서부터 내부 구현까지 모두 완료되면 인수 테스트가 통과된다. Outside-In에서는 협력 객체들을 Mock으로 사용하기에 Mock처리한 객체들의 입력과 반환을 자연스럽게 상위 테스트에서 만들면서 협력 객체들의 인터페이스가 만들어진다.
- 협력 객체의 public api가 자연스레 도출된다.
- 객체들 간의 구현보다는 행위에 집중할 수 있다.
- 객체지향적인 코드 작성이 가능하다.
- 설계에 대한 기초적인 지식이 필요해서 숙련도가 필요하다.
- 오버 엔지니어링으로 이어질 수 있다.
🔗 https://velog.io/@lusate/TDD-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A3%BC%EB%8F%84-%EA%B0%9C%EB%B0%9C
안녕하세요 메일 확인 부탁드립니다!