테스트 대역(Test Double)
테스트하려는 객체와 연관된 객체를 사용하기가 어렵고 모호할 때 대신해 줄 수 있는 객체를 말한다.
참고로 영화 대역 배우에서 아이디어를 얻어서 만든 용어이며 xUnit Test Patterns의 저자인 제라드 메스자로스(Gerard Meszaros)가 만들었다.
테스트 대역의 필요성
테스트 대상에서 의존하는 요인 때문에 테스트가 어려워질 때 필요한 것이다.
테스트를 작성하다 보면 외부 요인이 필요한 시점이 있는데, 다음은 외부 요인이 테스트에 관여하는 주요 예이다.
- 테스트 대상에서 파일 시스템 사용
- 테스트 대상에서 DB로부터 데이터를 조회하거나 데이터를 추가
- 테스트 대상에서 외부의 HTTP 서버와 통신
이러면 테스트가 어려워진다. 예를 들면 외부 API 서버가 일시적으로 장애가 나는 경우, 내부 DB라도 상황에 맞게 데이터를 구성하는게 안되는 경우가 있겠다.
외부 요인은 테스트 작성을 어렵게 만들 뿐만 아니라 테스트 결과도 예측할 수 없게 만든다. 예를 들어 카드 정보 검사 대행업체에서 테스트할 때 사용하라고 제공한 카드번호의 유효기간이 한 달 뒤일 수 있다. 한 달 뒤에 유효 기간 만료로 테스트가 실패하는 경우가 생긴다.
이렇게 테스트 대상에서 의존하는 요인 때문에 테스트가 어려울 때는 대역을 써서 테스트를 진행할 수 있다.
대역의 종류와 사용 예시
더미(Dummy)
가장 기본적인 테스트 대역이며, 인스턴스화된 객체는 필요하지만 기능은 필요없는 경우에 사용된다.
그래서 일반적으로 매개 변수 목록을 채우기 위해 사용된다.
@ExtendWith(MockitoExtension.class)
public class UserCreateTest { // 사용자 회원가입 테스트 클래스
// User 관련 SQL Mapper의 모의(Mock) 객체
// 모의(Mock) 객체는 아래 이어서 설명할테니 우선 넘어가기 바란다.
@Mock
private UserMapper userMapper;
// User 관련 서비스 구현체에 User 관련 SQL Mapper를 주입
@InjectMocks
private UserServiceImpl userServiceImpl;
// UserDTO 클래스의 모든 필드가 들어간 객체
private UserDTO UserDTOAllField;
// 매 테스트 메서드 실행 직전에 init() 메서드가 실행됨
@BeforeEach
void init() {
// 객체에 모든 필드를 추가하자
UserDTOAllField = UserDTO.builder().
loginId("loginid123").
name("황사이다").
birthDate(LocalDate.of(2000,11,11)).
sex(UserDTO.Sex.MALE).
password("비1밀2번3호").
nickname("닉네임123이다").
phoneNumber("01012345678").
build();
}
@Test
@DisplayName("모든 UserDTO 데이터가 입력된 경우 유저 등록 성공")
void SuccessUserCreateIfAllFieldInserted() {
// UserDTOAllField Dummy 객체가 addUser 메서드 파라미터에 사용됨
assertDoesNotThrow(() -> userServiceImpl.addUser(UserDTOAllField));
}
}
위 코드는 회원가입 테스트를 위해 회원가입에 필요한 필드 데이터만을 추가한 Dummy 객체를 init() 메서드에서 생성하고 있다.
💡 평소 개발중인 토이프로젝트의 예문이니 다른 테스트코드에도 관심이 있으신 분들은 참고 바란다.
public interface PringWarning {
void print();
}
public class PrintWarningDummy implements PrintWarning {
@Override
public void print() {
// 아무런 동작을 하지 않는다.
}
}
위 코드처럼 실제 객체는 PrintWarning 인터페이스의 구현체를 필요로하지만, 특정 테스트에서는 해당 구현체의 동작이 필요 없을 수 있다. 실제 객체가 로그용 경고만 출력한다면 테스트 환경에서는 필요 없기 때문이다.
이런 경우는 print() 메서드가 아무 동작을 안해도 테스트에는 영향이 없다.
가짜(Fake)
동작은 하지만 실제 사용되는 객체처럼 정교하게 동작하지는 않는 객체를 말한다.
그래서 일반적으로 프로덕션에는 적합하지 않다.
public interface UserRepository {
void save(UserDTO user);
User findById(long id);
}
public class FakeUserRepository implements UserRepository {
// 메모리를 데이터베이스 역할로 활용한다.
private ArrayList<UserDTO> users = new ArrayList<>();
@Override
public void save(UserDTO user) {
if (findById(user.getId()) == null) {
user.add(user);
}
}
@Override
public User findById(long id) {
for (UserDTO user : users) {
if (user.getId() == id) {
return user;
}
}
return null;
}
}
위 코드처럼 인 메모리 테스트(In-memory Test) DB 가 좋은 예이다.
💡 인 메모리 데이터베이스(In-memory DataBase)
메인 메모리에 설치되어 운영되는 방식의 데이터베이스 관리 시스템이다.
테스트해야 하는 객체가 데이터베이스와 연관되어 있다고 가정한다면 실제 데이터베이스를 연결해서 테스트해야 하지만, 그 대신 Fake 데이터베이스 역할을 하는 FakeUserRepository를 만들어 테스트 객체에 주입하는 방법이 있다. 이러면 테스트 객체는 실제 데이터베이스에 의존하지 않으면서 동일하게 동작하는 Fake 데이터베이스를 가지게 된다.
테스트 스텁(Stub)
Dummy 객체가 실제로 동작하는 것 처럼 보이게 만들어 놓은 객체이며 테스트 중에 호출되면 미리 의도한 결과를 반환하는 것이다.
의도한 결과가 대개 한정적이므로 인터페이스 또는 기본 클래스가 최소한으로 구현된 것이 특징이다.
public class StubUserRepository implements UserRepository {
// Fake 에서 설명한 UserRepository 인터페이스의 findById 메서드이다.
@Override
public User findById(long id) {
return new UserDTO(id, "beststar");
}
}
위 코드처럼 StubUserRepository는 findById() 메서드를 사용하면 언제나 동일한 id값에 beststar 라는 이름을 가진 UserDTO 인스턴스를 반환받는다.
테스트 환경에서 UserDTO 인스턴스의 name을 beststar만 받기를 원하는 경우 이처럼 동작하는 객체(UserRepository의 구현체)를 만들어 사용할 수 있다.
물론 이 방식의 단점은 findById() 메서드의 반환값이 변경될 경우 Stub 객체도 함께 수정해야 하는 것이다.
테스트 스파이(Spy)
Stub의 역할을 가지며 테스트 대역으로 구현된 객체에 자기 자신이 호출 되었을 때 방법/과정 등 확인이 필요한 부분을 기록하도록 구현하는 것이다.
실제 객체처럼 동작시킬 수도 있고 필요한 부분에 대해서는 Stub로 만들어서 동작을 지정할 수도 있다.
public class MailingService {
private int sendMailCount = 0;
private Collection<Mail> mails = new ArrayList<>();
public void sendMail(Mail mail) {
sendMailCount++;
mails.add(mail);
}
public long getSendMailCount() {
return sendMailCount;
}
}
위 코드처럼 보낸 메일의 수를 기록하는 이메일 서비스를 예로 들 수 있다.
MailingService는 sendMail() 메서드를 호출할 때마다 보낸 메일을 저장하고 몇 번 보냈는지를 체크한다.
그리고 getSendMailCount() 메서드로 메일을 보낸 횟수를 물어볼 때 sendMailCount 변수에 저장된 값을 반환한다.
모의(Mock)
호출했을 때 사전에 정의된 명세대로의 결과를 돌려주도록 미리 프로그램 돼있는 것이다.
예상치 못한 호출이 있을 경우 예외를 던질 수 있으며 모든 호출이 예상된 것이었는지 확인할 수 있다.
Mock 객체는 정의된 명세대로의 결과를 돌려준다는 점에서 Stub이 될수도 있고
객체 정의 목적을 호출 되었을 때 방법/과정 등 확인이 필요한 부분을 기록하느냐에 따라서 Spy도 될수도 있다.
@ExtendWith(MockitoExtension.class)
public class UserCreateTest {
@Mock
private UserMapper userMapper;
@InjectMocks
private UserServiceImpl userServiceImpl;
private UserDTO UserDTOAllField;
@BeforeEach
void init() {
UserDTOAllField = UserDTO.builder().
loginId("loginid123").
name("황사이다").
birthDate(LocalDate.of(2000,11,11)).
sex(UserDTO.Sex.MALE).
password("비1밀2번3호").
nickname("닉네임123이다").
phoneNumber("01012345678").
build();
}
@Test
@DisplayName("아이디 중복으로 인한 유저 등록 실패")
public void FailToUserCreateIfDuplicateLoginId() throws Exception {
// findUserByLoginId() 메서드가 UserDTOAllField를 반환할지 결정하는 코드이다.
given(userServiceImpl.findUserByLoginId("loginid123")).willReturn(UserDTOAllField);
assertThrows(DuplicateKeyException.class,
() -> {
userServiceImpl.addUser(UserDTOAllField);
}
);
}
}
위 코드처럼 UserService 인터페이스의 구현체인 UserServiceImpl이 findUserByLoginId() 메서드를 동작했을 때 어떤 결과를 반환할지를 결정할 수 있다.
💡 평소 개발중인 토이프로젝트의 예문이니 다른 테스트코드에도 관심이 있으신 분들은 참고 바란다.
테스트 대역의 장점 - 개발 속도 향상
테스트 코드 작성시 테스트 대역을 사용하지 않고 코드를 구현한다면 다음과 같은 일이 벌어지게 된다.
- 카드 정보 제공 API가 비정상 응답을 주는 상황을 테스트하기 위해서 업체가 비정상 응답을 줄 수 있게 대응하는 작업을 기다린다.
- 회원 가입 테스트를 한 뒤에 인증 메일이 발송되므로 메일함을 직접 확인한다.
- 가위바위보 게임 개발을 두명이서 역할분담하여 한사람은 가위바위보를 내는 기능, 다른 사람은 가위바위보 결과를 판정하는 기능을 개발한다고 가정한다. 그러면 가위바위보를 내는 기능이 개발될 때까지 가위바위보 결과 테스트를 대기할 수 밖에 없다.
위 경우 모두 대기 시간이 발생한다. 즉, 대역은 상황에 맞는 의존 대상을 구현하지 않아도 테스트 대상을 완성할 수 있게 만들어주며, 이는 대기 시간을 줄여주어 개발 속도를 올리는 데 도움이 된다.
출처
테스트 대역(Test Double)
en.wikipedia.org/wiki/Test_double
www.martinfowler.com/bliki/TestDouble.html
woowacourse.github.io/javable/post/2020-09-19-what-is-test-double/
martinfowler.com/articles/mocksArentStubs.html
테스트 대역 종류별 예문
woowacourse.github.io/javable/post/2020-09-19-what-is-test-double/
책 '테스트 주도 개발 시작하기'