효습
Chapter 07. 대역 본문
대역의 필요성
테스트를 작성하다 보면 외부 요인이 필요한 시점이 있다.
- 테스트 대상이 파일 시스템을 사용
- 테스트 대상에서 DB로부터 데이터를 조회하거나 데이터를 추가
- 테스트 대상에서 외부의 HTTP 서버와의 통신
테스트 대상이 이런 외부 요인에 의존하면 테스트를 작성하고 실행하기 어려워진다.
TDD는 테스트 작성 -> 통과시킬 만큼 구현 -> 리팩토링
의 과정을 짧은 흐름으로 반복해야하는데 외부와 통신이 되지 않으면 테스트를 진행할 수 없게 된다.
외부 요인 때문에 테스트가 어려울 때는 대역을 써서 테스트를 진행할 수 있음
test double : 테스트에서 대신 사용할 대역
대역에는 스텁 , 가짜 , 스파이 , 모의객체가 존재
대역을 이용한 테스트
외부 API를 이용하여 카드 번호가 유효한지 검사하는 CardNumberValidator
클래스 대신,
하지만 외부 API를 이용하기 때문에 대신할 대역 클래스 StubCardNumberValidator
클래스 사용하자
실제 카드번호 검증 기능을 구현하지 않고 단순한 구현으로 실제 구현을 대체
// 카드번호 유효한지 검사하는 대역 클래스
public class StubCardNumberValidator extends CardNumberValidator
{
private String invalidNo;
public void setInvalidNo(String invalidNo){
this.invalidNo = invalidNo;
}
@Override
public CardValidity validate(String cardNumber){
if(invalidNo != null && invalidNo.equals(cardNumber)){
return CardValidity.INVALID;
}
return CardValidity.VALID;
}
}
자동 이체 기능을 구현하는 AutoDebitRegister
클래스
//자동이체 기능 구현
public class AutoDebitRegister{
private CardNumberValidator validator;
private AutoDebitInfoRepository repository;
public AutoDebitRegister(CarNumberValidator validator,
AutoDebitInfoRepository repository){
this.validator = validator;
this.repository = repository;
}
public RegisterResult register(AutoDebitReq req){
CardValidity validity = validator.validate(req.getCardNumber());
if(validity != CardValidity.VALID){
return RegisterResult.error(validity);
}
AutoDebitInfo info = repository.findOne(req.getUserId());
if(info != null){
info.changeCardNumber(req.getCardNumber());
}
else{
AutoDebitInfo info = new AutoDebitInfo(req.getUserId() , req.getCardNumber() , LocalDateTime.now());
repository.save(newInfo());
}
return RegisterResult.success();
}
}
대역 클래스를 사용해서 AutoDebitRegister를 테스트
public calss AutoDebitRegister_Stub_Test{
private AutoDebitRegister register;
private StubCardNumberValidator StubCardNumberValidator;
private StubAutoDebitInfoRepository stubRepository;
@BeforeEach
void setUp(){
stubValidator = new StubCardNumberValidator();
stubRepository = new StubAutoDebitInfoRepository();
register = new AutoDebitRegister(stubValidator , stubRepository);
}
@Test
void invalidCard(){
stubValidator.setInvalidNo("111122223333");
AutoDebitReq req = new AutoDebitReq("user1" , "111122223333");
RegisterResult = register.register(req);
assertEquals(INVALID , result.getValidity());
}
}
AutoDebitRegister
객체를 생성할 때 stub 객체를 전달한다
추가로 도난 카드에 대한 테스트를 진행한다고 하자
StubCardNumberValidator
에 추가
.
.
.
public void setTheftNo(String theftNo) {
this.theftNo = theftNo;
}
@Override
public CardValidity validate(String cardNumber) {
if (invalidNo != null && invalidNo.equals(cardNumber)) {
return CardValidity.INVALID;
}
if (theftNo != null && theftNo.equals(cardNumber)) {
return CardValidity.THEFT;
}
return CardValidity.VALID;
}
.
.
테스트 코드에도 도난 카드번호 처리 부분을 추가해준다
@Test
void theftCard() {
stubValidator.setTheftNo("1234567890123456");
AutoDebitReq req = new AutoDebitReq("user1", "1234567890123456");
RegisterResult result = register.register(req);
assertEquals(CardValidity.THEFT, result.getValidity());
}
DB 연동 코드도 대역을 사용하기에 적합하다
원래 AutoDebitRegister은 해당 repository를 사용하여 DB에 데이터를 저장하지만 대역 클래스인MemoryAutoDebitInfoRepository
를 사용하여 DB 대신 맵을 이용하여 자동이체 정보를 저장한다.
메모리에만 데이터가 저장되므로 DB와 같은 영속성을 제공하지는 않지만 , 테스트에 사용할 수 있을 만큼의 기능을 제공한다.
// MemoryAutoDebitInfoRepository를 이용한 테스트
public class AutoDebitRegister_Fake_Test {
private AutoDebitRegister register;
private StubCardNumberValidator cardNumberValidator;
private MemoryAutoDebitInfoRepository repository;
@BeforeEach
void setUp() {
cardNumberValidator = new StubCardNumberValidator();
repository = new MemoryAutoDebitInfoRepository();
register = new AutoDebitRegister(cardNumberValidator, repository);
}
@Test
void alreadyRegistered_InfoUpdated() {
repository.save(new AutoDebitInfo("user1", "111222333444", LocalDateTime.now()));
AutoDebitReq req = new AutoDebitReq("user1", "123456789012");
RegisterResult result = this.register.register(req);
AutoDebitInfo saved = repository.findOne("user1");
assertEquals("123456789012", saved.getCardNumber());
}
@Test
void notYetRegistered_newInfoRegistered() {
AutoDebitReq req = new AutoDebitReq("user1", "1234123412341234");
RegisterResult result = this.register.register(req);
AutoDebitInfo saved = repository.findOne("user1");
assertEquals("1234123412341234", saved.getCardNumber());
}
}
대역을 사용한 외부 상황 흉내와 결과 검증
앞서 대역을 사용한 테스트에서는
- 외부 카드 정보 API 연동
- 자동이체 정보를 저장한 DB
없이 테스트를 수행했다.
이와 같은 테스트트 대역을 이용하여 외부의 상황을 흉내낸다는 것이다.
대역을 이용하면 외부에 대한 결과를 검증할 수도 있다.
대역의 종류
스텁 (Stub)
구현을 단순한 것으로 대체한다. 테스트에 맞게 단순히 원하는 동작을 수핸한다.
StubCardNumberValidator가 스텁 대역에 해당한다.
가짜(Fake)
제품에는 적합하지 않지만 , 실제 동작하는 구현을 제공한다.
DB 대신에 메모리를 이용해서 구현한 MemoryAutoDebitInfoRepository가 가짜 대역에 해당한다.
스파이(Spy)
호출한 내역을 기록한다. 기록한 내용은 테스트 결과를 검증할 때 사용한다.
스텁이기도 한다
모의(Mock)
기대한 대로 상호작용하는지 행위를 검증한다. 기대한 대로 동작하지 않으면 익셉션을 발생할 수 있다.
모의 객체는 스텁이자 스파이도 한다.
회원 가입 기능에거 각 대역을 사용해본다고 가정하자
회원가입 관련 타입은 다음과 같다.
- UserRegister : 회원 가입에 대한 핵심 로직을 수행한다
- WeakPasswordChecker : 암호가 약한지 검사한다
- UserRepository : 회원 정보를 저장하고 조회하는 기능을 제공한다
- EmailNotifier : 이메일 발송 기능을 제공한다.
UserRegister를 위한 테스트를 만들어 가는 과정에서 나머지 타입을 위한 대역으로 스텁 , 가짜 , 스파이 , 모의 객체를 사용해보자
약한 암호 확인 기능에 스텁 사용
암호가 약한 경우 회원 가입에 실패하는 테스트부터 시작하자
암호가 약한지를 UserRegister 말고 WeakPasswordChecker를 사용하자
public class UserRegisterTest {
private UserRegister userRegister;
private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker();
@BeforeEach
void setUp() {
userRegister = new UserRegister(stubPasswordChecker);
}
@DisplayName("약한 암호면 가입 실패")
@Test
void weakPassword() {
stubPasswordChecker.setWeak(true); // 암호가 약하다고 응답하도록 설정
assertThrows(WeakPasswordException.class, () -> {
userRegister.register("id", "pw", "email");
});
}
}
StubWeakPasswordChecker
은 스텁이므로 상위 타입인 WeakPasswordChecker
인터페이스를 만들고 이 인터페이스를 상속해서 스텁을 추가한다.
아직 어떤 메서드를 추가할지는 미정이므로 인터페이스를 작성하기만 해놓는다
public interface WeakPasswordChecker{
}
StubWeakPasswordChecker
클래스는 setWeak()
메서드가 필요하므로 이 메서드까지 구현한 코드 작성
public class StubWeakPasswordChecker implements WeakPasswordChecker {
private boolean weak;
public void setWeak(boolean weak) {
this.weak = weak;
}
@Override
public boolean checkPasswordWeak(String pw) {
return weak;
}
}
하지만 아직도 에러가 난다
아직 assertThrow() 부분에서 userRegister.register() 메서드가 익셉션을 발생시키지 않는다
그래서 아래와 같이 의존 대상에 필요한 기능을 도출한다
register 메서드에 익셉션을 추가한다.
public class UserRegister{
private WeakPasswordChecker passwordChecker;
public UserRegister(WeakPasswordChecker passwordChecker){
this.passwordChecker = passwordChecker;
}
public void register(String id , String pw , String email){
if(passwordChecker.checkPasswordWeak(pw)){
throw new WeakPasswordException();
}
}
}
아직 WeakPasswordChecker에는 메서드가 없다
이제 추가해주기
public interface WeakPasswordChecker{
boolean checkPasswordWeak(String pw);
}
근데 이러면 대역 클래스인 StubWeakPasswordChecker
에서 컴파일 에러가 남
스텁에 구현 추가
public class StubWeakPasswordChecker implements WeakPasswordChecker {
private boolean weak;
public void setWeak(boolean weak) {
this.weak = weak;
}
@Override
public boolean checkPasswordWeak(String pw) {
return weak;
}
리포지토리에 가짜 구현으로 사용
동일 ID를 가진 회원이 존재할 경우 익셉션을 발생하는 테스트
// 상황 : 동일 ID를 가진 회원 존재
상황을 만들기 위한 코드
// 실행 및 결과 검증
assertThrows(DupIdException.class, () -> {
userRegister.register("id", "pw", "email");
});
가장 쉬운 방법은 리포지토리에 사용자를 추가하는 것 → UserRegisterTest에 추가
public class UserRegisterTest {
private UserRegister userRegister;
private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker();
private MemoryUserRepository fakeRepository = new MemoryUserRepository();
@BeforeEach
void setUp() {
userRegister = new UserRegister(stubPasswordChecker, fakeRepository);
}
... 생략
@DisplayName("이미 같은 ID가 존재하면 가입 실패")
@Test
void dupIdExists() {
// 이미 같은 ID 존재하는 상황 만들기
fakeRepository.save(new User("id", "pw1", "email@email.com"));
assertThrows(DupIdException.class, () -> {
userRegister.register("id", "pw2", "email");
});
}
}
이후에 UserRepository, MemoryUserRepository, User 클래스를 생성하고 테스트에 통과할 수 있도록 컴파일 에러를 없애는 코드를 추가한다.
그 다음 이미 동일한 사용자가 존재하는 경우를 일반화하여 구현한다.
public class UserRegister {
private WeakPasswordChecker passwordChecker;
private UserRepository userRepository;
public UserRegister(WeakPasswordChecker passwordChecker, UserRepository userRepository) {
this.passwordChecker = passwordChecker;
this.userRepository = userRepository;
}
public void register(String id, String pw, String email) {
if (passwordChecker.checkPasswordWeak(pw)) {
throw new WeakPasswordException();
}
User user = userRepository.findById(id);
if (user != null) {
throw new DupIdException();
}
}
}
다음은 중복 아이디가 존재하지 않을 경우 회원 가입에 성공하는 경우도 테스트 한다.
이 테스트도 가짜 대역을 사용한다.
public class UserRegisterTest {
private UserRegister userRegister;
private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker();
private MemoryUserRepository fakeRepository = new MemoryUserRepository();
@BeforeEach
void setUp() {
userRegister = new UserRegister(stubPasswordChecker, fakeRepository);
}
... 생략
@DisplayName("같은 ID가 없으면 가입 성공함")
@Test
void noDupId_RegisterSuccess() {
userRegister.register("id", "pw", "email");
User savedUser = fakeRepository.findById("id"); // 가입 결과 확인
assertEquals("id", savedUser.getId());
assertEquals("email", savedUser.getEmail());
}
}
TDD에서 속도 조절은 중요하다. 상수를 이용해서 테스트를 통과시킨 다음에 구현을 일반화할 방법이 떠오르지 않으면 예를 추가하면서 점진적으로 구현을 완성해 나가면 된다.
이메일 발송 여부를 확인하기 위해 스파이를 사용
회원가입에 성공하면 이메일로 회원 가입 안내 메일을 발송한다고 하자
// 실행
userRegister.register("id", "pw", "email@somedomain.com");
// 결과
email@somedomain.com으로 이메일 발송을 요청했는지 확인
이메일 발송 여부를 어떻게 확인할 수 있을까?? → UserRegister가 EmailNotifier의 메일 발송 기능을 실행할 때 이메일 주소로 “email@somedomain.com”을 사용했는지 확인하는 것이다.
이런 용도로 사용할 수 있는 것이 스파이 대역이다
회원 가입 시 이메일을 올바르게 발송했는지 확인할 수 있으려면 EmailNotifier의 스파이 대역이 이메일 발송 여부와 발송을 요청할 때 사용한 이메일 주소를 제공할 수 있어야 한다.
EmailNotifier의 스파이 대역 구현
public class SpyEmailNotifier implements EmailNotifier {
private boolean called;
private String email;
public boolean isCalled() {
return called;
}
public String getEmail() {
return email;
}
}
EmailNotifier 인터페이스는 일단 생성만 해놓는다.
스파이 대역을 이용하여 메일 발송 여부를 확인하는 테스트를 작성하자
스파이 대역을 이용하여 메일 전송 여부를 확인하는 테스트 코드
public class UserRegisterTest {
private UserRegister userRegister;
private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker();
private MemoryUserRepository fakeRepository = new MemoryUserRepository();
private SpyEmailNotifier spyEmailNotifier = new SpyEmailNotifier();
@BeforeEach
void setUp() {
userRegister = new UserRegister(stubPasswordChecker, fakeRepository, spyEmailNotifier);
}
... 생략
@DisplayName("가입하면 메일을 전송함")
@Test
void whenRegisterThenSendMail() {
userRegister.register("id", "pw", "email@email.com");
assertTrue(spyEmailNotifier.isCalled());
assertEquals("email@email.com", spyEmailNotifier.getEmail());
}
}
UserRegister클래스의 생성자는 파라미터가 두 개이므로 컴파일 에러가 발생하므로 UserRegister 생성자 코드를 수정한다.
그런 다음 실행을 하면 아래와 같이 테스트에 실패한다
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
assertTrue(spyEmailNotifier.isCalled());
코드에서 테스트를 실패한 것이다.
이를 통과하기 위해서는 다음 두 가지를 해야한다.
- UserRegister가 EmailNotifier의 이메일 발송 기능을 호출
- 스파이의 이메일 발송 기능 구현에서 호출 여부 기록
UserRegister에 이메일 발송 코드 추가하고 EmailNotifier 인터페이스에 sendRegisterEmail() 메서드를 추가한다.
emailNotifier.sendRegisterEmail(email);
SpyEmailNotifier 클래스에 메서드를 구현한다.
public class SpyEmailNotifier implements EmailNotifier {
private boolean called;
private String email;
public boolean isCalled() {
return called;
}
public String getEmail() {
return email;
}
@Override
public void sendRegisterEmail(String email) {
this.called = true;
}
}
테스트를 통과시키기 위해 called 필드를 true로 설정하였으나 또 테스트 에러가 발생한다.
expected: <email@email.com> but was: <null>
Expected :email@email.com
Actual :null
이젠 assertEquals
부분에서 에러가 발생한다.
이를 통과시키기 위한 코드를 스파이에 추가하자
스파이 대역에 테스트를 통과시키기 위한 코드 추가
public class SpyEmailNotifier implements EmailNotifier {
private boolean called;
private String email;
public boolean isCalled() {
return called;
}
public String getEmail() {
return email;
}
@Override
public void sendRegisterEmail(String email) {
this.called = true;
this.email = email;
}
}
이제 모든 테스트가 통과한다.
모의 객체로 스텁과 스파이 대체
앞서 작성한 테스트 코드를 모의 객체를 이용해서 다시 작성해보자 , Mokito를 사용한다
Mokito를 이용한 모의 객체 생성과 사용
public class UserRegisterMockTest {
private UserRegister userRegister;
private WeakPasswordChecker mockPasswordChecker = Mockito.mock(WeakPasswordChecker.class);
private MemoryUserRepository fakeRepository = new MemoryUserRepository();
private EmailNotifier mockEmailNotifier = Mockito.mock(EmailNotifier.class);
@BeforeEach
void setUp() {
userRegister = new UserRegister(mockPasswordChecker, fakeRepository, mockEmailNotifier);
}
@DisplayName("약한 암호면 가입 실패")
@Test
void weakPassword() {
BDDMockito.given(mockPasswordChecker.checkPasswordWeak("pw")).willReturn(true);
assertThrows(WeakPasswordException.class, () -> {
userRegister.register("id", "pw", "email");
});
}
}
Mokito.mock()
메서드는 인자로 전달받은 타입의 모의 객체를 생성한다.
이 모의 객체를 이용하여 스텁을 대신하다.
BDDMockito
// "pw" 인자를 사용해서 모의 객체의 checkPasswordWeak 메서드를 호출하면
.given(mockPasswordChecker.checkPasswordWeak("pw"))
// 결과로 true를 리턴하라
.willReturn(true);
대역 객체가 기대하는 대로 상호작용했는지 확인하는 것이 모의 객체의 주요 기능이다.
모의 객체가 기대한 대로 불렀는지 검증하는 코드
@DisplayName("회원 가입시 암호 검사 수행함")
@Test
void checkPassword() {
userRegister.register("id", "pw", "email");
BDDMockito.then(mockPasswordChecker) //인자로 전달한 mockPasswordChecker 모의 객체의
.should() //특성 메서드가 호출됐는지 검증하는데
.checkPasswordWeak(BDDMockito.anyString());
//임의의 String 타입 인자를 이용해서 checkPasswordWeak()메서드 호출 여부를 확인한다.
}
모의 객체를 사용하면 스파이도 가능하다
모의 객체의 메서드를 호출할 때 전달한 인자를 구하는 코드
@DisplayName("가입하면 메일을 전송함")
@Test
void whenRegisterThenSendMail() {
userRegister.register("id", "pw", "email");
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
BDDMockito.then(mockEmailNotifier)
.should().sendRegisterEmail(captor.capture());
String realEmail = captor.getValue();
assertEquals("email@email.com", realEmail);
}
Mokito의 ArguementCaptor는 모의 객체를 메서드를 호출할 때 전달한 객체를 담는 기능을 제공한다.
BDDMokito.then().should()
로 모의 객체의 메서드가 호출됐는지 확인할 때 , ArgumentCaptor#cpature()
메서드를 사용하면 메서드를 호출할 때 전달한 인자가 ArgumentCaptor
에 담긴다.
ArgumentCaptor#getValue()
메서드를 사용해서 보관한 인자를 구할 수 있다.
상황과 결과 확인을 위한 협업 대상(의존) 도출과 대역 사용
한 테스트는 특정한 상황에서 기능을 실행하고 그 결과를 확인한다.
제어하기 힘든 외부 상황이 존재하면 다음과 같은 방법으로 의존을 도출하고 이를 대역으로 대신할 수 있다.
- 제어하기 힘든 외부 상황을 별도 타입으로 분리
- 테스트 코드는 별도로 분리한 타입의 대역을 생성
- 생성한 대역을 테스트 대상의 생성자 등을 이용해서 전달
- 대역을 이용해서 상황 구성
당장 구현하는 데 시간이 오래 걸리는 로직도 분리하기에 좋은 후보이다.
구현에 시간이 걸리는 로직을 별도 타입으로 분리하면 지금 당장 로직을 구현하지 않아도 관련 테스트를 통과시킬 수 있다.
대역과 개발 속도
TDD 과정에서 대역을 사용하지 않고 실제 구현을 사용한다면 다음과 같은 일이 벌어지게 된다.
- 카드 정보 제공 업체에서 도난 카드번호를 받을 때까지 테스트를 기다린다.
- 카드 정보 제공 API가 비정상 응답을 주는 상황을 테스트하기 위해 업체의 변경 대응을 기다린다.
- 회원 가입 테스트를 한 뒤에 편지가 도착할 때까지 메일함을 확인한다.
- 약한 암호 검사 기능을 개발할 때까지 회원 가입 테스트를 대기한다.
위의 경우 모두 대기 시간이 발생한다.
대역을 사용하면 실제 구현이 없어도 다양한 상황에 대해 테스트할 수 있다.
또한 대역을 사용하면 실제 구현이 없어도 실행 결과를 확인할 수 있다.
대역은 의존하는 대상을 구현하지 않아도 테스트 대상을 완성할 수 있게 만들어주며 이는 대기 시간을 줄여주어 개발 속도를 올리는 데 도움이 된다.
모의 객체를 과하게 사용하지 않기
모의 객체는 스텁과 스파이를 지원하므로 대역으로 모의 객체를 많이 사용한다.
하지만 모의 객체를 과하게 사용하면 오히려 테스트 코드가 복잡해지는 경우도 발생한다.
모의 객체를 이용하면 대역 클래스를 만들지 않아도 되니까 편할 수는 있으나 결과 값을 확인하는 수단으로 모의 객체를 사용하기 시작한다면 결과 검증 코드가 길어지고 복잡해진다.
'책 > 테스트 주도 개발 시작하기' 카테고리의 다른 글
Chapter 8. 테스트 가능한 설계 (1) | 2025.06.26 |
---|---|
Chapter 06. 테스트 코드의 구성 (1) | 2025.05.26 |
Chapter 05. JUnit5 기초 (0) | 2025.04.30 |
Chapter 04. TDD 기능 명세 , 설계 (0) | 2025.04.13 |
Chapter 03. 테스트 코드 작성 순서 (2) | 2024.09.30 |