Study/TDD

TDD - 테스트가 가능한 설계

hou27 2023. 2. 14. 17:18

이전 포스트

https://hou27.tistory.com/entry/TDD-%EB%8C%80%EC%97%AD

 

TDD - 대역

이전 포스트 https://hou27.tistory.com/entry/TDD-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C%EC%9D%98-%EA%B5%AC%EC%84%B1 TDD - 테스트 코드의 구성 이전 포스트 https://hou27.tistory.com/entry/TDD-JUnit-5-%EA%B8%B0%EC%B4%88 TDD - JUnit 5

hou27.tistory.com

 

 

실습 환경

- 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의 사용을 지양해야 하는가?

Testable Code 3

 

 

'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