이전 포스트
https://hou27.tistory.com/entry/TDD-%EB%8C%80%EC%97%AD
실습 환경
- Java 17
- Spring Boot 3.0.1
- wsl 2
테스트가 어려운 코드
개발을 진행하다 보면 테스트할 수 없는 상황이 발생하게 된다.
어떤 경우가 있는지 살펴보도록 하자.
- 하드 코딩된 경로 존재
- 의존 객체를 직접 생성
- 정적 메서드 사용
- 실행 시점에 따라 달라지는 결과
- 역할이 섞여있는 코드
- 그 외
- 메서드 중간에 소켓 통신 코드가 포함
- 콘솔에서 입력받거나 결과를 콘솔에 출력
- 테스트 대상이 사용하는 의존 대상 클래스나 메서드가 final -- 이 경우 대역으로 대체 어려울 수 있음
- 테스트 대상의 소스를 소유하고 있지 않아 수정 어려움
하드 코딩 & 의존 객체 직접 생성 예시
public class PaySync {
// 의존 객체를 직접 생성하고 있다.
private PayInfoDao payInfoDao = new PayInfoDao();
public void sync() {
// 하드 코딩된 경로를 사용하고 있다.
Path path = Path.of("tmp/paydata/payinfo.csv");
List<PayInfo> payInfos = Files.lines(path)
.map(line -> {
String[] data = line.split(",");
return new PayInfo(data[0], Integer.parseInt(data[1]));
}).collect(Collectors.toList());
payInfos.forEach(pi -> payInfoDao.insert(pi));
}
}
파일 경로가 하드 코딩되어있어
반드시 해당 경로에 파일이 위치해야 테스트가 가능하다.
이런 경로 뿐만 아니라 하드 코딩된 ip, port 정보도
테스트를 어렵게 만든다
의존하는 대상을 직접 생성하는 경우도
테스트 시 올바르게 동작시키기 위해
필요한 환경을 모두 구성해야하므로 테스트가 어려워진다
정적 메서드 사용 예시
public class LoginService {
private String authKey = "some_key";
private CustomerRepository customerRepository;
public LoginService(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
public LoginResult login(String id, String password) {
int resp = 0;
boolean authorized = AuthUtil.authorize(authKey); // 정적 메서드를 사용
if (authorized) {
resp = AuthUtil.authenticate(id, password); // 정적 메서드를 사용
} else {
resp = -1;
}
if (resp == -1) {
return LoginResult.badAuthKey();
} else if (resp == 1) {
return LoginResult.success();
} else {
return LoginResult.fail();
}
}
}
AuthUtil 클래스가 어떠한 인증 서버와 통신하는 경우 동작하고 있는 인증 서버가 필요하며,
통신할 인증 서버 정보를 시스템 프로퍼티에서 가져온다면 그 정보 또한 테스트 환경에 맞게 설정해야 하는 등
정적 메서드를 사용하는 경우 까다로운 부분이 있다.
테스트 중 static한 field 또는 method를 사용할 경우 모든 클래스는 예상되는 상태에서 시작되어야하며 매우 까다로워질 수 있다.
실행 시점에 따라 결과가 달라지는 예시 & 역할이 섞인 코드 예
public class UserPointCalculator {
...
public int calculatePoint(User u) {
Subscription s = subscriptionDao.selectByUser(u);
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;
}
}
주석이 붙은 행을 보면 알 수 있듯이
현재 시간에 따라 결과가 달라지는 코드이다.
분명 성공하던 테스트가 저 부분때문에 실패할 수도 있기 때문에 테스트가 어려워진다.
랜덤 값을 사용하는 경우도 마찬가지이다.
또한 point를 계산하는 로직만 따로 테스트하기 어려운 코드이다.
테스트가 가능한 설계
위에서 살펴본 경우들은
의존하는 코드를 교체할 수 있는 수단이 없기 때문에 테스트가 어려운 것인데,
상황에 맞게 알맞은 방법을 활용하여 해결할 수 있다.
- 하드 코딩된 상수를 생성자나 메서드 파라미터로 받기
- 의존 대상을 주입 받기
- 테스트하고 싶은 코드를 분리
- 시간이나 임의 값 생성 기능 분리
- 외부 라이브러리는 직접 사용하지 말고 감싸서 사용
하드 코딩된 상수 처리
메서드 파라미터로 받기
public class PaySync {
...
// 경로를 설정 가능하게 메서드 생성
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public void sync() throws IOException {
Path path = Path.of(filePath);
List<PayInfo> payInfos = Files.lines(path)
.map(line -> {
...
}
}
파일 경로를 변경할 수 있는 메서드를 구현하여
경로를 교체할 수 있도록 하였다.
경우에 따라 생성자를 통해 받게 작성할 수도 있다.
의존 대상을 주입 받기
public class PaySync {
private PayInfoDao payInfoDao;
private String filePath = "tmp/paydata/payinfo.csv";
// 생성자를 통해 의존성을 주입받는다.
public PaySync(PayInfoDao payInfoDao) {
this.payInfoDao = payInfoDao;
}
...
}
의존 대상을 주입 받도록 하여 테스트 진행 시
대역을 사용할 수 있도록 하였다.
테스트하고 싶은 코드를 분리
public class UserPointCalculator {
...
public int calculatePoint(User u) {
Subscription s = subscriptionDao.selectByUser(u);
if(s == null) {
throw new NoSubscriptionException();
}
Product p = productDao.selectById(s.getProductId());
LocalDate now = LocalDate.now(); // 현재 날짜를 구한다.
PointRule rule = new PointRule();
int point = rule.calculate(s, p, now);
// int point = 0;
// if(s.isFinished(now)) { // 현재 시간에 따라 결과가 달라진다.
// point += p.getDefaultPoint();
// } else {
// point += p.getDefaultPoint() + 10;
// }
//
// if(s.getGrade() == GOLD) {
// point += 100;
// }
return point;
}
}
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 {
private SubscriptionDao subscriptionDao;
private ProductDao productDao;
private Times times;
public UserPointCalculator(
SubscriptionDao subscriptionDao,
ProductDao productDao,
// 외부로부터 주입받는다.
Times times
) {
this.subscriptionDao = subscriptionDao;
this.productDao = productDao;
this.times = times;
}
public int calculatePoint(User u) {
Subscription s = subscriptionDao.selectByUser(u);
if(s == null) {
throw new NoSubscriptionException();
}
Product p = productDao.selectById(s.getProductId());
// LocalDate now = LocalDate.now(); // 기존 코드
LocalDate now = times.today(); // 현재 날짜를 구한다.
PointRule rule = new PointRule();
int point = rule.calculate(s, p, now);
return point;
}
}
// 시간을 생성하는 기능을 분리하여 대역을 사용할 수 있도록 한다.
public class Times {
public LocalDate today() {
return LocalDate.now();
}
}
테스트 대상이 시간 또는 임의 값을 사용하면 테스트 결과가 일관되지 않을 수 있으므로
해당 값을 생성하는 기능을 별도로 분리하여 대역을 사용할 수 있도록 한다.
이렇게
각 상황 별로 적절한 조치를 통해 테스트를 가능하게 만드는 것이 매우 중요하다.
참고자료
왜 JAVA에서 static의 사용을 지양해야 하는가?
'Study > TDD' 카테고리의 다른 글
TDD - 테스트 범위와 종류 (0) | 2023.02.21 |
---|---|
TDD - 대역 (0) | 2023.02.08 |
TDD - 테스트 코드의 구성 (0) | 2023.02.03 |
TDD - JUnit 5 기초 (0) | 2023.01.25 |
TDD - TDD ∙ 기능 명세 ∙ 설계 (2) | 2023.01.16 |