Engineering
[테스트 주도 개발 시작하기] 7 ~ 8장
unknownomad
2023. 6. 15. 23:42
<7장> 대역
대역의 필요성
외부 요인이 테스트에 관여하는 예시
- 테스트 대상에서 파일 시스템 사용
- 테스트 대상에서 DB 로부터 데이터를 조회하거나 데이터를 추가
- 테스트 대상에서 외부의 HTTP 서버와 통신
Double
대역
- test double = 테스트에서 진짜 대신 사용할 대역
- 테스트 대상에서 의존하는 요인 때문에 테스트가 어려울 때 대역 사용
- = 스턴트들처럼 외부 요인으로 인해 테스트가 어려울 때 외부 요인을 대신하는 대역 사용
대역을 이용한 테스트
단순 구현으로 실제 구현 대체함
public class AutoDebitRegister_Stub_Test {
private AutoDebitRegister register;
private StubCardNumberValidator stubValidator; // 대역, 실제 구현 대체
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 result = this.register.register(req);
assertEquals(INVALID, result.getValidity());
}
DB 연동 없이 메모리 사용하기에 테스트 빠름
- 아래 예시에서는 DB 대신 맵 이용
- 메모리에만 데이터가 저장되므로 DB 같은 영속성을 제공하지는 않지만, 테스트에 사용할 수 있을 만큼의 기능은 제공함
public class MemoryAutoDebitInfoRepository implements AutoDebitInfoRepository {
private Map<String, AutoDebitInfo> infos = new HashMap<>();
@Override
public void save(AutoDebitInfo info) {
infos.put(info.getUserId(), info);
}
@Override
public AutoDebitInfo findOne(String userId) {
return infos.get(userId);
}
}
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() {
// repo.save()
repository.save(
new AutoDebitInfo("user1", "111222333444", LocalDateTime.now()));
AutoDebitReq req = new AutoDebitReq("user1", "123456789012");
RegisterResult result = this.register.register(req);
// repo.findOne()
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);
// repo.findOne()
AutoDebitInfo saved = repository.findOne("user1");
assertEquals("1234123412341234", saved.getCardNumber());
}
}
대역을 사용한 외부 상황 흉내와 결과 검증
- 외부의 상황을 흉내냄
- 외부에 대한 결과 검증 가능
@Test
void invalidCard() {
// 유효하지 않은 카드 번호 상황 흉내
stubValidator.setInvalidNo("111122223333");
AutoDebitReq req = new AutoDebitReq("user1", "111122223333");
RegisterResult result = this.register.register(req);
assertEquals(INVALID, result.getValidity());
}
@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());
}
대역의 종류
대역 종류 | 설명 |
스텁(Stub) | 구현을 단순한 것으로 대체 테스트에 맞게 단순히 원하는 동작 수행 |
가짜(Fake) | 제품에는 부적합하나, 실제 동작하는 구현 제공 DB 대신 메모리를 이용해 구현 |
스파이(Spy) | 호출된 내역 기록 기록한 내용은 테스트 결과 검증 시 사용 Stub 이기도 함 |
모의(Mock) | 기대한 대로 상호작용하는지 행위 검증 기대한 대로 동작하지 않으면 익셉션 발생할 수 있음 모의 객체는 Stub 이자 Spy 도 됨 |
테스트 코드 작성 순서
- 상황(실행) ➡️ 검증(결과) 시나리오용 테스트 코드 작성
- 컴파일 에러 발생
- 컴파일 에러 제거
- 스텁 구현 알맞게 추가 및 일반화
- 테스트 재실행
- 최종 테스트
- 상수를 이용해 통과시킨 테스트 코드 개선
package user;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
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 weakPassword() {
stubPasswordChecker.setWeak(true);
assertThrows(WeakPasswordException.class, () -> {
userRegister.register("id", "pw", "email");
});
}
@DisplayName("이미 같은 ID가 존재하면 가입 실패")
@Test
void dupIdExists() {
// 이미 같은 ID 존재하는 상황 만들기
fakeRepository.save(new User("id", "pw1", "email@email.com"));
assertThrows(DupIdException.class, () -> {
userRegister.register("id", "pw2", "email");
});
}
@DisplayName("같은 ID가 없으면 가입 성공함")
@Test
void noDupId_RegisterSuccess() {
userRegister.register("id", "pw", "email");
User savedUser = fakeRepository.findById("id"); // 가입 결과 확인
assertEquals("id", savedUser.getId());
assertEquals("email", savedUser.getEmail());
}
@DisplayName("가입하면 메일을 전송함")
@Test
void whenRegisterThenSendMail() {
userRegister.register("id", "pw", "email@email.com");
// 결과 확인 위해 대역 이용
assertTrue(spyEmailNotifier.isCalled());
assertEquals(
"email@email.com",
spyEmailNotifier.getEmail());
}
}
모의 객체로 스텁과 스파이 대체
public class UserRegisterMockTest {
private UserRegister userRegister;
// Mockito.mock(): 인자로 전달받은 타입의 모의 객체 생성
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
// BDDMockito.given()
// pw 인자를 사용해 모의 객체의 checkPasswordWeak 메서드 호출하면(상황)
.given(mockPasswordChecker.checkPasswordWeak("pw"))
// .willReturn(): 결과로 true 리턴(검증)
.willReturn(true);
assertThrows(WeakPasswordException.class, () -> {
userRegister.register("id", "pw", "email");
});
}
@DisplayName("회원 가입시 암호 검사 수행함")
@Test
void checkPassword() {
userRegister.register("id", "pw", "email");
// BDDMockito.then(): 인자로 전달한 mockPasswordChecker 모의 객체의
BDDMockito.then(mockPasswordChecker)
// .should(): 특정 메서드가 호출됐는지 검증하는데,
.should()
// 임의의 String 타입 인자를 이용해 checkPasswordWeak 메서드 호출 여부 확인
.checkPasswordWeak(Mockito.matches("pw"));
}
@DisplayName("이미 같은 ID가 존재하면 가입 실패")
@Test
void dupId() {
// 이미 같은 ID 존재하는 상황 만들기
fakeRepository.save(new User("id", "pw1", "email@email.com"));
assertThrows(DupIdException.class, () -> {
userRegister.register("id", "pw2", "email");
});
}
@DisplayName("같은 ID가 없으면 가입 성공함")
@Test
void noDupId_RegisterSuccess() {
userRegister.register("id", "pw", "email");
User savedUser = fakeRepository.findById("id");
assertEquals("id", savedUser.getId());
assertEquals("email", savedUser.getEmail());
}
@DisplayName("가입하면 메일을 전송함")
@Test
void whenRegisterThenSendMail() {
userRegister.register("id", "pw", "email@email.com");
// ArgumentCaptor: 메서드 호출 시 모의 객체를 전달받아 담는 기능
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
// ArgumentCaptor#capture()
// 테스트할 메서드 호출 시 전달한 인자가 ArgumentCaptor 에 담김
BDDMockito.then(mockEmailNotifier).should().sendRegisterEmail(captor.capture());
// ArgumentCaptor#getValue(): 보관한 인자 구하기
String realEmail = captor.getValue();
assertEquals("email@email.com", realEmail);
}
}
상황과 결과 확인을 위한 협업 대상(의존) 도출과 대역 사용
- 제어하기 힘든 외부 상황을 별도 타입으로 분리
- 테스트 코드는 별도로 분리한 타입의 대역을 생성
- 생성한 대역을 테스트 대상의 생서자 등을 이용해 전달
- 대역을 이용해 상황 구성
@DisplayName("가입하면 메일을 전송함")
@Test
void whenRegisterThenSendMail() {
userRegister.register("id", "pw", "email@email.com");
// 결과 확인 위해 대역 이용
assertTrue(spyEmailNotifier.isCalled());
assertEquals(
"email@email.com",
spyEmailNotifier.getEmail());
}
점진적으로 테스트 코드 작성
- 구현에 시간이 오래 걸리는 로직도 별도 타입으로 분리 시, 지금 당장은 미구현했어도 관련 테스트 통과시킬 수 있음
@DisplayName("약한 암호면 가입 실패")
@Test
void weakPassword() {
assertThrows(WeakPasswordException.class, () -> {
userRegister.register("id", "pw", "email");
});
assertThrows(WeakPasswordException.class, () -> {
userRegister.register("id", "pw3", "email");
});
...
약한 암호 예시 추가하며 기능 구현
}
대역과 개발 속도
- 대역 사용 시 의존하는 대상을 구현하지 않아도 테스트 대상 완성할 수 있음
- 대기 시간 단축 및 개발 속도 향상에 기여
- e.g.
- DB 없어도 데이터가 올바르게 저장되는지 확인
- 메일 서버가 없어도 이메일 발송 요청 확인
모의 객체 과하게 사용하지 않기
Mock overuse case
- "리포지토리의 save() 메서드를 호출해야 하고, 이때 전달한 객체의 값이 어때야 한다" 식으로 결과 검증
@DisplayName("같은 ID가 없으면 가입 성공함")
@Test
void noDupId_RegisterSuccess() {
userRegister.register("id", "pw", "email");
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
// save() 호출 여부 확인
BDDMockito.then(mockRepository).should().save(captor.capture());
// ArgumentCaptor 이용해 호출 시 전달한 인자 저장해야 함
User savedUser = captor.getValue();
assertEquals("id", savedUser.getId());
assertEquals("email", savedUser.getEmail());
}
Better case
- 메모리 이용한 가짜 구현 사용한 예시
- "리포지토리에 저장된 객체의 값이 어때야 한다" 식으로 실제 검증 내용에 더 가까워짐
- 결과 확인 코드가 더 단순해짐
- 테스트 코드의 의미도 더 명확해짐
@DisplayName("같은 ID가 없으면 가입 성공함")
@Test
void noDupId_RegisterSuccess() {
userRegister.register("id", "pw", "email");
User savedUser = fakeRepository.findById("id");
assertEquals("id", savedUser.getId());
assertEquals("email", savedUser.getEmail());
}
모의 객체
- 결과 값을 확인하는 수단으로 모의 객체 사용 시, 결과 검증 코드 길어지고 복잡해짐
- 기본적으로 메서드 호출 여부를 검증하는 수단이기에, 테스트 대상과 모의 객체 간의 상호 작용이 조금만 바뀌어도 테스트가 깨지기 쉬움
- 모의 객체의 메서드 호출 여부를 결과 검증 수단으로 사용하는 것 주의하기
- 특히 DAO 나 리포지토리 같은 저장소에 대한 대역은 모의 객체 사용보단, 메모리를 이용한 가짜 구현이 테스트 코드 관리에 더 유리함
<8장> 테스트 가능한 설계
테스트가 어려운 코드
하드 코딩된 경로
- 의존 객체를 직접 생성
- 생성된 객체가 올바르게 동작하는데 필요한 모든 환경을 구성해야 함
- DB 준비 및 필요 테이블 구축 필요
- 테스트 시행 시 데이터가 DB 에 추가되기에, 같은 테스트 다시 실행 전, 기존 데이터 삭제 필요
public class PaySync {
private PayInfoDao payInfoDao = new PayInfoDao();
...
}
정적 메서드 사용
- 아래 예시 기준
- AuthUtil 클래스가 인증 서버와 통신하는 경우, 이 코드를 테스트하려면 동작하고 있는 인증 서버 필요
- AuthUtil 클래스가 통신할 인증 서보 정보를 시스템 프로퍼티에서 가져오면, 이도 테스트 환경에 맞게 설정해야
- + 다양한 상황 테스트 위해 인증 서버에 저장된 유효한 ID/PW 사용해야 ...
public LoginResult login(String id, String pw) {
int resp = 0;
boolean authorized = AuthUtil.authorize(authKey);
if (authorized) {
resp = AuthUtil.authenticate(id, pw);
} else {
resp = -1;
}
...
}
실행 시점에 따라 달라지는 결과
- LocalDate 나 Random 이용한 값 사용 시, 생성된 값에 따라 실행 결과가 달라질 수 있음
LocalDate now = LocalDate.now();
int point = 0;
if (s.isFinished(now)) {
point += p.getDefaultPoint();
} else {
point += p.getDefaultPoint() + 10;
}
역할이 섞여 있는 코드
- 테스트할 메서드 실행에 필요한 DAO 등
기타 테스트하기 힘든 상황
- 메서드 중간에 소켓 통신 코드가 포함됨
- 콘솔에서 입력을 받거나 결과를 콘솔에 출력함
- 테스트 대상이 사용하는 의존 대상 클래스나 메서드가 final 임, 이 경우 대역으로 대체 어려움
- 테스트 대상의 소스를 소유하고 있지 않아 수정 어려움
소켓 통신이나 HTTP 통신
- 실제를 대체할 서버를 로컬에 띄워 처리 가능
- 서버 수준에서 대역 사용한다 생각하면 됨
테스트 가능한 설계
하드 코딩된 상수를 생성자나 메서드 파라미터로 받기
- 테스트 환경에 따라 경로를 다르게 줄 수 있는 수단이 없으면 테스트가 어려움
- 그러므로 해당 상수를 교체할 수 있는 기능 추가하면 됨(생성자, 세터 등 통해 전달받기)
- e.g.1. 세터 사용해 파일 경로를 필드에 보관
public class PaySync {
private String filePath = "D:\\data\\pay\\cp0001.csv";
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public void sync() throws IOException {
Path path = Paths.get(filePath);
...
}
}
@Test
void allDataSaved() throws IOException {
PaySync paySync = new PaySync();
paySync.setFilePath("src/test/resources/c0111.csv");
paySync.sync();
...
}
- e.g.2. 메서드 실행 시 인자로 전달받기
public void sync(String filePath) throws IOException {
Path path = Paths.get(filePath);
....
}
@Test
void someTest() throws IOException {
PaySync paySync = new PaySync();
paySync.sync("/src/test/resources/c0111.csv");
...
}
의존 대상 주입 받기
- 의존 대상은 주입 받을 수 있는 수단을 제공해 교체할 수 있도록
- 생성자나 세터를 주입 수단으로 이용
- e.g.1. 생성자 이용해 의존 대상 주입
public class PaySync {
private PayInfoDao payInfoDao = new PayInfoDao();
private String filePath = "D:\\data\\pay\\cp0001.csv";
public PaySync(PayInfoDao payInfoDao) {
this.payInfoDao = payInfoDao;
}
...
}
- e.g.2. 만약 레거시에서 생성자 없는 버전 사용 시, 기존 코드 그대로 유지 및 세터 이용해 의존 대상 교체
public class PaySync {
private PayInfoDao payInfoDao = new PayInfoDao();
private String filePath = "D:\\data\\pay\\cp0001.csv";
public void setPayInfoDao(PayInfoDao payInfoDao) {
this.payInfoDao = payInfoDao;
}
...
}
- 이후 테스트
public class PaySyncTest {
// 대역 생성
private MemoryPayInfoDao memoryDao = new MemoryPayInfoDao();
@Test
void allDataSaved() throws IOException {
PaySync paySync = new PaySync();
paySync.setPayInfoDao(memoryDao); // 대역으로 교체
paySync.setFilePath("src/test/resources/c0111.csv");
paySync.sync();
// 대역 이용한 결과 검증
List<PayInfo> savedInfos = memoryDao.getAll();
assertEquals(2, savedInfos.size());
}
}
테스트하고 싶은 코드 분리
- Before
public int calculatePoint(User u) {
Subscription s = subscriptionDao.selectByUser(u.getId());
if (s == null) throw new NoSubscriptionException();
Product p = productDao.selectById(s.getProductId());
LocalDate now = LocalDate.now();
int point = 0;
if (s.isFinished(now)) {
point += p.getDefaultPoint();
} else {
point += p.getDefaultPoint() + 10;
}
if (s.getGrade() == GOLD) {
point += 100;
}
return point;
}
- After
public class PointRule {
public int calculate(Subscription s, Product p, LocalDate now) {
int point = 0;
if (s.isFinished(now)) {
point += p.getDefaultPoint();
} else {
point += p.getDefaultPoint() + 10;
}
if (s.getGrade() == GOLD) {
point += 100;
}
return point;
}
}
시간이나 임의 값 생성 기능 분리
- 테스트 대상이 사용하는 시간이나 임의 값을 제공하는 기능을 별도 분리해 테스트 가능성 높이기
- 분리한 대상을 주입할 수 있게 변경
public class Times {
public LocalDate today() {
return LocalDate.now();
}
}
public class DailyBatchLoader {
private Times times = new Times(); // Times 의 대역 이용해
public int load() {
LocalDate date = times.today(); // DailyBatchLoader 가 사용할 일자 지정
...
}
}
public class DailyBatchLoaderTest {
private Times mockTimes = Mockito.mock(Times.class);
private final DailyBatchLoader loader = new DailyBatchLoader();
@BeforeEach
void setUp() {
loader.setBasePath("src/test/resources");
loader.setTimes(mockTimes);
}
@Test
void loadCount() {
given(mockTimes.today()).willReturn(LocalDate.of(2019, 1, 1));
int ret = loader.load();
assertEquals(3, ret);
}
}
외부 라이브러리는 직접 사용하지 말고 감싸서 사용
- 외부 라이브러리가 정적 메서드 제공 시 대체 불가함
- ➡️ 외부 라이브러리와 연동하기 쉬운 타입을 따로 만들기
- ➡️ 테스트 대상은 분리한 타입 사용함으로써 외부 연동 필요한 기능을 쉽게 대역으로 대체 가능
- 의존하는 대상이 Final 클래스이거나 의존 대상의 호출 메서드가 final 이어서 대역으로 재정의할 수 없는 경우에도 동일 기법 적용
Before
- 아래 예시 : AuthUtil 클래스의 정적 메서드는 대역으로 대체하기 어려움
public LoginResult login(String id, String pw) {
int resp = 0;
boolean authorized = AuthUtil.authorize(authKey);
if (authorized) {
resp = AuthUtil.authenticate(id, pw);
} else {
resp = -1;
}
if (resp == -1) return LoginResult.badAuthKey();
if (resp == 1) {
Customer c = customerRepo.findOne(id);
return LoginResult.authenticated(c);
} else {
return LoginResult.fail(resp);
}
}
After
- 대역으로 대체 어려운 외부 라이브러리는 직접 사용하지 말고, 그와 연동하기 위한 타입을 따로 만들기
public class AuthService {
private String authKey = "somekey";
public int authenticate(String id, String pw) {
boolean authorized = AuthUtil.authorize(authKey);
if (authorized) {
return AuthUtil.authenticate(id, pw);
} else {
return -1;
}
}
}
public class LoginService {
// AuthService 를 대역으로 대체
private AuthService authService = new AuthService();
public void setAuthService(AuthService authService) {
this.authService = authService;
}
public LoginResult login(String id, String pw) {
int resp = authService.authenticate(id, pw);
...
}
}