[테스트] 좋은 테스트에 대한 고찰

도현김·2024년 4월 12일
post-thumbnail
  • 테스트 코드가 구현 코드의 설계를 바꿀 정도로 중요한 것인가?

    • 테스트는 구현의 보조적인 수단인데, 이를 위해 구현부 설계가 교체되는게 옳은 것인가?
    • 테스트를 위해 구현 설계가 변경될 수 있다.
    • 좋은 디자인으로 구현된 코드는 대부분 테스트 하기가 쉽다.
    • 테스트 하기 어렵게 구현되었다면, 코드 확장성 / 의존성 등 코드 디자인, 설계가 잘 못 되었을 확률이 굉장히 높다.
    • 테스트 코드는 구현의 보조 수단이 절대 아니다. 오히려 구현 설계 Smell을 맡게 해주는 좋은 수단이다.
  • 테스트하기 좋은 코드란?

    • 몇 번을 수행해도 항상 같은 결과가 반환되는 함수 (멱등성이 보장되는 순수 함수)가 테스트하기 좋은 코드
    • 이러한 코드가 되기 위해서는 아래의 2가지 요소를 해결할 필요가 있다.

1. 제어할 수 없는 값에 의존하는 경우

개발자가 제어할 수 없는 값에 의존하는 함수인 경우는 테스트하기가 어렵다.

  • Random()new Date() (LocalDate.now()) 와 같이 실행할 때마다 결과가 다른 함수에 의존하는 경우
    • LocalDate.now()인 날짜를 메소드 내부에 기입해놓은 경우는 테스트를 실행할 때마다 결과 값이 달라진다.
  • readLine 혹은 inputBox 등 사용자들의 입력에 의존하는 경우
  • 전역 함수, 전역 변수 등에 의존하는 경우
  • PG사 라이브러리등 외부 SDK에 의존하는 경우

1-1. 제어할 수 없는 코드 개선

1-1-1. 제어할 수 없는 값을 외부에서 주입.

  • 생성자, 함수(메소드)의 인자로 테스트하기 어려운 코드의 결과를 받는 것
  • 내가 원하는 값으로 지정해서 테스트를 작성할 수 있다.
  • 그러나 함수에 인자가 너무 많아진다면 함수 인자의 기본 값을 사용한다.
    • 이렇게 하면 비즈니스에서는 인자를 굳이 넣을 필요가 없고, 테스트에서는 내가 넣고 싶은 값을 인자로 넣으면 된다.

1-1-2. 제어할 수 없는 코드의 위치

  • Controller → Service → Repository → Domain 의 계층일 때 Domain에 LocalDate.now()와 같은 제어할 수 없는 코드가 있다면 계층 전반의 테스트가 어려워진다.

  • 테스트의 어려움이 전파가 된다.

  • 그렇기 때문에 의존하는 코드가 가장 적은 영역까지 제어할 수 없는 코드를 밀어내는 것이 좋다.

  • 가장 바깥 쪽으로 밀어내기

    • 이렇게 되면 Controller를 제외한 나머지 계층은 테스트 하기 어려운 코드와 분리된다. (코드의 의존 범위를 줄이는 것)
    • 가장 바깥 쪽의 예시
      1. AWS Lambda 등의 서버리스 환경에서의 handler
      2. 백엔드 API에서의 Controller
      3. 웹프론트 혹은 앱 등의 클라이언트에서의 이벤트 핸들러

1-1-3. 의존성 주입

  • 그러나 여전히 Controller는 테스트가 어렵다.
  • 그 밑으로 Service, Repository, Component는 모든 함수가 제어할 수 없는 값을 항상 메소드 인자로 받아야함.
  • 제어할 수 없는 값을 인터페이스로 정의해둠 (의존성 주입)
    1. 실제 비즈니스에서는 제어할 수 없는 값을 반환하는 클래스를 구현체로 주입한다.
    2. 테스트에서는 람다식으로 값을 주입하거나 테스트용 클래스를 구현체로 주입한다.
  • 의존성 주입 방법을 사용하면 제어할 수 없는 값을 굳이 가장 바깥 쪽으로 밀어낼 필요 또한 없어진다.

