일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 |
- UXUIPrimary
- Java
- 부트캠프
- 오픈챌린지
- 국비지원취업
- 디자인챌린지
- Spring
- UXUI기초정복
- 환급챌린지
- 티스토리챌린지
- mysql
- 백엔드
- 백엔드개발자
- 백준
- 디자인강의
- 백엔드 부트캠프
- 오블완
- Be
- KDT
- baekjoon
- 국비지원교육
- 디자인교육
- 객체지향
- UXUI챌린지
- 오픈패스
- 국비지원
- OPENPATH
- 패스트캠퍼스
- 내일배움캠프
- 내일배움카드
- Today
- Total
군만두의 IT 공부 일지
[스터디2] 09. 테스트 대역 본문
목차
14 . 테스트 대역
- 테스트 대역(test double): 실제 객체를 대신해서 행동하고 실제 객체가 하지 못하는 일을 대신함.
- 예) 어떤 코드는 테스트 단계에서 실제로 실행하기 부담스러움. 또는 테스트하는 데 굳이 실제 객체를 사용해야 하는지 모르겠음.
- 테스트 대역을 이용하면 개발자가 테스트를 위한 격리되고 고정된 환경(정상적인 상황, 장애 상황, 타임아웃 상황 등)을 만들 수도 있음.
서비스에 가입한 회원에게 이메일 인증을 위해 이메일을 발송하고 대기 상태로 데이터베이스에 저장하는 UserService 코드가 있다고 가정한다.
// 사용자가 시스템에 가입하는 상황을 표현하는 UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final VerificationEmailSender verificationEmailSender;
@Transactional
public User register(UserCreateDto userCreateDto) {
User user = User.builder()
.email(userCreateDto.getEmail())
.nickname(userCreateDto.getNickname())
.status(UserStatus.PENDING)
.verificationCode(UUID.randomUUID().toString())
.build();
user = userRepository.save(user);
verificationEmailSender.send(user);
return user;
}
}
데이터베이스에 저장할 사용자 정보를 만들고, 데이터베이스에 저장할 사용자 상태는 '가입 보류(UserStatus.PENDING)'로 지정한다. UserService 컴포넌트는 이 데이터를 저장하고 가입 인증 메일을 보낸다.
UserService.register 메서드에 대한 테스트를 잘못 구성하면 테스트를 실행할 때마다 실제 메일이 발송되는 문제가 생긴다. 이메일 발송은 VerificationEmailSender.send 메서드의 테스트로 확인하고 있다.
⇒ UserService.register 메서드를 실행한 결과, 사용자의 상태가 UserStatus.PENDING인 상태로 저장소에 저장되었는지만 확인하면 된다.
// VerificationEmailSender 인터페이스를 상속해 상위 인터페이스의 동작을 무시하는 메서드를 구현한 테스트 대역 클래스
public class DummyVerificationEmailSender implements VerificationEmailSender {
@Override
public void send(User user) {
// Do nothing...
}
}
// 서비스가 테스트 대역을 사용하는 테스트
public class UserServiceTest {
@Test
public void 회원_가입을_완료하고_상태를_확인한다() {
UserCreateDto userCreateDto = UserCreateDto.builder()
.email("foobar@localhost.com")
.nickname("foobar")
.build();
UserService userService = UserService.builder()
.verificationEmailSender(new DummyVerificationEmailSender())
.userRepository(userRepository)
.build();
User user = userRepository.register(userCreateDto);
assertThat(user.isPending()).isTrue();
}
}
DummyVerificationEmailSender는 UserServiceTest에서 사용할 수 있다. UserService를 생성할 때 메일을 보내는 컴포넌트 객체인 verificationEmailSender 자리에 DummyVerificationEmailSender 클래스의 객체를 사용하도록 주입한다.
⇒ UserServiceTest에서 UserService.register 메서드를 실행해도 메일을 보내지 않는다.
유형 | 설명 |
Dummy | 아무런 동작을 하지 않는다. |
Stub | 지정한 값만 반환한다. |
Fake | 자체적인 로직이 있다. |
Mock | 아무런 동작을 하지 않는다. 대신 어떤 행동이 호출됐는지를 기록한다. |
Spy | 실제 객체와 똑같이 행동한다. 그리고 모든 행동 호출을 기록한다. |
▲ 테스트 대역의 5가지 유형
각각의 의미를 생각하면 Mock은 모조품이고, Fake는 가짜이며, Stub는 부본이고, Dummy는 가짜 인형이다.
14.1 Dummy
- 역할: 아무런 동작도 하지 않는 것
- Dummy 객체: 오롯이 코드가 정상적으로 돌아가게 하기 위한 역할을 하고, 특정 행동이 일어나지 않게 만드는 데 사용됨.
- Dummy(더미)는 멤버 변수 주입 외에도 매개변수가 포함된 메서드를 호출해야 하는 경우 등 도메인 객체를 사용하는 어디서든 사용할 수 있음.
- 예) DummyVerificationEmailSender 코드
14.2 Stub
- Stub: 원본을 따라한 부분과 마찬가지로 실제 객체의 응답을 최대한 비슷하게 따라하는 대역. 원본의 응답과 복제해 똑같은 응답으로 미리 준비하고 이를 반환함.
- Stub은 개발자가 의도한 미리 준비된 값을 반환해서 고연산 작업이 실제로 실행되지 않게 함. 그래서 Stub은 외부 시스템과 협업하는 상황에서 테스트 코드를 작성할 때 유용함. 외부 시스템과 통신하는 클라이언트가 반환할 응답을 바탕으로 Stub 객체를 만들면 되기 때문.
- Stub은 어떤 객체의 특정 메서드의 행동을 다르게 유도하고 싶을 때 사용되기 때문에 메서드 스텁(method stub)이라고도 불림.
ping 요청을 받으면 별 다른 동작 없이 'pong' 메시지와 200 OK 응답을 반환하는 API가 있다고 가정한다.
// 고연산 작업에 비해 그 결과를 쉽게 예상할 수 있는 경우
[요청]
GET https://another-server/ping
[응답]
status: 200
body: {
"content": "pong"
}
이 API 요청 결과는 pong 응답으로 성공하거나, 다른 이유로 실패하는 것이다. 그래서 테스트할 때마다 실제로 API 호출을 하는 것이 자원 낭비일 수 있다.
또는 위에서 다룬 UserService에서 JPA 같은 저장소에서 findById를 이용해 데이터를 불러오는 상황에는 디스크 I/O를 발생시키지 않고 처음부터 값을 제대로 불러오는 것을 가정한 채로 테스트를 작성하는 편이 나을 수 있다.
- findByEmail 메서드를 호출한 결과, 이메일이 일치하는 사용자를 찾을 수 있다면 UserService.register 메서드를 호출했을 때 DuplicatedEmailException이 발생함.
- findByEmail 메서드를 호출한 결과, 이메일이 일치하는 사용자를 찾을 수 없다면 UserService.register 메서드를 호출했을 때 사용자를 저장하고, 저장된 사용자 정보를 반환함.
// findByEmail 메서드를 호출한 결과, 데이터가 있는 케이스를 반환하는 Stub 클래스
// -> Mockito의 given 메서드를 이용하여 구현할 수도 있음.
class StubExistUserRepository implements UserRepository {
@Override
public Optional<User> findByEmail(String email) {
return Optional.of(User.builder()
.email(email)
.nickname("foobar")
.status(UserStatus.ACTIVE)
.verificationCode("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
.build());
}
public User save(User user) {
return User.builder()
.id(1L)
.email(user.getEmail())
.nickname(user.getNickname())
.status(user.getStatus())
.verificationCode(user.getVerificationCode())
.build();
}
}
// findByEmail 메서드를 호출한 결과, 빈 Optional 값을 반환하는 Stub 클래스
class StubEmptyUserRepository implements UserRepository {
public Optional<User> findByEmail(String email) {
return Optional.empty();
}
public User save(User user) {
return User.builder()
.id(1L)
.email(user.getEmail())
.nickname(user.getNickname())
.status(user.getStatus())
.verificationCode(user.getVerificationCode())
.build();
}
}
// Stub을 사용하는 테스트
@Test
public void 중복된_이메일_회원가입_요청이_오면_에러가_발생한다() {
// given
UserCreateDto userCreateDto = UserCreateDto.builder()
.email("foobar@localhost.com")
.nickname("foobar")
.build();
// then
assertThrows(DuplicatedEmailException.class, () -> {
// when
UserService userService = UserService.builder()
.verificationEmailSender(new DummyVerificationEmailSender())
.userRepository(new StubExistUserRepository())
.build();
User user = userRepository.register(userCreateDto);
});
}
@Test
public void 이메일_회원가입을_하면_가입_보류_상태가_된다() {
// given
UserCreateDto userCreateDto = UserCreateDto.builder()
.email("foobar@localhost.com")
.nickname("foobar")
.build();
// when
UserService userService = UserService.builder()
.verificationEmailSender(new DummyVerificationEmailSender())
.userRepository(new StubEmptyUserRepository())
.build();
User user = userRepository.register(userCreateDto);
// then
assertThat(user.isPending()).isTrue();
}
14.3 Fake
- Fake(페이크): 테스트를 위한 자체적인 논리를 갖고 있음.
- 잘 만들어진 Fake는 테스트의 가독성도 높고 여러 테스트에서 재활용할 수 있음.
- 로컬 환경에서 개발할 때 Fake 저장소를 사용하도록 만들어서, 로컬 환경에서 데이터베이스와 연동하지 않고 서버를 구동시킬 수 있음. 외부 API 서버와 연동하는 컴포넌트에서도 적용할 수 있음.
UserRepository 역할의 대역으로 사용될 객체에 데이터 저장을 위한 간단한 메모리 변수를 갖고 있게 한다고 가정한다. UserRepository 인터페이스에 읽기/쓰기 요청이 왔을 때 이 요청을 메모리 변수에 쓰고 불러오게 하면, 데이터베이스의 동작을 메모리 수준에서 흉내낼 수 있다.
// 리포지토리를 대체할 수 있는 Fake
public class FakeUserRepository implements UserRepository {
private final long autoGeneratedId = 0;
private final List<User> data = new ArrayList<>();
@Override
public Optional<User> findByEmail(long 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())
.address(user.getAddress())
.verificationCode(user.getVerificationCode())
.status(user.getStatus())
.lastLoginAt(user.getLastLoginAt())
.build();
data.add(newUser);
return newUser;
} else {
// Update 공간
data.removeIf(item -> item.getId() == user.getId());
data.add(user);
return user;
}
}
}
// Fake를 사용하는 테스트
@Test
public void 중복된_이메일_회원가입_요청이_오면_예외가_발생한다() {
// given
UserCreateDto userCreateDto = UserCreateDto.builder()
.email("foobar@localhost.com")
.nickname("foobar")
.build();
UserRepository userRepository = new FakeUserRepository(); // 미리 준비된 데이터를 Fake 저장소에 넣음.
userRepository.save(User.builder()
.id(1L)
.email("foobar@localhost.com")
.nickname("foobar")
.status(UserStatus.ACTIVE)
.verificationCode("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
.build());
// then
assertThrows(DuplicatedEmailException.class, () -> {
// when
UserService userService = UserService.builder()
.verificationEmailSender(new DummyVerificationEmailSender())
.userRepository(userRepository)
.build();
userService.register(userCreateDto);
});
}
@Test
public void 이메일_회원가입을_하면_가입은_보류로_설정된다() {
// given
UserCreateDto userCreateDto = UserCreateDto.builder()
.email("foobar@localhost.com")
.nickname("foobar")
.build();
// when
UserService userService = UserService.builder()
.verificationEmailSender(new DummyVerificationEmailSender())
.userRepository(new FakeUserRepository())
.build();
User user = userService.register(userCreateDto);
// then
assertThat(user.isPending()).isTrue();
}
// Fake는 로컬 프로필에서 사용할 수도 있다.
@Repository
@Profile("!local")
public class UserRepositoryImpl implements UserRepository {
// ...
}
---
@Repository
@Profile("local")
public class FakeUserRepository implements UserRepository {
// ...
}
잊지 말아야 할 사실
1. 소형 테스트가 중요하다.
2. 소형 테스트를 위해서는 도메인이 비즈니스 로직을 처리하는 것이 좋다.
3. 테스트 대역을 이용하면 중형/대형 테스트도 소형 테스트로 만들 수 있다.
14.4 Mock
- Mock(목): 메서드 호출이 발생했는지 여부를 검증함.
- 최근에는 Mock이 테스트 대역과 거의 같은 의미로 사용됨. 개념적인 의미에서 Mock는 메서드 호출 및 상호 작용을 기록하고, 실제로 상호 작용이 일어났는지, 어떻게 상호 작용이 일어났는지를 확인하는 데 사용되는 객체에 해당함.
- Mock 객체는 기본적으로 모든 메서드 호출이 Dummy 또는 Sub처럼 동작함.
- Mock은 메서드 호출 및 상호 작용을 기록한다.
- Mock은 어떤 객체와 상호 작용이 일어났는지 기록한다.
- Mock은 어떻게 상호 작용이 일어났는지 기록한다.
14.4.1 상태 기반 검증
- 상태 기반 검증(state-based verification): 테스트의 검증 동작에 상태를 사용하는 것
- 상태 기반 검증으로 동작하는 테스트에서는 테스트를 실행한 후 테스트 대상의 상태가 어떻게 변화됐는지를 보고 테스트 실행 결과를 판단함.
// 상태 기반 검증을 사용하는 테스트
@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();
}
14.4.2 행위 기반 검증
- 행위 기반 검증(behaviour-based verification): 테스트의 검증 동작에 메서드 호출 여부를 보게 하는 것
- 행위 기반 검증으로 동작하는 테스트에서는 테스트 대상이나 협력 객체, 협력 시스템의 메서드 호출 여부를 봄. 상호 작용 테스트라고도 함.
- 테스트에서 말하는 상호 작용은 객체 간의 협력이고, 협력 객체의 메서드 호출을 뜻함.
// 행위 기반 검증을 사용하는 테스트
@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");
}
14.4.3 상태 기반 vs. 행위 기반
- 상태 기반 검증: 시스템의 내부 데이터 상태를 검증하는 테스트
- 행위 기반 검증: 주로 시스템의 내/외부 동작을 검증하는 테스트
- 행위 기반 검증은 알고리즘을 테스트하는 것과 같아서, 객체에 할당된 책임을 달성하는 더 나은 방법이 있어도 적용할 수 없어 시스템 코드가 전체적으로 경직될 수 있음. 테스트는 상태 기반 검증으로 작성하는 편이 좋음.
14.5 Spy
- Spy(스파이): 실제 객체 대신 사용돼서 만약 실제 객체였다면 어떤 메서드가 호출되고 이벤트가 발생했는지 등을 기록하고 감시함. 메서드가 몇 번 호출됐는지, 메서드는 어떤 매개변수로 호출됐는지, 메서드 호출 순서는 어떤지 등 모든 것을 기록함.
- Spy 객체는 기본적인 동작이 실제 객체의 코드와 같음. 따라서 실제 객체와 구분할 수 없음.
- 테스트에서 Spy는 기본적으로 실제 객체인 것처럼 행동함. 하지만 실상은 테스트를 검증하는 데 필요한 정보를 모음. 그리고 검증이 필요한 단계에서 이를 외부에 알림. 원활한 테스트 환경을 구축하기 위해 특정 코드가 원하는 대로 동작하도록 조작하기도 함.
Spy를 구현하는 가장 간단한 방법은 상속을 이용하는 것이다. 구현체인 UserRepositoryImpl 컴포넌트를 상속받아 SpyUserRepository 클래스는 UserRepository 인터페이스를 구현하지 않아도 UserRepositoryImpl 컴포넌트에 상응하는 실제 구현 정보를 모두 가질 수 있다.
// UserRepositoryImpl 컴포넌트를 상속해 Spy를 만들 수 있다.
public class SpyUserRepository extends UserRepositoryImpl {
public int findByEmailCallCount = 0;
public int saveCallCount = 0;
@Override
public Optional<User> findByEmail(String email) {
this.findByEmailCallCount++;
return super.findByEmail(email);
}
@Override
public User save(User user) {
this.saveCallCount++;
return super.save(user);
}
}
Spy를 구현하는 다른 방법은 프록시 패턴을 이용하는 것이다. 또한, Mockito를 이용해 간단하게 만들 수도 있다.
// Mockito를 이용해 Spy를 만들 수 있다.
// UserRepositoryImpl 객체를 spy로 지정한다.
UserRepository userRepositorySpy = spy(new UserRepositoryImpl());
// spy로 지정된 객체는 특정 메서드가 몇 번 호출됐는지 확인할 수 있다.
verify(userRepositorySpy, times(2)).findByEmail(anyString());
// findByEmail 메서드에 대해서만 스텁을 생성할 수도 있다.
given(userRepositorySpy.findByEmail("foobar@localhost.com"))
.thenReturn(Optional.of(User.builder()
.id(1L)
.email("foobar@localhost.com")
.nickname("foobar")
.status(UserStatus.ACTIVE)
.verificationCode("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
.build()));
테스트 대역을 잘 사용하려면 추상화가 잘 돼 있어야 한다. 그리고 의존성 역전도 잘 적용돼 있어야 한다.
이번에는 테스트 대역에 대해서 학습했습니다. Mock을 제외하고는 처음 접하는 개념이 많았는데, 테스트 대역에 대해 확실히 알고 자원 낭비를 줄이기 위해 실제 프로젝트에 적용할 수 있으면 좋을 것 같습니다.
이 글은 『자바/스프링 개발자를 위한 실용주의 프로그래밍』 책을 학습한 내용을 정리한 것입니다.
'프로그래밍 > 객체지향' 카테고리의 다른 글
[스터디5] 01. 협력하는 객체들의 공동체, 이상한 나라의 객체 (0) | 2025.02.20 |
---|---|
[스터디2] 10. 테스트 가능성 (0) | 2025.02.13 |
[스터디2] 08. 자동 테스트 및 테스트 피라미드 (0) | 2025.01.31 |
[스터디2] 07. 도메인 및 알아두면 유용한 스프링 활용법 (0) | 2025.01.25 |
[스터디2] 06. 모듈 (0) | 2025.01.15 |