효습
Chapter 8. 테스트 가능한 설계 본문
테스트가 어려운 코드
모든 코드를 테스트할 수 있는 것은 아니다.
어떻게 하면 테스트 가능하게 바꿀 수 있는지 알아보자
하드 코딩된 경로
결제 대행업체가 결제 내역이 유효한지 확인할 수 있도록 익일 오전에 결제 결과를 파일로 제공한다고 하자
이 파일을 읽어와 DB에 결제 내역을 반영하는 코드는 아래와 같이 작성할 수 있다
public class PaySync {
private PayInfoDao payInfoDao = new PayInfoDao();
public void sync() throws IOException {
Path path = Paths.get("D:\\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[2])
);
return payInfo;
})
.collect(Collectors.toList());
payInfos.forEach(pi -> payInfoDao.insert(pi));
}
}
위의 코드를 보면 파일 경로가 하드 코딩되어 있다.
이 코드를 테스트하려면 해당 경로에 파일이 반드시 위치해야한다.
하지만 파일이 없거나 윈도우 사용자가 아니라면 테스트할 수 없다 .
테스트하려면 경로를 알맞게 수정해야한다.
하드 코딩된 경로뿐만 아니라 하드 코딩된 IP 주소 , 포트 번호도 테스트를 어렵게 만든다.
의존 객체를 직접 생성
위의 코드가 테스트하기 어려운 이유는 의존 대상을 직접 생성하고 있다는 점이다.
public class PaySync {
private PayInfoDao payInfoDao = new PayInfoDao();
....
}
이 코드를 테스트하려면 PayInfoDao
가 올바르게 동작해야하는데 , DB를 준비해야하고 필요한 테이블도 만들어야 한다.
정적 메서드 사용
정적 메서드를 사용해도 테스트가 어려워질 수 있다.
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
클래스의 정적 메서드를 사용하고 있다.
AuthUtil
클래스가 인증 서버와 통신하는 경우 이 코드를 테스트하려면 동작하고 있는 인증 서버가 필요하다.
AuthUtil
클래스가 통신할 인증 서버 정보를 시스템 프로퍼티에 가져온다면 시스템 프로퍼티도 테스트 환경에 맞게 설정해야한다.
이런 경우 테스트하기 어려운 코드가 되는 것이다.
실행 시점에 따라 달라지는 결과
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;
}
}
위의 코드는 특정 사용자의 포인트를 계산하는 로직을 나타낸다.
calculatePoint()
메서드는 사용자의 구독 상태나 제품에 따라 계산한 결과 값을 리턴한다.
LocalDate.now()
에 따라 실행 결과가 달라지는데 이렇게 테스트를 실행하는 시점에 따라 테스트 결과가 달라진다면 테스트는 믿을 수 없게 된다.
역할이 섞여 있는 코드
특정 사용자의 포인트를 계산하는 코드의 다른 문제는 포인트 계산 로직만 테스트하기 어렵다는 것이다.
포인트 계산 결과를 테스트하려면 SubscriptionDao 와 ProductDao에 대한 대역을 구성해야 한다.
포인트 계산 자체는 두 DAO와 상관이 없지만 포인트 계산에 필요한 것은 Subscription과 시간 그리고 Product이다. → 그래서 포인트 계산만 테스트할 수 없음
그 외 테스트가 어려운 코드
- 메서드 중간에 소켓 통신 코드가 포함되어 있다.
- 콘솔에서 입력을 받거나 결과를 콘솔에 출력한다
- 테스트 대상이 사용하는 의존 대상 클래스나 메서드가 final이다. ← 이 경우 대역으로 대체가 어려울 수 있다.
- 테스트 대상의 소스를 소유하고 있지 않아 수정이 어렵다
소켓 통신이나 HTTP 통신을 실제를 대체할 서버를 로컬에 띄워서 처리할 수 있다. 서버 수준에서 대역을 사용한다고 생각하면 된다. HTTP 서버를 대역으로 대신하는 예는 9장에서 확인 plz
테스트 가능한 설계
테스트가 어려운 주된 이유는 의존하는 코드를 교체할 수 있는 수단이 없기 때문이다.
상황에 따라 알맞는 방법을 적용하면 의존 코드를 교체할 수 있게 만들 수 있다.
하드 코딩된 상수를 생성자나 메서드 파라미터로 받기
하드 코딩된 경로가 테스트가 어려운 이유는 테스트 환경에 따라 경로를 다르게 줄 수 있는 수단이 없기 때문이다.
하드 코딩된 상수 때문에 테스트가 힘들다면 해당 상수를 교체할 수 있는 기능을 추가하면 된다.
쉬운 방법은 생성자나 세터를 이용해서 경로를 전달받는 것이다.
// 세터를 이용해서 값을 교체 가능하게 함으로써 테스트가 쉬워짐
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);
...
}
파일 경로를 변경할 수 있게 세터 메서드를 추가했다면 테스트 코드는 알맞게 파일 경로를 변경해서 테스트할 수 있다.
파일 경로를 변경하는 또 다른 방법은 메서드를 실행할 때 일자로 전달 받는 것이다.
// 하드 코딩된 경로를 파라미터로 전달받아 테스트 가능하게 변경
public void sync(String filePath) throws IOException {
Path path = Paths.get(filePath);
...
}
테스트 코드에서 데이터를 읽을 때 사용하는 파일은 소스 코드 리포지토리에 함께 등록해야한다.
메이븐 프로젝트를 사용한다면 src/test/file
폴더나 src/test/resources
폴더가 테스트 용도의 파일을 저장하기에 적당한 위치이다.
의존 대상을 주입 받기
의존 대상은 주입 받을 수 있는 수단을 제공해서 교체할 수 있도록 해야한다.
생성자나 세터를 주입 수단으로 이용하면 된다. 생성자나 세터를 통해 의존 대상을 교체할 수 있게 되면 실제 구현 대신에 대역을 사용할 수 있어 테스트를 보다 원활하게 작성할 수 있다.
public class PaySync {
private PayInfoDao payInfoDao = new PayInfoDao();
private String filePath = "D:\\data\\pay\\cp0001.csv";
public PaySync(PayInfoDao payInfoDao){
this.payInfoDao = payInfoDao;
}
public void sync() throws IOException {
Path path = Paths.get("D:\\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[2])
);
return payInfo;
})
.collect(Collectors.toList());
payInfos.forEach(pi -> payInfoDao.insert(pi));
}
}
만약 많은 레거시 코드에서 생성자 없는 버전을 사용하고 있다면 , 기존 코드는 그대로 유지하고 세터를 이용해서 의존 대상을 교체할 수 있도록 하면 된다.
의존 대상을 교체할 수 있도록 코드를 수정했다면 대역을 사용해서 테스트를 진행하면 된다.
시간이나 임의 값 생성 기능 분리하기
테스트 대상이 시간이나 임의 값을 사용하면 테스트 시점에 따라 테스트 결과가 달라진다.
이 경우 테스트 대상이 사용하는 시간이나 임의 값을 제공하는 기능을 별도로 분리해서 테스트 가능성을 높일 수 잇다.
그렇다면 이러한 기능을 분리하고 분리한 대상을 주입할 수 있게 변경하면 테스트를 원하는 상황으로 쉽게 제어할 수 있다.
임의 값도 비슷하다. 임의 값을 제공하는 라이브러리를 직접 사용하지 말고 별도로 분리한 타입을 사용해서 대역으로 처리할 수 있어야 테스트 가능하게 만들 수 있다.
외부 라이브러리는 직접 사용하지 말고 감싸서 사용하기
테스트 대상이 사용하는 외부 라이브러리를 쉽게 대체할 수 없는 경우도 있다.
외부 라이브러리가 정적 메서드를 제공한다면 대체할 수 없다
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
클래스가 외부에서 제공한 라이브러리에 포함되어 있다고 하자
AuthUtil.authorize()
메서드와 AuthUtil.authenticate()
메서드는 정적 메서드이기 때문에 대역으로 대체하기 어렵다
이렇다면 외부 라이브러리를 직접 사용하지 말고 외부 라이브러리와 연동하기 위한 타입을 따로 만든다.
그리고 테스트 대상은 이렇게 분리한 타입을 사용하게 바꾼다.
테스트 대상 코드는 새로 분리한 타입을 사용함으로써 외부 연동이 필요한 기능을 쉽게 대역으로 대체할 수 있어야 한다.
예를 들어 AuthUtil
을 사용하는 코드는 아래와 같이 클래스로 분리할 수 있다.
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;
}
}
}
테스트 대상은 분리한 타입을 사용하도록 변경한다.
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);
...
}
의존하는 대상이 Final 클래스이거나 의존 대상의 호출 메서드가 final 이어서 대역으로 재정의할 수 없는 경우에도 동일한 기법을 적용해서 테스트 가능하게 만들 수 있다.
'책 > 테스트 주도 개발 시작하기' 카테고리의 다른 글
Chapter 07. 대역 (0) | 2025.06.17 |
---|---|
Chapter 06. 테스트 코드의 구성 (1) | 2025.05.26 |
Chapter 05. JUnit5 기초 (0) | 2025.04.30 |
Chapter 04. TDD 기능 명세 , 설계 (0) | 2025.04.13 |
Chapter 03. 테스트 코드 작성 순서 (2) | 2024.09.30 |