2. 외부에 영향을 주는 코드

외부와의 연동이 필요한 경우 테스트 코드 작성이 어렵다.

  • console.logSystem.out.println() 과 같은 표준 출력
  • Logger 등을 사용하는 경우
  • 이메일 발송, 메세지 큐 등 외부로의 메세지 발송
  • 외부 API에 의존하는 경우
  • 데이터베이스 등에 의존하는 경우
    • 매 테스트 마다 테이블 스키마가 존재해야 함
    • 테스트를 수행하기 위해 기본적인 데이터 적재 / 테스트 환경 setup이 필요함
    • 테스트가 끝날 때마다 사용된 테이블을 초기화하여 다음 테스트에 영향을 끼치지 않도록 해야한다.
    • 요즘은 그나마 Docker나 in memory DB를 써서 그나마 낫지만 이것 또한 테스트의 난이도를 높인다.
    • 느린 테스트의 주범

도메인 내부에 외부에 연동을 필요로 하는 코드가 있다고 가정하자. (이를 Active Record 패턴이라고 한다.)

2-1. 문제점

2-1-1. 복잡한 테스트 환경 구축

  • 도메인 로직의 테스트를 검증하기 위해서 외부에 연동을 해야 할 필요가 생겨남.
    • ex) 테스트 DB 실행, 테이블 생성, 데이터 주입, 데이터 베이스에서 쿼리 실행, 데이터베이스 종료
  • 굳이 데이터베이스가 포함된 통합 테스트 환경 구축에 해야할 일이 많아진다.
  • 테스트 환경 구축에 많은 리소스를 사용하게 된다.

2-1-2. 낮은 테스트 리팩토링 내구성

  • 만일 외부에 연동해야 하는 의존 대상이 교체될 때마다 모든 테스트 코드를 수정해야 한다.
  • 이는 리팩토링을 더욱 힘들게 만든다.

2-1-3. 지키기 어려운 일관성

  • 외부에 의존하고 있기 때문에 외부의 상황에 따라 언제든 테스트 결과가 바뀔 수 있다.
  • 테스트는 언제 수행해도 동일한 결과가 반환되어야 한다.

2-1-4. 느린 테스트

  • 위에서 말했던 외부에 연동하기 위해 하는 행위 때문에 테스트의 속도가 느려진다.

2-2 해결 방법

  • 문제점은 로직 안에 외부 의존성이 포함되어 있게 때문에 발생한다.
  • 그렇기 때문에 외부 영역에 의존성이 있는 로직을 도메인 영역에서 떨어뜨려 놓으면 된다.
    • domain 영역에서는 domain만 생성한 후 반환한다.
    • domain을 의존하고 있는 service나 repository 영역에서 domain의 반환 값을 받아 외부에 연동하는 역할을 한다.
  • 이렇게 하면 도메인 영역에선 외부 영역에 접근하는 로직을 제외하기 때문에 나머지 로직의 검증이 쉽다.
  • 도메인 영역은 그저 throw가 잘 발생하는지 return으로 의도한 값이 넘어오는지만 확인하면 된다.
  • 이렇게 책임을 분리하고 외부 의존성이 필요한 통합 테스트의 범위를 좁힘으로써 더 나은 테스트 코드를 작성할 수 있다.
  • 외부 영역을 접근하는 코드나 async/await 함수는 도메인 로직에 최대한 거리를 두는 것이 좋다.
  • 근데 너무 당연한 얘기를 하는 거 아닌가? 도메인에서 ORM이나 DB, 외부 영역에 접근하는 코드가 있는지 확인해봐야 겠다.

3. 검증이 필요한 비공개 함수

  • private 메소드/함수의 테스트 코드는 작성하지 않는 것이 좋을때가 많다.
  • 그럼에도 불구하고 private 메소드/함수를 검증해야할 경우가 있다.
    • 테스트가 없는 기존 private 코드를 리팩토링 해야하는 경우
    • 테스트하기 어려운 코드를 몰아넣은 Presentation (Controller, Handler 등), Service Infra 계층의 private 로직일 경우

