[테스트 주도 개발 시작하기] CHAPTER 8 - 테스트 가능한 설계

myeonji·2023년 1월 5일
0
  • 테스트가 어려운 코드
  • 테스트 가능한 설계

테스트가 어려운 코드

모든 코드를 테스트할 수 있는 것은 아니다. 개발을 진행하다 보면 테스트하기 어려운 코드를 만나게 된다. 자주 보게 되는 테스트하기 어려운 사례를 살펴보고 뒤이어 이를 어떻게 하면 테스트 가능하게 바꿀 수 있는지 알아본다.

하드 코딩된 경로

결제 대행업체가 결제 내역이 유효한지 확인할 수 있도록 익일 오전에 결제 결과를 파일로 제공한다. 이 파일을 읽어와 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));
    }
}

위 코드에서 파일 경로가 하드 코딩되어 있다. 이 코드를 테스트하려면 해당 경로에 파일이 반드시 위치해야 한다. 만약 윈도우에 D 드라이브가 없다면 테스트를 할 수 없다. 또한, 윈도우 전용 파일 경로를 사용하고 있기 때문에 맥OS나 리눅스를 사용하는 개발자 역시 테스트할 수 없다.

하드 코딩된 경로뿐만 아니라 하드 코딩된 IP 주소, 포트 번호도 테스트를 어렵게 만든다.

의존 객체를 직접 생성

위 코드에서 테스트를 어렵게 만드는 또 다른 요인은 의존 대상을 직접 생성하고 있다는 점이다.

public class PaySync {
    // 의존 대상을 직접 생성
    private PayInfoDao payInfoDao = new PayInfoDao();

    public void sync() throws IOException {
        ... 생략
        payInfos.forEach(pi -> payInfoDao.insert(pi));
    }
}

이 코드를 테스트하려면 PayInfoDao가 올바르게 동작하는데 필요한 모든 환경을 구성해야 한다. DB를 준비해야 하고 필요한 테이블도 만들어야 한다.

테스트를 실행하면 데이터가 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 값을 구하고 이 값을 기준으로 구독이 끝났는지 확인한다.
같은 코드라도 LocalDate.now()에 따라 실행 결과가 달라진다. 어제까지는 문제없이 성공하던 테스트가 오늘은 깨질 수 있는 것이다.
Random을 이용해서 임의 값을 사용하는 코드도 비슷하다. Random이 생성한 값에 따라 실행 결과가 달라질 수 있다. 이렇게 테스트를 실행하는 시점에 따라 테스트 결과가 달라진다면 그 테스트는 믿을 수 없게 된다.

역할이 섞여 있는 코드

위 '실행 시점에 따라 달라지는 결과'의 코드에서 또 다른 문제는 포인트 계산 로직만 테스트하기 어렵다는 점이다.
포인트 계산 결과를 테스트하려면 SubscriptionDao와 ProductDao에 대한 대역을 구성해야 한다. 사실 포인트 계산 자체는 이 두 DAO와 상관이 없다.
포인트 계산에 필요한 것은 Subscription과 Product이다. 하지만 포인트 계산만 테스트할 수 없다.
SubscriptionDao와 ProductDao의 대역을 알맞게 설정해야만 포인트 계산도 가능해진다.

그 외 테스트가 어려운 코드

  • 메서드 중간에 소켓 통신 코드가 포함되어 있다.
  • 콘솔에서 입력을 받거나 결과를 콘솔에 출력한다.
  • 테스트 대상이 사용하는 의존 대상 클래스나 메서드가 final이다. 이 경우 대역으로 대체가 어려울 수 있다.
  • 테스트 대상의 소스를 소유하고 있지 않아 수정이 어렵다.

소켓 통신이나 HTTP 통신은 실제를 대체할 서버를 로컬에 띄워서 처리할 수 있다. 서버 수준에서 대역을 사용한다고 생각하면 된다. HTTP 서버를 대역으로 대신하는 예는 다음 글에서 소개한다.

테스트 가능한 설계

앞서 살펴본 코드의 테스트가 어려운 주된 이유는 의존하는 코드를 교체할 수 있는 수단이 없기 때문이다. 상황에 따라 알맞은 방법을 적용하면 의존 코드를 교체할 수 있게 만들 수 있다.

하드 코딩된 상수를 생성자나 메서드 파라미터로 받기

하드 코딩된 경로가 테스트가 어려운 이유는 테스트 환경에 따라 경로를 다르게 줄 수 있는 수단이 없기 때문이다. 하드 코딩된 상수 때문에 테스트가 힘들다면 해당 상수를 교체할 수 있는 기능을 추가하면 된다. 쉬운 방법은 생성자나 세터를 이용해서 경로를 전달받는 것이다.

// 세터를 이용해서 값을 교체 가능하게 함으로써 테스트가 쉬워짐
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 폴더가 테스트 용도의 파일을 저장하기에 적당한 위치이다.

의존 대상을 주입 받기

의존 대상은 주입 받을 수 있는 수단을 제공해서 교체할 수 있도록 한다.
생성자나 세터를 통해 의존 대상을 교체할 수 있게 되면 실제 구현 대신에 대역을 사용할 수 있어 테스트를 보다 원활하게 작성할 수 있다.

테스트하고 싶은 코드를 분리하기

위 UserPointCalculator 클래스에서 포인트 계산 기능만 테스트하려면 SubscriptionDao과 ProductDao에 대한 대역 또는 실제 구현이 필요하고 LocalDate에 대한 값이 필요했다.
테스트하고 싶은 코드는 포인트 계산인데 나머지 코드가 올바르게 동작해야 비로소 포인트 계산에 대한 테스트가 가능하다.

이렇게 기능의 일부만 테스트하고 싶다면 해당 코드를 별도 기능으로 분리해서 테스트를 진행할 수 있다. 예를 들어 아래와 같이 포인트를 계산하는 코드를 별도 클래스로 분리할 수 있다.

public class PointRule {

    public int calculatePoint(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 Times {
    public LocalDate today() {
        return LocalDate.now();
    }
}

테스트 코드는 Times 대역을 이용해서 원하는 상황을 쉽게 구성할 수 있다.

외부 라이브러리는 직접 사용하지 말고 감싸서 사용하기

테스트 대상이 사용하는 외부 라이브러리를 쉽게 대체할 수 없는 경우도 있다. 외부 라이브러리가 정적 메서드를 제공한다면 대체할 수 없다.

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() 메서드는 정적 메서드이기 때문에 대역으로 대체하기 어렵다.

이렇게 대역으로 대체하기 어려운 외부 라이브러리가 있다면 외부 라이브러리를 직접 사용하지 말고 외부 라이브러리와 연동하기 위한 타입을 따로 만든다.
그리고 테스트 대상은 이렇게 분리한 타입을 사용하게 바꾼다. 테스트 대상 코드는 새로 분리한 타입을 사용함으로써 외부 연동이 필요한 기능을 쉽게 대역으로 대체할 수 있게 된다.

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);
    ... 생략
}

AuthService를 대역으로 대체할 수 있게 되었으므로 인증 성공 상황과 실패 상황에 대해 LoginService가 올바르게 동작하는지 검증하는 테스트 코드를 만들 수 있다.

의존하는 대상이 Final 클래스이거나 의존 대상의 호출 메서드가 final이어서 대역으로 재정의할 수 없는 경우에도 동일한 기법을 사용해서 테스트 가능하게 만들 수 있다!

0개의 댓글