본문 바로가기
카테고리 없음

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

by compile-this 2024. 9. 11.

 

테스트가 어려운 코드

  • 테스트하기 어려운 사례를 살펴보고, 테스트하기 수월하게 변경하는 과정을 살펴보자.

 

1. 하드 코딩된 경로

[파일 경로가 하드 코딩되어 있는 테스트 대상]

public class PaySync {
    private PayInfoDao payInfoDao = new PayInfoDao();

    public void sync() throws IOException {
        Path path = Paths.get("/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[1])
                    );
                    return payInfo;
                })
                .collect(Collectors.toList());

        payInfos.forEach(pi -> payInfoDao.insert(pi));
    }
}
  • Path path = Paths.get("/data/pay/cp0001.csv") 코드를 보면 파일 경로가 하드코딩되어 있다.
    해당 경로에 파일이 없다면 코드가 잘 동작해도 테스트에 실패하게 된다.

 

2. 의존 객체를 직접 생성

[의존 객체를 직접 생성하는 테스트 대상 코드]

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

    public void sync() throws IOException {
	// ... 생략
        payInfos.forEach(pi -> payInfoDao.insert(pi));
    }
}
  • 위 코드를 테스트하려면 PayInfoDao가 올바르게 동작하는데 필요한 모든 환경(DB, 테이블)을 구성해야 한다.

 

3. 정적 메서드 사용

[정적인 메서드로 인해 테스트하기 어려운 코드]

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의 정적 메서드를 사용것처럼 정적메서드는 대역으로 대체할 수 없기에 테스트하기 어려워진다.

 

 

4. 실행 시점에 따라 달라지는 결과

[LocalDateTime.now()로 인해 실행결과가 매번 달라져 테스트하기 어려운 코드]

public boolean verifyEmail(String email, String code) {
    EmailVerification verification = repository.findByEmail(email);

    if (verification == null || !verification.getCode().equals(code)) {
        return false;
    }

    LocalDateTime now = LocalDateTime.now();
    if (verification.isExpired(now)) {
        return false;
    }

    verification.markAsVerified();

    return true;
}
  • LocalDateTime.now(), Random 등이 테스트 대상에 있으면 테스트 결과가 매번 달라질 수 있다.

 

5. 역할이 섞여 있는 코드

[기능 구현이 섞여 있어 특정한 부분만 테스트하기 어려운 코드]

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;
    }
}
  • 위 코드에서 subscriptionDao, productDao등  '포인트 계산'과  관계없는 로직 등이 섞여 있어서,
    '포인트 계산'에 대한 로직만 테스트하기 어렵다.

 

6. 그 외에 테스트가 어려운 코드

  • 테스트 대상 메서드 중간에 HTTP통신 혹은 소켓 통신 코드가 포함되어 있는 경우
    • 외부 서버에 의존적인 테스트가 될 수 있다.
  • 콘솔에서 입력을 받거나 결과를 콘솔에 출력하는 경우

  • 테스트 대상이 사용하는 의존 클래스나 메서드가 final인 경우
    • override등이 되지않아, 대역을 사용하기 힘들다

 

테스트 가능한 설계

  • 위에서 살펴본 테스트를 어렵게 하는 경우들을 해결해보자.

 

1. 하드 코딩된 경로 -> 하드 코딩된 상수를 생성자, setter나 메서드 파라미터로 받아서 해결

 

[setter메서드를 이용해서 값을 교체 가능하게 함으로써 테스트가 쉬워짐]

public class PaySync {
    private String filePath = "/data/pay/cp0001.csv";
    
    public void setFilePath(String filePath) {
        this.filePath = filePath;
    }

    public void sync() throws IOException {
        Path path = Paths.get(filePath);
        //.. 생략
    }
}
  • 테스트시에 원하는 경로를 setter메서드에 주입하면 된다.

 

[하드 코딩된 경로를 파라미터로 전달받아 테스트 가능하게 변경]

public class PaySync {

    public void sync(String filePath) throws IOException {
        Path path = Paths.get(filePath);
        //.. 생략
    }
}
  • 테스트시에 원하는 경로를 테스트 대상 메서드 파라미터로 넘겨주면 된다.

 

2. 의존 객체를 직접 생성 -> 의존 대상(객체)을 생성자나 세터를 통해 주입받아서 해결

 

[생성자를 통해서 의존 대상을 주입하게 수정해서 테스트 가능하게 함]

public class PaySync {
    private PayInfoDao payInfoDao;
    
    public PaySync(PayInfoDao payInfoDao) {
        this.payInfoDao = payInfoDao;
    }

    public void sync() throws IOException {
	// ... 생략
        payInfos.forEach(pi -> payInfoDao.insert(pi));
    }
}
  • 이렇게 생성자를 통해 주입받게 되면 payInfoDao를 대역으로 교체해서 테스트 대상 메서드를 쉽게 검증할 수 있다.

 

3. 역할이 섞여 있는 코드 -> 테스트하고 싶은 코드를 분리하기

[테스트하고 싶은 코드만 별도로 분리하면 테스트할 수 있다]

public 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 {
    // ... 생략
    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();
        return new PointRule().calculate(s, p, now);
    }
}

 

 

4. 실행 시점에 따라 달라지는 결과 -> 시간이나 임의 값 생성 기능 분리하기

 

[시간을 구하는 기능을 별도로 분리하면 테스트를 하기 수월해진다]

public class Times {
    public LocalDateTime today() {
        return LocalDateTime.now()
    }
}

// --------------------------------------------------------------------

public class AuthService{
    private Times times = new Times();
    
    public void setTimes(Times times) {
        this.times = times;
    }

    public boolean verifyEmail(String email, String code) {
        EmailVerification verification = repository.findByEmail(email);

        if (verification == null || !verification.getCode().equals(code)) {
            return false;
        }

        LocalDateTime now = times.today();
        if (verification.isExpired(now)) {
            return false;
        }

        verification.markAsVerified();

        return true;
    }
}

 

 

5. 정적 메서드 사용 - 직접 사용하지 말고 감싸서 사용하기

[외부 라이브러리를 감싼 클래스]

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;
        }
    }
}

 

 

[대역 사용이 어려운 외부 라이브러리를 직접 사용하지 않게 변경]

public class LoginService {
    private AuthService authService = new AuthService();
    private CustomerRepository customerRepo;

    public LoginService(CustomerRepository customerRepo) {
        this.customerRepo = customerRepo;
    }

    public void setAuthService(AuthService authService) {
        this.authService = authService;
    }

    public LoginResult login(String id, String pw) {
        int resp = authService.authenticate(id, pw);
        if (resp == -1) return LoginResult.badAuthKey();

        if (resp == 1) {
            Customer c = customerRepo.findOne(id);
            return LoginResult.authenticated(c);
        } else {
            return LoginResult.fail(resp);
        }
    }
}
  • 정적메서드를 다루는 타입을 따로 분리해서 클래스로 만들고, 해당 클래스를 대역으로 교체하면 된다.