Martin Fowler : 테스트 대역은 테스트를 목적으로 프로덕션 객체를 대체하는 모든 경우를 포괄하는 일반적인 용어이다.
(Test Double is a generic term for any case where you replace a production object for testing purposes.)
Gerard Meszaros : 테스트를 작성할 때, DOC를 사용할 수 없거나 사용하지 않기로 선택한 경우, 이를 테스트 대역으로 대체할 수 있다. 테스트 대역은 실제 DOC와 동일하게 동작할 필요는 없으며, 단지 실제 DOC와 동일한 API를 제공하여 SUT가 이를 실제 DOC로 인식하게 만들면 된다.
(When we are writing a test in which we cannot (or chose not to) use a real depended-on component (DOC), we can replace it with a Test Double. The Test Double doesn’t have to behave exactly like the real DOC; it merely has to provide the same API as the real one so that the SUT thinks it is the real one!)SUT (System Under Test)
- 테스트 대상 시스템을 의미한다.
- 단위 테스트에서의 SUT는 테스트 대상이 되는 클래스, 객체, 메서드를 의미한다.
DOC (Depended-On Component)
- SUT가 의존하는 개별 클래스 또는 대규모 컴포넌트를 의미한다.
테스트 대역 종류
종류 | 설명 | 부가설명 | 관련검증 |
---|---|---|---|
스텁(Stub) | 테스트만을 위한 단순한 구현을 가지고 있는 객체 | 상태 검증(State Verification) | |
가짜(Fake) | 프로덕션에는 적합하지 않지만 실제 동작하는 구현을 가지고 있는 객체 | 상태 검증 | |
스파이(Spy) | 호출된 내역을 가지고 있는 객체, Stub이기도 하다. | 호출된 내역이란 테스트 중 Spy 객체의 메서드가 몇 번 호출되었는지, 어떤 인자로 호출되었는지에 대한 정보이다. | 상태 검증, 행위 검증 |
모의(Mock) | 실제 객체를 흉내 내는 가짜 객체, Stub과 Spy를 대신할 수 있다. | mock 객체의 주요 기능은 기대한 대로 상호작용하는지 확인(= 행위 검증)하는 것이다. | 행위 검증(Behavior Verification) |
테스트 대역 예시 - TDD (참고 : TDD 흐름과 예시)
- UserRegister(테스트 대상) : 회원 가입에 대한 핵심 로직을 수행
- WeakPasswordChecker : 암호가 약한지 검사
- UserRepository : 회원 정보 저장 및 조회 기능
- EmailNotifier : 이메일 발송 기능
첫 번째 테스트 : 암호가 약한 경우 회원 가입에 실패 - Stub 사용
- 테스트 코드 작성
public class UserRegisterTest { private UserRegister userRegister; private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker(); @BeforeEach void setUp() { userRegister = new UserRegister(stubPasswordChecker); } @DisplayName("약한 암호면 가입 실패") @Test void weakPassword() { // given stubPasswordChecker.setWeak(true); // when & then assertThatThrownBy(() -> userRegister.register("id", "pw", "email")) .isInstanceOf(WeakPasswordException.class); } }
- 구현
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(); } } }
public interface WeakPasswordChecker { boolean checkPasswordWeak(String pw); }
public class StubWeakPasswordChecker implements WeakPasswordChecker { private boolean weak; public void setWeak(boolean weak) { this.weak = weak; } @Override public boolean checkPasswordWeak(String pw) { return weak; } }
public class WeakPasswordException extends RuntimeException { }
두 번째 테스트 : 동일 ID를 가진 회원이 존재할 경우 회원 가입에 실패 - Fake 사용
- 테스트 코드 작성
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() { // given fakeRepository.save(new User("id", "pw1", "email1")); // when & then assertThatThrownBy(() -> userRegister.register("id", "pw2", "email2")) .isInstanceOf(DubIdException.class); } }
- 구현
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(); } userRepository.save(new User(id, pw, email)); } }
public interface UserRepository { void save(User user); User findById(String id); }
public class MemoryUserRepository implements UserRepository { private Map<String, User> users = new HashMap<>(); @Override public void save(User user) { users.put(user.getId(), user); } @Override public User findById(String id) { return users.get(id); } }
public class DubIdException extends RuntimeException { }
세 번째 테스트 : 회원 가입 성공 시 메일 발송 - Spy 사용
- 이메일 발송 여부를 어떻게 확인할 수 있을까?
- 이를 확인할 수 있는 방법 중 하나는, UserRegister가 EmailNotifier의 메일 발송 기능을 실행할 때 이메일 주소로 “email@naver.com”을 사용했는지 확인하는 것이다. 이런 용도로 사용할 수 있는 것이 스파이(Spy) 대역이다.
- 테스트 코드 작성
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() { // when userRegister.register("id", "pw", "email@naver.com"); // then // sendRegisterEmail가 호출되었는지 확인 assertThat(spyEmailNotifier.isCalled()).isTrue(); // sendRegisterEmail가 호출될 때, 인자로 "email@naver.com"가 사용되었는지 확인 assertThat(spyEmailNotifier.getEmail()).isEqualTo("email@naver.com"); } }
- 구현
public class UserRegister { private WeakPasswordChecker passwordChecker; private UserRepository userRepository; private EmailNotifier emailNotifier; // 추가 public UserRegister(WeakPasswordChecker passwordChecker, UserRepository userRepository, EmailNotifier emailNotifier) { this.passwordChecker = passwordChecker; this.userRepository = userRepository; this.emailNotifier = emailNotifier; // 추가 } 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(); } userRepository.save(new User(id, pw, email)); emailNotifier.sendRegisterEmail(email); // 추가 } }
public interface EmailNotifier { void sendRegisterEmail(String email); }
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; } }
Stub과 Spy 대체 - Mock 사용
public class UserRegisterTest {
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); // 수정
}
// Mock 객체를 사용하여 Stub을 대신함
@DisplayName("약한 암호면 가입 실패")
@Test
void weakPassword() {
// given
BDDMockito.given(mockPasswordChecker.checkPasswordWeak("pw"))
.willReturn(true);
// when & then
assertThatThrownBy(() -> userRegister.register("id", "pw", "email"))
.isInstanceOf(WeakPasswordException.class);
}
// Mock 객체를 사용하여 Spy를 대신함
@DisplayName("가입하면 메일을 전송함")
@Test
void whenRegisterThenSendMail() {
// when
userRegister.register("id", "pw", "email@naver.com");
// then
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
// sendRegisterEmail가 호출되었는지 확인 + 인자를 captor에 저장
BDDMockito.then(mockEmailNotifier)
.should().sendRegisterEmail(captor.capture());
String realEmail = captor.getValue();
// sendRegisterEmail가 호출될 때, 인자로 "email@naver.com"가 사용되었는지 확인
assertThat(realEmail).isEqualTo("email@naver.com");
}
}
Behavior Verification - Mock 사용
@DisplayName("회원 가입시 암호 검사 수행함")
@Test
void checkPassword() {
// when
userRegister.register("id", "pw", "email");
// then
BDDMockito.then(mockPasswordChecker) // 해당 Mock 객체의
.should() // 특정 메서드가 호출됐는지 검증하는데
.checkPasswordWeak(BDDMockito.anyString()); // 임의의 String 타입 인자를 이용해서 checkPasswordWeak 메서드 호출 여부 확인
}
대역 사용의 이점과 필요성
- 대기 시간을 줄여준다.
- ex) 다른 개발자가 A 기능을 구현하고 있을 때, 구현이 완료될 때까지 기다리지 않고 A 기능을 테스트할 수 있다.
- ex) DB 서버가 구축되기 전에도 데이터가 올바르게 저장되는지 확인할 수 있다.
- ex) 회원 가입 성공 시 메일 발송 여부를 확인할 때, 메일함에 메일이 도착할 때까지 기다릴 필요가 없다.
- 다양한 상황에 대해 테스트할 수 있다.
- ex) 외부 API를 사용할 때, 해당 API 서버의 응답을 5초 이내에 받지 못하는 상황을 테스트해볼 수 있다.
- 테스트 수행이 외부에 영향을 주면 안되는 경우, 대역을 통해 해결할 수 있다.
- ex) 은행 계좌에서 돈을 인출하는 기능을 테스트할 때, 실제 인출 요청이 API 서버에 전달되어서는 안된다.
- 테스트 내에서 같은 요청을 보냈을 때 외부의 응답이 항상 동일할 것이라고 신뢰할 수 없는 경우, 대역을 통해 해결할 수 있다.
- ex) 발급받은 오픈 API의 Key가 만료되면 응답이 달라진다.
- ex) 외부 API 서버에 장애가 발생하면 응답이 달라진다.
- 참고 자료
테스트 주도 개발 시작하기(저자: 최범균)
https://martinfowler.com/bliki/TestDouble.html
http://xunitpatterns.com/Test%20Double.html
http://xunitpatterns.com/SUT.html
http://xunitpatterns.com/DOC.html
https://gyuwon.github.io/blog/2020/05/10/do-you-really-need-test-doubles.html
https://beomseok95.tistory.com/295
https://jonghoonpark.com/2023/12/05/test-double-for-well-grounded-java-developer
https://greeng00se.github.io/test-double
https://umbum.dev/1233/
https://f-lab.kr/insight/test-code-and-mock-object