테스트가 어려운 코드
- 테스트하기 어려운 사례를 살펴보고, 테스트하기 수월하게 변경하는 과정을 살펴보자.
1. 하드 코딩된 경로
[파일 경로가 하드 코딩되어 있는 테스트 대상]
public class PaySync {
private PayInfoDao payInfoDao = new PayInfoDao();
public void sync() throws IOException {
Path path = Paths.get("/data/pay/cp0001.csv");
List<PayInfo> payInfos = Files.lines(path)
.map(line -> {
String[] data = line.split(",");
PayInfo payInfo = new PayInfo(
data[0], data[1], Integer.parseInt(data[1])
);
return payInfo;
})
.collect(Collectors.toList());
payInfos.forEach(pi -> payInfoDao.insert(pi));
}
}
- Path path = Paths.get("/data/pay/cp0001.csv") 코드를 보면 파일 경로가 하드코딩되어 있다.
해당 경로에 파일이 없다면 코드가 잘 동작해도 테스트에 실패하게 된다.
2. 의존 객체를 직접 생성
[의존 객체를 직접 생성하는 테스트 대상 코드]
public class PaySync {
// 의존 대상을 직접 생성
private PayInfoDao payInfoDao = new PayInfoDao();
public void sync() throws IOException {
// ... 생략
payInfos.forEach(pi -> payInfoDao.insert(pi));
}
}
- 위 코드를 테스트하려면 PayInfoDao가 올바르게 동작하는데 필요한 모든 환경(DB, 테이블)을 구성해야 한다.
3. 정적 메서드 사용
[정적인 메서드로 인해 테스트하기 어려운 코드]
public class LoginService {
private String authKey = "somekey";
private CustomerRepository customerRepo;
public LoginService(CustomerRepository customerRepo) {
this.customerRepo = customerRepo;
}
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);
}
}
}
- AuthUtil의 정적 메서드를 사용것처럼 정적메서드는 대역으로 대체할 수 없기에 테스트하기 어려워진다.
4. 실행 시점에 따라 달라지는 결과
[LocalDateTime.now()로 인해 실행결과가 매번 달라져 테스트하기 어려운 코드]
public boolean verifyEmail(String email, String code) {
EmailVerification verification = repository.findByEmail(email);
if (verification == null || !verification.getCode().equals(code)) {
return false;
}
LocalDateTime now = LocalDateTime.now();
if (verification.isExpired(now)) {
return false;
}
verification.markAsVerified();
return true;
}
- LocalDateTime.now(), Random 등이 테스트 대상에 있으면 테스트 결과가 매번 달라질 수 있다.
5. 역할이 섞여 있는 코드
[기능 구현이 섞여 있어 특정한 부분만 테스트하기 어려운 코드]
public class UserPointCalculator {
private SubscriptionDao subscriptionDao;
private ProductDao productDao;
public UserPointCalculator(SubscriptionDao subscriptionDao,
ProductDao productDao) {
this.subscriptionDao = subscriptionDao;
this.productDao = productDao;
}
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;
}
}
- 위 코드에서 subscriptionDao, productDao등 '포인트 계산'과 관계없는 로직 등이 섞여 있어서,
'포인트 계산'에 대한 로직만 테스트하기 어렵다.
6. 그 외에 테스트가 어려운 코드
- 테스트 대상 메서드 중간에 HTTP통신 혹은 소켓 통신 코드가 포함되어 있는 경우
- 외부 서버에 의존적인 테스트가 될 수 있다.
- 콘솔에서 입력을 받거나 결과를 콘솔에 출력하는 경우
- 테스트 대상이 사용하는 의존 클래스나 메서드가 final인 경우
- override등이 되지않아, 대역을 사용하기 힘들다
테스트 가능한 설계
- 위에서 살펴본 테스트를 어렵게 하는 경우들을 해결해보자.
1. 하드 코딩된 경로 -> 하드 코딩된 상수를 생성자, setter나 메서드 파라미터로 받아서 해결
[setter메서드를 이용해서 값을 교체 가능하게 함으로써 테스트가 쉬워짐]
public class PaySync {
private String filePath = "/data/pay/cp0001.csv";
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public void sync() throws IOException {
Path path = Paths.get(filePath);
//.. 생략
}
}
- 테스트시에 원하는 경로를 setter메서드에 주입하면 된다.
[하드 코딩된 경로를 파라미터로 전달받아 테스트 가능하게 변경]
public class PaySync {
public void sync(String filePath) throws IOException {
Path path = Paths.get(filePath);
//.. 생략
}
}
- 테스트시에 원하는 경로를 테스트 대상 메서드 파라미터로 넘겨주면 된다.
2. 의존 객체를 직접 생성 -> 의존 대상(객체)을 생성자나 세터를 통해 주입받아서 해결
[생성자를 통해서 의존 대상을 주입하게 수정해서 테스트 가능하게 함]
public class PaySync {
private PayInfoDao payInfoDao;
public PaySync(PayInfoDao payInfoDao) {
this.payInfoDao = payInfoDao;
}
public void sync() throws IOException {
// ... 생략
payInfos.forEach(pi -> payInfoDao.insert(pi));
}
}
- 이렇게 생성자를 통해 주입받게 되면 payInfoDao를 대역으로 교체해서 테스트 대상 메서드를 쉽게 검증할 수 있다.
3. 역할이 섞여 있는 코드 -> 테스트하고 싶은 코드를 분리하기
[테스트하고 싶은 코드만 별도로 분리하면 테스트할 수 있다]
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 UserPointCalculator {
// ... 생략
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();
return new PointRule().calculate(s, p, now);
}
}
4. 실행 시점에 따라 달라지는 결과 -> 시간이나 임의 값 생성 기능 분리하기
[시간을 구하는 기능을 별도로 분리하면 테스트를 하기 수월해진다]
public class Times {
public LocalDateTime today() {
return LocalDateTime.now()
}
}
// --------------------------------------------------------------------
public class AuthService{
private Times times = new Times();
public void setTimes(Times times) {
this.times = times;
}
public boolean verifyEmail(String email, String code) {
EmailVerification verification = repository.findByEmail(email);
if (verification == null || !verification.getCode().equals(code)) {
return false;
}
LocalDateTime now = times.today();
if (verification.isExpired(now)) {
return false;
}
verification.markAsVerified();
return true;
}
}
5. 정적 메서드 사용 - 직접 사용하지 말고 감싸서 사용하기
[외부 라이브러리를 감싼 클래스]
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 {
private AuthService authService = new AuthService();
private CustomerRepository customerRepo;
public LoginService(CustomerRepository customerRepo) {
this.customerRepo = customerRepo;
}
public void setAuthService(AuthService authService) {
this.authService = authService;
}
public LoginResult login(String id, String pw) {
int resp = authService.authenticate(id, pw);
if (resp == -1) return LoginResult.badAuthKey();
if (resp == 1) {
Customer c = customerRepo.findOne(id);
return LoginResult.authenticated(c);
} else {
return LoginResult.fail(resp);
}
}
}
- 정적메서드를 다루는 타입을 따로 분리해서 클래스로 만들고, 해당 클래스를 대역으로 교체하면 된다.