테스트 대역이란 무엇일까요?
네트워크에서 사용하는 대역(bandwith)과 비슷합니다만. 전혀 관련이 없답니다!
테스트에서의 대역은 오롯이 테스트를 위해 만들어진 가짜 객체 또는 컴포넌트를 의미합니다.
아래의 상황에서 테스트코드를 짜본 적이 없는 개발자라면 공감하실 수도 있겠습니다.
회원가입을 완료하면 회원 테이블에 저장하고 환영 이메일을 보냅니다.
이런 상황에서 테스트코드를 짜야한다면 머리가 지끈지끈하지 않으신가요? 그리고 이런 고민도 할 수 있겠습니다.
"테스트를 할때마다 디비에 계속 저장되겠는데..?" , "더미 이메일을 계속 보내야 하나..?"
이 고민을 해봤다면 계속 글을 읽어도 좋을 거 같습니다. 저와 함께 가보시죠.
아까의 상황을 다시 가져와 보겠습니다.
회원가입을 완료하면 회원 테이블에 저장하고 환영 이메일을 보냅니다.
이 상황에서 테스트를 할 때마다 환영 이메일이 가게 된다면 이는 저희가 원하는 방향이 절대 아닐 것입니다.
글만 보다 보니 재미가 없네요. 개발자들 답게 코드를 봐봅시다.
public class DummyEmailSender implements EmailSender {
@Override
public void send(User user) {
// 아무것도 안합니다.
}
}
짤막하게 설명을 해보자면 EmailSender는 interface이며, 구현체에 따라 실제 이메일을 보내기도 합니다.
위의 코드인 DummyEmailSender는 EmailSender의 구현체이지만 send() 메서드에서 아무것도 안 하고 있는 것을 볼 수 있습니다.
이를 테스트에서 쓰게 된다면 어떻게 될까요?
public class UserServiceTest {
@Test
public void 회원가입을_하면_이메일_상태가_변한다() {
//given
UserDto userDto = UserDto.builder()
.email("choon@naver.com")
.nickname("choon")
.build();
//when
UserService userService = UserService.builder()
.EmailSender(new DummyEmailSender())
.userRepository(userRepository)
.build();
User user = userRepository.register(userDto);
//then
assertThat(user.isEmailSending()).isTrue();
}
EmailSender의 구현체로 DummyEmailSender를 사용하기 때문에 실제로 이메일이 생성되지 않습니다. 이는 가짜 상황을 만들어 내는 것이며 즉, EmailSender의 대역을 사용한 것입니다.
하지만 이런 생각도 할 수 있을 것입니다.
"이런 테스트가 실효성이 있나?" 당연히 이렇게 생각할 수 있습니다.
하지만 테스트 메서드명에서도 볼 수 있듯 저희가 이 테스트를 실행할 때 검증하고자 한 것과 일치합니다.
public class DummyEmailSenderTimeout implements EmailSender {
@Override
public void send(User user) {
throw new ConnectTimeoutException();
}
}
위와 같은 구현체도 만들 수 있습니다. 이 구현체를 테스트에서 사용하게 된다면 Timeout인 상황을 가상으로 만들어낼 수 있겠죠?
이렇게 테스트 대역을 이용하여 개발자가 원하는 환경을 만들 수 있으며 이를 고정시킬 수 있다는 강력한 장점이 있습니다.
테스트 대역의 종류로는 대표적으로 5가지가 존재합니다.
유형 | 설명 |
Dummy | 아무런 동작을 하지 않습니다. |
Stub | 지정한 값만 반환합니다. |
Fake | 자체적인 로직이 있습니다. |
Mock | 아무런 동작을 하지 않습니다. 대신 어떤 행동이 호출됐는지를 기록합니다. |
Spy | 실제 객체와 똑같이 행동합니다. 그리고 모든 행동 호출을 기록합니다. |
각각 알아보도록 하죠!!
Dummy
Dummy(더미)는 테스트 대역 중에서도 가장 간단하고 뚜렷한 목적을 지닌 대역입니다.
Dummy의 역할은 아무런 동작도 하지 않는 것입니다.
위의 예시 코드에서 EmailSender의 테스트전용 구현체로 DummyEmailSender를 만들었던 것을 기억할 것입니다.
이 구현체의 sender() 메서드에서 아무것도 안 하던 것을 볼 수 있었는데요.
이게 바로 지금 설명하고자 하는 Dummy 그 자체입니다. Dummy의 설명은 이게 다입니다.
다음 대역으로 넘어가기 전 오해하지 않게 몇 마디만 더 하겠습니다.
예시에서는 매개변수에 주입되는 상황을 봤는데요. 꼭 이런 상황만 Dummy를 사용할 수 있는 게 아닙니다.
doFilter(
servletRequest,
servletResponse,
new FilterChain() {
@Override
public void doFilter(ServletRequest req, ServletResponse resp) {
// do nothing
}
}
)
위의 코드는 필터단에서 사용하는 doFilter() 메서드를 잠시 가져와보았습니다.
doFilter의 세 번째 매개변수로 filterChain을 넘겨줘야 하는데요. 위와 같이 익명클래스를 통해 Dummy를 만드는 방식도 가능합니다!
Dummy는 주로 실제 코드의 일부분을 건너뛰기 위한 목적으로 자주 사용됩니다.
Stub
Stub은 미리 준비된 값을 반환하는 대역 객체를 가리킵니다.
Stub은 Dummy처럼 실제 구현체의 코드를 실행하지 않는다는 점에서 유사하지만 Dummy 보다는 조금 더 발전된 형태입니다.
Dummy는 정말 아무 동작도 하지 않지만 Stub은 개발자가 의도한 미리 준비된 값을 반환합니다. 이를 이용하여 테스트가 원하는 방향으로 동작할 수 있게 합니다.
이런 Stub을 테스트에서 어떻게 활용할 수 있을까요?
하나의 상황을 또 가정해 보죠.
PING을 보내면 PONG이 와야 한다.
굉장히 간단합니다. 하지만 이를 테스트하기 위해선 API 요청이 꼭 필요합니다.
API 요청은 네트워크 자원을 사용하는 고연산 작업입니다. 테스트할 때 꼭 필요할까요? 이런 고연산 작업이?
이럴 때 Stub이 빛을 발합니다.
테스트할 때 PING을 받으면 PONG이 반환되도록 Stub을 만들어 놓으면 API 요청 없이 아주 간단하게 테스트할 수 있습니다.
또 하나의 예시를 들어볼까요? 스프링을, 특히 JPA를 해보셨다면 findByEmail 이런 메서드 네이밍이 굉장히 익숙하실 것입니다.
이를 이용하여 Stub을 직접 코드로 이해해 봅시다.
회원가입 시 이메일이 중복되면 에러가 발생한다.
위와 같은 상황이라고 할 때, Stub 클래스를 하나 만들어봤습니다.
class StubExistRepository implements UserRepository {
public Optional<User> findByEmail(String email) {
return Optional.of(User.builder()
.id(1L)
.email(email)
.build());
}
}
이때, 이메일 중복이 됐는지 안 됐는지는 findByEmail 메서드의 리턴값이 빈(empty) Optional이 아니라면 존재하는 이메일이기 때문에 예외가 발생해야 합니다.
@Test
public void 중복된_이메일_회원가입_요청이_오면_에러가_발생한다.() {
// given
UserDto userDto = userDto.builder()
.email("choon@naver.com")
.nickname("choon")
.build();
// then
assertThrows(DuplicatedEmailException.class, () -> {
// when
UserService userService = UserService.builder()
.emailSender(new DummyEmailSender())
.userRepository(new StubExistUserRepository())
.build();
User user = userRepository.register(userDto)
});
}
위의 테스트코드를 보면 StubExistUserRepository를 사용하는 것을 볼 수 있고
이를 통해 항상 텅 비지 않은 Optional이 반환되기 때문에 항상 테스트 목적에 맞게 테스트가 실행됨을 알 수 있습니다.
이처럼 Stub은 아무런 동작도 하지 않았던 Dummy와는 다르게 실제 구현체의 응답을 흉내 냅니다. 이를 활용하여 테스트 환경을 마음대로 조작합니다.
위와 같은 특징으로 Stub은 외부 연동을 하는 컴포넌트나 클라이언트를 대체하는 데 자주 사용됩니다.
만약 외부 API에 의존적인 서비스를 개발 중에 있다면 Stub은 아주 큰 도움이 될 것입니다.
Fake
Fake는 Dummy와 Stub과는 달리 테스트를 위한 자체적인 논리를 갖고 있습니다.
Stub을 살펴볼 때 UserRepository 인터페이스를 대상으로 Stub을 생성했습니다.
그런데 테스트를 작성하면서 이처럼 모든 테스트에 Stub을 사용하는 코드를 넣기란 너무 힘든 일이며 바람직하지도 않습니다.
왜냐하면 Stub으로 만들어진 코드가 테스트의 대부분을 차지해서 테스트의 중요한 부분을 가리기 때문입니다.
테스트는 최대한 간결하고 보자마자 이해가 가능한 형태로 작성해야 합니다. 그러니 테스트를 위해 매번 Stub을 달리 해줘야 해서 테스트의 중요한 부분을 가리고, 그로 인해 테스트의 목적이 무엇인지 한눈에 파악할 수 없는 상황은 그다지 좋은 현상이 아닙니다.
이때 Fake가 위의 문제점을 보완해 줍니다. 어떻게 보완하는지 봐볼까요?
위의 Stub예제에서는 StubRepository를 만들어서 테스트를 진행했다면 이제는 테스트를 위한 Repository를 만들어보는 것입니다.
public class FakeUserRepository implements UserRepository {
private final long autoGeneratedId = 0;
private final List<User> data = new ArrayList<>();
@Override
public Optional<User> findById(String id) {
return data.stream().filter(item -> item.getId().equals(id).findAny());
}
@Override
public User save(User user) {
if(user.getId() == null || user.getId() == 0) {
// create 동작
User newUser = User.builder()
.id(++autoGeneratedId)
.email(user.getEmail())
.nickname(user.getNickname())
.build();
data.add(newUser);
return newUser;
} else {
// update 동작
data.removeIf(item -> item.getId() == user.getId());
data.add(user);
return user;
}
}
}
위의 FakeUserRepository는 JPA의 동작을 흉내 내는 Fake 객체입니다.
이를 이용하여 테스트코드를 다시 작성해 볼까요?
@Test
public void 중복된_이메일_회원가입_요청이_오면_에러가_발생한다.() {
// given
UserDto userDto = userDto.builder()
.email("choon@naver.com")
.nickname("choon")
.build();
UserRepository userRepository = new FakeUserRepository();
userRepository.save(User.builder()
.id(1L)
.email("choon@naver.com")
.nickname("choon")
.build());
// then
assertThrows(DuplicatedEmailException.class, () -> {
// when
UserService userService = UserService.builder()
.emailSender(new DummyEmailSender())
.userRepository(userRepository)
.build();
User user = userRepository.register(userDto)
});
}
given에서 미리 FakeUserRepository에 데이터를 담아놔서 테스트가 원하는 방향으로 흘러가게 만들었습니다.
이는 Stub을 이용하여 같은 테스트를 진행했을 때 보다 테스트의 가독성이 높으며 여러 테스트에서 재활용하기 좋습니다.
Mock
Mock을 간단히 설명해 보겠습니다.
- Mock은 메서드 호출 및 상호 작용을 기록한다.
- Mock은 어떤 객체와 상호 작용이 일어났는지 기록한다.
- Mock은 어떻게 상호 작용이 일어났는지 기록한다.
상호 작용이라는 단어가 Mock을 설명하는 모든 문장에서 사용된 것을 볼 수 있습니다.
그전에 테스트에서 코드를 검증하는 데 사용할 수 있는 두 가지 테스트 접근 방식에 대해서 먼저 알아봅시다.
상태 기반 검증
상태 기반 검증이란
- 테스트를 실행한 후 테스트 대상의 상태가 어떻게 변화됐는지를 보고 테스트 실행 결과를 판단한다.
코드를 통해 한번 살펴보겠습니다.
@Test
void 유저는_북마크를_toggle_해서_제거할_수_있다() {
//given
User user = User.builder()
.bookmark(new ArrayList<>())
.build();
user.appendBookmark("foobar");
//when
user.toggleBookmark("foobar");
//then
// user는 foobar를 북마크로 갖고 있어서는 안됩니다.
assertThat(user.hasBookmark("foobar")).isFalse();
}
이 테스트는 마지막에 user 객체의 북마크가 어떤 식으로 변화했는지를 확인합니다.
이러한 테스트코드가 상태 기반 검증에 속합니다.
이를 그림으로 도식화를 해보면 아래와 같습니다.
행위 기반 검증
행위 기반 검증이란
- 테스트 대상이나 협력 객체, 협력 시스템의 메서드 호출 여부를 봅니다.
이 또한 코드를 통해 살펴보죠
@Test
void 유저는_북마크를_toggle_해서_제거할_수_있다() {
//given
User user = User.builder()
.bookmark(new ArrayList<>())
.build();
user.appendBookmark("foobar");
//when
user.toggleBookmark("foobar");
//then
// user.removeBookmark("foobar")이 호출되었는지 확인합니다.
verify(user).removeBookmark("foobar");
}
verify 메서드를 이용하여 북마크를 제거하는 메서드가 호출됐는지를 확인하고 있습니다.
이러한 테스트 유형이 행위 기반 검증입니다.
그림으로 도식화해보죠!
이렇게 상태 기반 검증과 행위 기반 검증에 대해서 간략하게 알아보았습니다.
팁을 하나 드리자면 테스트는 상태 기반 검증으로 작성하는 편이 좋습니다.
상태 기반 검증으로 작성해야 테스트를 책임 단위로 바라볼 수 있기 때문입니다.
행위 기반 검증으로 작성할 경우에는 "어떻게 목표에 달성해 왔는가? 에 집중하기 때문에 객체지향적인 코드와 멀어지게 됩니다.
Spy
Spy는 어휘 그대로 마치 영화 속 스파이와 같습니다. 실제 객체 대신 사용돼서 만약 실제 객체였다면 어떤 메서드가 호출되고 이벤트가 발생했는지 등을 기록하고 감시합니다. 더불어 메서드가 몇 번 호출됐는지, 메서드는 어떤 매개변수로 호출됐는지, 메서드 호출 순서는 어떤지 등 모든 것을 기록합니다.
이를 보면 Mock과 비슷하다고 느끼겠지만 결정적인 차이가 한 가지 있습니다. 바로 '내부구현이 진짜 구현체인가, 가짜 구현체인가?"의 차이입니다.
Mock으로 만들어진 객체는 기본적으로 모든 메서드 호출이 Dummy 또는 Stub처럼 동작합니다. 반면, Spy로 만들어진 객체는 기본적인 동작이 실제 객체의 코드와 같습니다.
즉, Spy는 실제 객체와 구분할 수 없습니다. 그러니 Spy는 실제 객체의 메서드 구현에 메서드 호출을 기록하는 부수적인 기능들이 추가되는 것이라 생각하면 좋습니다.
코드로 한번 살펴보죠!
public class SpyUserRepository extends UserRepositoryImpl {
public int findByEmailCallCount = 0;
public int saveCallCount = 0;
@Override
public Optional<User> findByEmail(String email) {
this.findByEmailCount++;
return super.findByEmail(email);
}
@Override
public User save(User user) {
this.saveCallCount++;
return super.save(user);
}
}
이 코드에는 두 가지 특징이 있습니다.
- Mock처럼 메서드가 호출됐는지를 기록하기 위한 멤버변수를 뒀습니다.
- 실제 구현체인 userRepository의 메서드들을 호출하였습니다.
위에서 살펴보았던 Mock과의 유사점과 차이점을 모두 가지고 있습니다.
이렇게 테스트 대역 5가지에 대해서 알아보았습니다.
하지만 이 내용은 시작에 불과합니다. 더 깊이 그리고 더 다양한 예제를 통해 학습을 진행해 보는 건 어떨까요?
'독후감' 카테고리의 다른 글
[스프링] 테스트를 어렵게 하는 요소랄까 (1) | 2024.07.24 |
---|---|
[자바/스프링 개발자를 위한 실용주의 프로그래밍] 책 리뷰 (0) | 2024.07.01 |
[독후감] 도메인 주도 개발 시작하기 챕터 03 (0) | 2023.09.24 |
[독후감] 오브젝트 - 챕터02 (0) | 2023.09.23 |
[독후감] 도메인 주도 개발 시작하기 [챕터 01, 02] (2) (2) | 2023.09.22 |