
테스트 코드가 구현 코드의 설계를 바꿀 정도로 중요한 것인가?
테스트하기 좋은 코드란?
개발자가 제어할 수 없는 값에 의존하는 함수인 경우는 테스트하기가 어렵다.
Random(), new Date() (LocalDate.now()) 와 같이 실행할 때마다 결과가 다른 함수에 의존하는 경우LocalDate.now()인 날짜를 메소드 내부에 기입해놓은 경우는 테스트를 실행할 때마다 결과 값이 달라진다.readLine 혹은 inputBox 등 사용자들의 입력에 의존하는 경우Controller → Service → Repository → Domain 의 계층일 때 Domain에 LocalDate.now()와 같은 제어할 수 없는 코드가 있다면 계층 전반의 테스트가 어려워진다.
테스트의 어려움이 전파가 된다.
그렇기 때문에 의존하는 코드가 가장 적은 영역까지 제어할 수 없는 코드를 밀어내는 것이 좋다.
가장 바깥 쪽으로 밀어내기
handler외부와의 연동이 필요한 경우 테스트 코드 작성이 어렵다.
console.log, System.out.println() 과 같은 표준 출력도메인 내부에 외부에 연동을 필요로 하는 코드가 있다고 가정하자. (이를 Active Record 패턴이라고 한다.)
async/await 함수는 도메인 로직에 최대한 거리를 두는 것이 좋다.private 메소드/함수를 검증해야할 경우가 있다.private 코드를 리팩토링 해야하는 경우private 로직일 경우Service 클래스에 테스트 하기 쉬운 코드 (validate 함수들)와 테스트 하기 어려운 코드 (데이터베이스를 사용하는 async/await 함수) 가 섞여있다고 가정하자.
이를 테스트 하기 위해서는 테스트하기 쉬운 코드와 어려운 코드를 분리해야만 한다.
validate 되어야 할 값이 특정 도메인 클래스의 멤버 변수라면 도메인 클래스에 validate 로직을 위임한다.validate 로직이 필요하다.)validate 되어야할 도메인 클래스의 멤버 변수가 amount고 다른 도메인 클래스에도 사용되어야 한다고 가정한다면validate 로직을 생성하면 된다.new Money(amount)로 Money 클래스를 생성하면 되기 때문에 문제를 해결할 수 있다.만약 private 메소드/함수가 많다면 그건 또 다른 공개 인터페이스 (클래스, public 함수)가 필요할 가능성이 높다.
즉, 단일 기능에 private 메소드/함수가 많다면 public 함수 혹은 클래스로 분리하는 것을 고려해보자.
Native Query 환경에서도 테스트 하기 좋은 방법이 있고, 아닌 방법은 분명히 있다.
아래와 같은 SQL 쿼리문을 SQL Builder를 통해 db에 접근한다고 가정하자.
-- 1)
SELECT * FROM blog WHERE publish_at <= NOW()
-- 2)
SELECT * FROM blog WHERE publish_at BETWEEN DATE_SUB(NOW(), INTERVAL 7 DAY) AND NOW()
NOW()) 가 쿼리 내부에 존재SQL에서 직접 데이터를 처리하거나 로직(NOW(), DATE_SUB(), PASSWORD() 등)을 담고 있으면 테스트 구현과 기능 확장에 취약하다.
가능하다면 SQL에서는 로직을 담지 말고, 저장소로서의 역할에만 충실하도록 구현하는 것이 좋다. 그래야만 테스트 구현이 쉽고, 기능 확장에 유리하다.
우리 프로젝트에서도 해당 내용 중 1번, 제어할 수 없는 값에 의존하는 코드가 있었다.
서비스 로직 코드를 작성하면서 LocalData.now()를 무분별하게 사용했었고 이에 대한 테스트 코드를 실행할 때면 항상 예상 값과 실제 값이 달라서 테스트 오류가 발생하였다. 그리하여 매번 테스트의 예상 값을 수정해야했다.
그래서 우리는 CurrentTime이라는 인터페이스를 만들었고 인터페이스를 사용시 LocalDate or LocalDateTime 중 알맞게 갈아 끼워서 사용할 수 있도록 제네릭으로 인터페이스를 생성했다.
public interface CurrentTime<T> {
T now();
}
그리고 제네릭으로 선택한 클래스 T를 반환할 수 있는 now() 메서드를 정의했고 인터페이스에 대한 구현을 빈 주입으로 구성해두었다.
@Configuration
public class CurrentTimeConfig {
@Bean
public CurrentTime<LocalDateTime> localDateTimeCurrentTime(){
return LocalDateTime::now;
}
@Bean
public CurrentTime<LocalDate> localDateCurrentTime(){
return LocalDate::now;
}
}
이렇게 구현체와 인터페이스를 의존성 주입으로 구현해두니 아래 코드처럼 메인 코드에서 LocalDateTime의 현재 시간을 사용할 수 있었다.
@Service
@RequiredArgsConstructor
@Transactional
public class CommentEditService implements EditCommentUseCase {
private final ModifyCommentPort modifyCommentPort;
private final CurrentTime<LocalDateTime> currentTime;
@Override
public void edit(Long commentId, String content) {
modifyCommentPort.modifyContent(commentId, content, currentTime.now());
}
}
그리고 테스트에서도 CurrentTime 인터페이스에 특정 날짜 값을 직접 주입하고 외부에서 값을 정하기 때문에 항상 예상 값과 실제 값이 같았고 테스트에 대한 오류가 발생하지 않았다.
CurrentTime<LocalDate> currentTime = () -> LocalDate.of(2023, 1,1);