3-1. 문제 상황

Service 클래스에 테스트 하기 쉬운 코드 (validate 함수들)와 테스트 하기 어려운 코드 (데이터베이스를 사용하는 async/await 함수) 가 섞여있다고 가정하자.

이를 테스트 하기 위해서는 테스트하기 쉬운 코드와 어려운 코드를 분리해야만 한다.

3-2. 해결 방법

3-2-1. 도메인 클래스에 위임

  • validate 되어야 할 값이 특정 도메인 클래스의 멤버 변수라면 도메인 클래스에 validate 로직을 위임한다.
  • 그렇게 되면 외부 의존성 (데이터베이스)에 의존하지 않기 때문에 테스트 코드 작성의 어려움이 해결된다.
  • 그러나 검증하고자 하는 로직이 기존 도메인 로직에 담기에 모호하거나 여러 도메인이 공통적으로 필요로 하는 경우에는 도메인 클래스에 위임할 수가 없다. (다른 도메인에도 비슷한 validate 로직이 필요하다.)

3-2-2. 공개 함수 혹은 클래스로 묶어서 추출

  • 아까의 validate 되어야할 도메인 클래스의 멤버 변수가 amount고 다른 도메인 클래스에도 사용되어야 한다고 가정한다면
  • amount를 감싸는 Money라는 클래스를 새로 만들어서 클래스 내부에 validate 로직을 생성하면 된다.
  • 그렇게 되면 테스트시에는 Money라는 클래스만 테스트하면 되기 때문에 외부 로직과 연동과는 관계가 없어진다.
  • 그리고 비즈니스 로직에서도 new Money(amount)로 Money 클래스를 생성하면 되기 때문에 문제를 해결할 수 있다.

만약 private 메소드/함수가 많다면 그건 또 다른 공개 인터페이스 (클래스, public 함수)가 필요할 가능성이 높다.

즉, 단일 기능에 private 메소드/함수가 많다면 public 함수 혹은 클래스로 분리하는 것을 고려해보자.

4. SQL에서 제어할 수 없는 값

Native Query 환경에서도 테스트 하기 좋은 방법이 있고, 아닌 방법은 분명히 있다.

4-1. 문제 상황

아래와 같은 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()
  • 실행할때마다 변경되는 현재 시간 SQL 함수 (NOW()) 가 쿼리 내부에 존재
  • 오늘이라는 조건이 고정되어있어, 특정 일자의 조회와 같은 기능 확장에 취약
  • 테스트 대상 내부에 제어할 수 없는 코드가 있으면 테스트 코드를 작성하기가 어렵다.
  • 해당 쿼리문이 있는 코드는 테스트 코드 작성시 매번 다른 결과를 나타낼 것이며 재활용 또한 어렵다.

4-2. 해결 방법

  • 쿼리문 내에서 비즈니스 로직을 파라미터로 받는다. 이렇게 하면 쿼리는 고정되더라도, 테스트 코드를 작성할 때도, 메소드를 재활용할 때도 쉽다.
  • 거의 대부분의 SQL Builder에는 대부분 쿼리문의 일부분을 파라미터를 통해 치환하는 기능을 가지고 있다.

SQL에서 직접 데이터를 처리하거나 로직(NOW()DATE_SUB()PASSWORD() 등)을 담고 있으면 테스트 구현과 기능 확장에 취약하다.

가능하다면 SQL에서는 로직을 담지 말고, 저장소로서의 역할에만 충실하도록 구현하는 것이 좋다. 그래야만 테스트 구현이 쉽고, 기능 확장에 유리하다.

5. 프로젝트에서 적용한 예시 ⭐⭐

우리 프로젝트에서도 해당 내용 중 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);

6. 참고

profile
안녕하세요! 신입 개발자 김도현입니다.

0개의 댓글