테스트 더블
고친 사람 github-actions[bot]
테스트 더블은 테스트하는 동안 진짜 부품이 할 일을 대신 맡습니다. 오래 걸리거나 마음대로 다룰 수 없는 부품을 테스트에서 빼 줍니다. 그러면 시험하려는 코드만 따로 돌려 볼 수 있습니다. 영화에서 위험한 장면을 대신 찍는 대역 배우를 부르는 말에서 이름을 따왔습니다.
쉽고 빠른 이해
무슨 일을 하나 — 테스트할 때 진짜 부품 대신 가짜를 끼워 넣습니다. 주문 코드를 시험할 때 진짜 결제 서버 대신 가짜 결제 부품을 넘기는 식입니다. 이 가짜는 「결제 실패」만 돌려줍니다.
왜 하나 — 진짜 결제 서버를 부르면 테스트가 오래 걸리고 돈이 나갈 수도 있습니다. 결제 실패 같은 드문 상황은 일부러 일으키기도 어렵습니다. 가짜를 쓰면 원하는 상황을 코드 몇 줄로 만듭니다.
어떻게 도나
- 코드가 부품을 안에서 만들지 않고 밖에서 넘겨받게 짭니다
- 운영에서는 진짜를, 테스트에서는 가짜를 넘깁니다
- 테스트는 코드가 낸 결과나 가짜가 받은 호출을 확인합니다
대가 — 가짜가 진짜와 다르게 굴면 테스트는 통과하고 운영에서 깨집니다. 그래서 진짜 부품을 붙여 보는 테스트를 몇 개 따로 둡니다.
상세
주문 코드와 결제 부품 하나를 예로 삼아 끝까지 따라갑니다. 진짜 결제 부품을 붙인 채로 테스트하면 무엇이 곤란한지부터 짚습니다. 이어서 진짜를 가짜로 바꿔 끼우는 방법을 코드로 보입니다. 뒤쪽은 가짜의 다섯 갈래, 쓰면 잃는 것, 쓰지 않는 편이 나은 경우를 다룹니다.
진짜 부품을 붙인 채로 하는 테스트
주문을 받는 코드를 시험한다고 해 보겠습니다. 이 코드는 주문을 처리하다가 결제 서버를 불러 카드 결제를 요청합니다. 이렇게 시험하려는 코드가 테스트 대상입니다.
테스트 대상이 일을 하려고 불러 쓰는 다른 부품이 의존 객체입니다. 주문 코드에게는 결제 서버와 통신하는 결제 부품이 의존 객체입니다. 테스트 대상은 의존 객체가 답을 줘야 다음 단계로 넘어갑니다.
의존 객체가 진짜이면 테스트가 곤란해지는 경우가 넷 있습니다.
| 곤란한 점 | 예 |
|---|---|
| 오래 걸린다 | 네트워크 너머의 서버나 데이터베이스를 부를 때마다 기다린다 |
| 되돌릴 수 없는 일이 벌어진다 | 테스트를 돌릴 때마다 카드 결제가 되고 메일이 나간다 |
| 원하는 상황을 못 만든다 | 결제 거절이나 응답 없음 같은 상황을 일부러 일으키기 어렵다 |
| 결과가 매번 다르다 | 현재 시각이나 난수에 따라 답이 바뀐다 |
넷 모두 테스트 대상 코드의 잘못과 상관없이 테스트를 흔듭니다. 테스트가 실패했을 때 주문 코드가 틀렸는지 결제 서버가 잠깐 멈췄는지 가릴 수 없게 됩니다. 코드를 안 고쳤는데 돌릴 때마다 성공과 실패가 오가는 테스트를 불안정한 테스트라 합니다.
그래서 의존 객체를 테스트하는 동안만 가짜로 바꿔 끼웁니다. 이 가짜를 통틀어 테스트 더블이라고 부릅니다.
대역 배우에서 온 이름
영화에서 주연 배우가 직접 찍기 어려운 장면은 대역 배우가 찍습니다. 대역은 그 장면에 필요한 동작만 해내면 됩니다. 관객은 화면 속 인물을 주연으로 봅니다. 영어로 이런 대역을 더블이라고 부릅니다.
테스트 더블도 테스트 대상의 눈에 진짜 의존 객체처럼 보이기만 하면 됩니다. 진짜가 가진 기능을 전부 갖출 필요는 없습니다. 그 테스트가 부르는 메서드만 준비하면 됩니다.
가짜를 끼울 틈 만들기
가짜로 바꿔 끼우려면 테스트 대상이 의존 객체를 스스로 만들지 않아야 합니다. 주문 코드 안에서 진짜 결제 클래스를 new 로 직접 만들면 테스트가 끼어들 틈이 없습니다. 두 코드가 이렇게 서로에게 기대는 정도가 결합도입니다.
틈을 만드는 첫 단계는 결제 부품이 할 일을 인터페이스로 적어 두는 것입니다. 인터페이스는 메서드 이름과 모양만 정하고 몸통은 비워 둔 약속입니다. 진짜와 가짜가 같은 약속을 저마다 구현할 수 있게 됩니다.
둘째 단계는 주문 코드가 결제 부품을 생성자로 넘겨받게 하는 것입니다. 필요한 부품을 안에서 만들지 않고 밖에서 받아 쓰는 이 방식이 의존성 주입입니다.
아래는 두 단계를 갖춘 주문 코드입니다.
interface PaymentGateway {
boolean charge(String card, int amount);
}
class OrderService {
private final PaymentGateway gateway;
OrderService(PaymentGateway gateway) {
this.gateway = gateway;
}
String order(String card, int amount) {
return gateway.charge(card, amount)
? "주문 완료" : "결제 실패";
}
}
주문 코드는 PaymentGateway 라는 약속만 압니다. 그 뒤에 진짜 결제 부품이 있는지 가짜가 있는지는 모릅니다. 운영에서는 결제 서버와 통신하는 진짜 결제 부품을 넘깁니다.
테스트에서는 결제를 늘 거절하는 가짜를 만들어 넘깁니다. 이렇게 정해 둔 답만 돌려주는 테스트 더블을 스텁이라 합니다.
class DeclineStub implements PaymentGateway {
public boolean charge(String c, int a) {
return false;
}
}
var s = new OrderService(new DeclineStub());
s.order("1234", 5000) // "결제 실패"
결제 서버를 한 번도 부르지 않고 결제 거절 상황을 만들었습니다.
같은 주문 코드가 운영과 테스트에서 받는 결제 부품만 다릅니다.
flowchart TD
O["주문 코드"] --> I["PaymentGateway 약속"]
I -->|운영| R["진짜 결제 부품"]
I -->|테스트| S["결제를 거절하는 스텁"]
R --> P["결제 서버"]
테스트 더블의 다섯 갈래
테스트 더블은 하는 일에 따라 다섯으로 나뉩니다. 가르는 기준은 둘입니다. 테스트 대상에게 무엇을 돌려주는지, 그리고 테스트가 끝난 뒤 무엇을 확인하는지입니다.
| 이름 | 하는 일 | 쓰는 때 |
|---|---|---|
| 더미 객체 | 아무 일도 안 한다. 인자 칸만 채운다 | 메서드가 인자를 요구하지만 그 테스트에서는 안 쓰일 때 |
| 스텁 | 정해 둔 답을 돌려준다 | 테스트 대상에게 특정 상황을 보여 주고 싶을 때 |
| 테스트 스파이 | 스텁처럼 답을 주면서 어떻게 불렸는지 적어 둔다 | 호출 횟수나 넘어온 인자를 나중에 보고 싶을 때 |
| 목 객체 | 받을 호출을 미리 적어 두고, 다른 호출이 오면 테스트를 실패시킨다 | 메일을 보냈는지처럼 호출 자체가 결과일 때 |
| 페이크 객체 | 작지만 동작하는 구현을 갖는다. 메모리에 담는 저장소가 흔한 예다 | 여러 번 읽고 쓰는 흐름을 스텁으로 흉내 내기 번거로울 때 |
더미와 스텁은 테스트 대상에게 값을 공급하기만 합니다. 스파이와 목은 테스트 대상이 무엇을 불렀는지 지켜봅니다. 페이크는 정해 둔 답 대신 스스로 동작해서 답을 만듭니다.
실무에서는 다섯을 가리지 않고 전부 목이라고 부르는 일이 많습니다. 가짜를 만들어 주는 라이브러리를 모킹 프레임워크라고 부르는 것도 그래서입니다. 테스트 더블은 다섯을 한데 묶는 이름입니다. 목은 그중 하나입니다.
결과를 보는 테스트와 호출을 보는 테스트
테스트 더블을 쓰는 테스트는 무엇을 확인하느냐에 따라 둘로 갈립니다. 하나는 테스트 대상이 낸 결과를 봅니다. 앞의 스텁 예에서 주문 코드가 「결제 실패」를 돌려줬는지 본 것이 그렇습니다. 이런 확인이 상태 검증입니다.
다른 하나는 테스트 대상이 의존 객체를 어떻게 불렀는지 봅니다. 결제가 한 번만 요청됐는지, 금액이 맞게 넘어갔는지를 확인하는 식입니다. 이런 확인이 행위 검증입니다. 스파이와 목이 이 일을 합니다.
아래 스파이로 주문 코드가 결제를 몇 번, 얼마로 불렀는지 확인해 보겠습니다.
class SpyGateway implements PaymentGateway {
int calls = 0;
int lastAmount = 0;
public boolean charge(String c, int a) {
calls++;
lastAmount = a;
return true;
}
}
var spy = new SpyGateway();
new OrderService(spy).order("1234", 5000);
spy.calls // 1
spy.lastAmount // 5000
charge 가 불리면 스파이는 결제를 성공으로 답합니다. 그 사이에 불린 횟수와 금액을 적어 둡니다. 테스트는 그 기록을 꺼내 결제가 한 번, 5000 원으로 요청됐는지 봅니다.
쓰면 잃는 것
테스트 더블은 진짜가 아니라서 진짜와 다르게 굴 수 있습니다. 진짜 결제 서버는 카드 번호 형식이 틀리면 오류를 냅니다. 스텁은 그런 검사를 하지 않습니다. 그래서 테스트는 통과합니다. 운영에 가서야 깨집니다.
코드 조각 하나를 떼어 시험하는 테스트를 단위 테스트라 합니다. 앞의 스텁 예도 주문 코드 하나만 떼어 시험한 단위 테스트입니다. 테스트 더블로 짠 단위 테스트만으로는 가짜와 진짜가 어긋나는 곳을 잡지 못합니다.
그래서 진짜 부품을 붙여 조각 사이의 이음매를 확인하는 테스트를 몇 개 따로 둡니다. 이런 테스트가 통합 테스트입니다.
호출을 확인하는 테스트는 테스트 대상의 속사정에 묶입니다. 결제 약속 PaymentGateway 에 잔액을 묻는 메서드 balance 를 하나 더 두었다고 해 보겠습니다. 주문 결과는 그대로 두고, 결제 전에 잔액을 한 번 묻게 주문 코드를 고칩니다.
앞의 스파이는 어떤 호출이 와도 테스트를 실패시키지 않습니다. 목은 다릅니다. 받을 호출을 미리 적어 둔 목은 적어 두지 않은 balance 호출이 오는 그 자리에서 테스트를 실패시킵니다.
동작은 두고 코드 구조만 바꾸는 일이 리팩터링입니다. 호출을 확인하는 테스트가 많으면 리팩터링할 때마다 테스트도 같이 고쳐야 합니다.
가짜를 만들고 답을 정해 두는 준비 코드도 늘어납니다. 준비 코드가 테스트 본문보다 길어지면 읽는 사람이 무엇을 시험하는지 찾기 어려워집니다.
언제 쓰고 언제 안 쓰나
진짜 의존 객체를 부르면 앞의 표에 적은 곤란함이 생길 때 씁니다. 네트워크 너머의 서버, 결제나 메일처럼 되돌릴 수 없는 일을 하는 부품, 현재 시각이나 난수처럼 매번 값이 바뀌는 것이 대표입니다.
진짜를 써도 곤란함이 없으면 진짜를 씁니다. 값만 담는 객체나 계산만 하는 클래스는 늘 같은 답을 곧바로 냅니다. 이런 것을 가짜로 바꾸면 얻는 것 없이 가짜가 진짜와 어긋날 틈만 늘어납니다.
테스트 대상 둘레의 부품을 모두 가짜로 바꾸면 테스트가 코드 구조를 베낀 꼴이 됩니다. 그런 테스트는 코드가 바뀔 때마다 같이 깨집니다. 그러면서도 부품을 붙였을 때 생기는 잘못은 하나도 못 잡습니다.
관련 항목
테스트 더블의 다섯 갈래
더미 객체 · 스텁 · 테스트 스파이 · 목 객체 · 페이크 객체
테스트 더블을 쓰는 테스트 종류
단위 테스트 · 통합 테스트 · 컴포넌트 테스트 · 계약 테스트 · 테스트 피라미드
가짜를 끼울 틈을 만드는 설계 기법
의존성 주입 · 인터페이스 · 결합도 · 의존성 역전 원칙 · 다형성 · 심
테스트 더블로 결과를 확인하는 방식
상태 검증 · 행위 검증 · 단언 · 테스트 픽스처
테스트 더블을 만드는 도구와 기법
모킹 프레임워크 · Mockito · 스터빙 · 몽키 패칭 · 인메모리 데이터베이스
테스트 더블 대신 진짜에 가깝게 쓰는 환경
샌드박스 · 테스트 환경 · 테스트 컨테이너 · 스테이징 환경
테스트 더블을 잘못 쓸 때 생기는 테스트 문제
불안정한 테스트 · 깨지기 쉬운 테스트 · 과잉 명세 · 비결정성
테스트 더블이 속하는 상위 분류
QA와 테스트 · 테스트 자동화 · xUnit · 테스트 주도 개발 · 리팩터링
다른 이름: test double · Test Double · 테스트 대역 · 테스트더블