11장에서 다루는 내용
- 비공개 메서드 단위 테스트
- 단위 테스트를 하기 위한 비공개 메서드 노출
- 테스트로 유출된 도메인 지식
- 구체 클래스 목 처리
•
11장에서는 책의 앞부분과 내용이 전혀 달라 따로 보는 편이 좋을 정도로 관련성이 없는 주제를 모았다.
11.1비공개 메서드 단위 테스트
•
비공개 메서드를 어떻게 테스트 하는가? → 전혀 하지 말아야 한다.
11.1.1 비공개 메서드와 테스트 취약성
•
단위 테스트를 하기위해 비공개 메서드를 노출하는 경우에는 5장에서 다룬 기본 원칙 중 하나인 ‘식별할 수 있는 동작만 테스트 하는 것’을 위반한다.
◦
비공개 메서드를 노출하면 테스트가 구현 세부 사항과 결합되어 리팩터링 내성이 떨어진다.
◦
비공개 메서드를 직접 테스트하는 대신 포괄적인 식별할 수 있는 동작으로서 간접적으로 테스트하는 것이 좋다.
11.1.2 비공개 메서드와 불필요한 커버리지
•
비공개 메서드가 너무 복잡해서 식별할 수 있는 동작으로 간접적으로 테스트하기에 충분히 커버리지를 얻을 수 없는 경우 → 식별할 수 있는 동작에 이미 합리적인 테스트 커버리지가 있다고 가정하면 두 가지 문제가 발생할 수 있다.
◦
테스트에서 벗어난 코드가 어디에도 사용되지 않는다면 리팩터링 후 남은 죽은 코드 → 삭제 필요
◦
추상화가 누락돼 있다. 비공개 메서드가 너무 복잡하면 별도의 클래스로 도출해야 하는 추상화가 누락됐다는 징후
// 예제 11.1
public class Order {
private Customer _customer;
private List<Product> _products;
public String generateDescription() {
return "Customer name : " + _customer.name +
"total number of products : " + _products.size() +
"total price : " + getPrice();
}
private BigDecimal getPrice() {
BigDecimal basePrice = /* _products 기반한 계산 */
BigDecimal discounts = /* _customer 기반한 계산 */
BigDecimal taxes = /* _products 기반한 계산 */
return basePrice.subtract(discounts).add(taxes);
}
}
Java
복사
▪
generateDescription() 메서드는 매우 간단하며, 주문에 대한 일반적인 설명을 반환
▪
getPrice() 메서드는 훨씬 더 복잡한 비공개 메서드 → 중요한 비즈니스 로직이 있기 때문에 테스트를 철저히 해야한다.
▪
이 로직은 추상화가 누락됐다. getPrice() 메서드를 노출하기보단 다음과 같이 추상화를 통해 별도의 클래스로 도출해서 명시적으로 작성하는 것이 좋다.
// 예제 11.2
public class Order {
private Customer _customer;
private List<Product> _products;
public String generateDescription() {
PriceCalculator calc = new PriceCalculator();
return "Customer name : " + _customer.name +
"total number of products : " + _products.size() +
"total price : " + calc.calculator(_customer, _products);
}
}
public class PriceCalculator {
public BigDecimal calculator(Customer customer, List<Product> products) {
BigDecimal basePrice = /* _products 기반한 계산 */
BigDecimal discounts = /* _customer 기반한 계산 */
BigDecimal taxes = /* _products 기반한 계산 */
return basePrice.subtract(discounts).add(taxes);
}
}
Java
복사
▪
이렇게 되면 Order 클래스와 별개로 PriceCalculator를 테스트할 수 있다.
11.1.3 비공개 메서드 테스트가 타당한 경우
•
비공개 메서드를 절대 테스트하지 말라는 규칙에도 예외가 있다. → 5장에서 살펴본 코드의 공개 여부와 목적 간의 관계를 다시 봐야한다.
표 11.1 코드의 공개 여부와 목적의 관계, 구현 세부 사항을 공개하지 말라.
•
5장에서 살펴봤듯이, 식별할 수 있는 동작을 공개로 하고 구현 세부 사항을 비공개로 하면 API가 잘 설계됐다고 할 수 있다.
◦
반면에 구현 세부 사항이 유출되면 코드 캡슐화를 해친다.
•
표에서 식별할 수 있는 동작과 비공개 메서드가 만나는 부분은 ‘해당 없음’으로 돼있다.
◦
메서드가 식별할 수 있는 동작이 되려면 클라이언트 코드에서 사용돼야 하므로 해당 메서드가 비공개인 경우엔 불가능하다.
◦
메서드가 비공개이면서 식별할 수 있는 동작인 경우는 드물다.
비공개 메서드를 테스트하는 것 자체는 나쁘지 않다. 비공개 메서드가 구현 세부 사항의 프록시에 해당하므로 나쁜 것이다.
•
예시) 신용 조회를 관리하는 시스템
◦
하루에 한 번 DB에 직접 대량으로 새로운 조회를 로드한다. 그 조회를 하나씩 검토하고 승인 여부를 결정한다.
// 예제 11.3
public class Inquiry {
public boolean isApproved;
public LocalDateTime timeApproved;
private Inquiry(boolean isApproved, LocalDateTime timeApproved) throws Exception {
if (isApproved && timeApproved == null) {
throw new Exception();
}
this.isApproved = isApproved;
this.timeApproved = timeApproved;
}
public void approve(LocalDateTime now) {
if (isApproved) {
return;
}
isApproved = true;
timeApproved = now;
}
}
Java
복사
◦
ORM(객체 관계 매핑) 라이브러리에 의해 데이터베이스에서 클래스가 복원되기 때문에 공개 생성자가 필요하지 않다.(ORM은 공개 생성자가 필요하지 않으며, 비공개 생성자로 잘 작동할 수 있다.)
•
객체를 인스턴스화 할 수 없다는 점을 고려해 Inquiry 클래스를 어떻게 테스트 할까?
◦
승인 로직은 중요하므로 단위 테스트를 거쳐야 한다.
◦
다른 한편으로 생성자를 공개하는 것은 비공개 메서드를 노출하지 않는 규칙을 위반하게 된다.
◦
Inquiry 생성자는 비공개이면서 식별할 수 있는 동작인 메서드의 예다.
◦
Inquiry 생성자를 공개한다고 해서 테스트가 쉽게 깨지지는 않는다.
→ 생성자가 캡슐화를 지키는 데 필요한 전제 조건이 모두 포함돼 있는지 확인하라.
(예제 11.3에서 전제 조건은 승인된 모든 조회에 승인 시간이 있도록 요구하는 것)
•
클래스의 공개 API 노출 영역을 최소화하려면 테스트에서 리플렉션을 통해 Inquiry를 인스턴스화할 수 있다.
11.2 비공개 상태 노출
•
또 다른 일반적인 안티 패턴 → 단위 테스트 목적으로만 비공개 상태를 노출하는 것.
◦
비공개로 지켜야 하는 상태를 노출하지 말고 식별할 수 있는 동작만 테스트 하라는 비공개 메서드 지침과 같다.
// 예제 11.4
public class Customer {
private CustomerStatus _status = CustomerStatus.REGULAR; // 비공개 상태
public void promote() {
_status = CustomerStatus.PREFERRED;
}
public double getDiscount() {
return _status == CustomerStatus.PREFERRED ? 0.05 : 0;
}
}
public enum CustomerStatus {
REGULAR,
PREFERRED,
}
Java
복사
◦
고객은 각각 Regular 상태로 생성된 후에 Preferred로 업그레이드 할 수 있으며, 업그레이드하면 모든 항목에 대해 5% 할인을 받는다.
◦
promote() 메서드를 어떻게 테스트 하겠는가?
▪
이 메서드의 부작용(Side Effect)은 _status 필드의 변경이지만, 필드는 비공개이므로 테스트할 수 없다.
▪
해결책은 해당 필드를 공개하는 것이지만 이는 안티 패턴일 것이다. 테스트는 제품 코드와 정확히 같은 방식으로 테스트 대상 시스템(SUT)과 상호 작용해야 하며, 특별한 권한이 있어서는 안된다.
▪
_status 필드는 제품 코드에 숨어있으므로 SUT의 식별할 수 있는 동작이 아니다. → 공개로 바꾸면 테스트가 구현 세부 사항에 결합된다.
◦
그렇다면 promote를 어떻게 테스트할까? → 제품 코드가 이 클래스를 어떻게 사용하는지를 대신 살펴보는 것이다.
▪
예제에서 제품 코드는 고객의 상태를 신경 쓰지 않는다.
▪
제품 코드가 관심을 갖는 정보는 승격 후 고객이 받는 할인뿐이다. 이것이 테스트에서 확인해야 할 사항이다.
•
새로 생성된 고객은 할인이 없음
•
업그레이드 시 5% 할인율 적용
◦
나중에 제품 코드가 고객 상태 필드를 사용하기 시작하면 공식적으로 SUT의 식별할 수 있는 동작이 되기 때문에 테스트에서 해당 필드를 결합할 수도 있다.
테스트 유의성을 위해 공개 API 노출 영역을 넓히는 것은 좋지 않은 관습이다.
11.3 테스트로 유출된 도메인 지식
•
도메인 지식을 테스트로 유출하는 것은 또 하나의 흔한 안티 패턴이며, 보통 복잡한 알고리즘을 다루는 테스트에서 일어난다.
// 예제 11.5
public class Calculator {
public static int add(int value1, int value2) {
return value1 + value2;
}
}
public class CalculatorTests {
@Test
@DisplayName("알고리즘 구현 유출")
public void adding_two_numbers() {
int value1 = 1;
int value2 = 3;
int expected = value1 + value2; // <- 유출
int actual = Calculator.add(value1, value2);
assertEquals(expected, actual);
}
}
// 예제 11.6 : 추가 비용 없이 @MethodSource를 통해 테스트 사례 추가
@Test
@DisplayName("같은 테스트의 매개변수화 버전")
@MethodSource("numberData")
public void adding_rwo_numbers(int value1, int value2) {
int expected = value1 + value2; // <- 유출
int actual = Calculator.add(value1, value2);
assertEquals(expected, actual);
}
private static Stream<Arguments> numberData() {
return Stream.of(
Arguments.of(1, 3),
Arguments.of(11, 33),
Arguments.of(100, 500)
);
}
Java
복사
◦
예제 11.5와 11.6은 괜찮아 보이지만, 사실은 안티 패턴의 예이다. 해당 테스트는 제품 코드에서 알고리즘 구현을 복사했다.
▪
물론 큰 문제는 아닌 것처럼 보인다. → 결국 한 줄짜리다.
▪
그러나 이는 예제가 단순하기 때문일 뿐이지 복잡한 알고리즘을 다루는 테스트를 저자가 보았을 때는 준비 부분(Arrange)에 해당 알고리즘을 다시 구현, 제품 코드에서 복사-붙여넣기를 했을 뿐이었다.
▪
이러한 테스트는 구현 세부 사항과 결합되는 또 다른 예 이기도 하다.
◦
그렇다면 어떻게 알고리즘을 올바르게 테스트할 수 있는가?
▪
테스트를 작성할 때 특정 구현을 암시하지 말라.
▪
다음 예제와 같이 결과를 테스트에 하드 코딩한다.
// 예제 11.7
@Test
@DisplayName("도메인 지식이 없는 테스트")
@MethodSource("numberDataAndExpected")
public void adding_two_numbers(int value1, int value2, int expected) {
int actual = Calculator.add(value1, value2);
assertEquals(expected, actual);
}
private static Stream<Arguments> numberDataAndExpected() {
return Stream.of(
Arguments.of(1, 3, 4),
Arguments.of(11, 33, 44),
Arguments.of(100, 500, 600)
);
}
Java
복사
▪
처음에는 직관적이지 않아 보일 수 있지만, 단위 테스트에서는 예상 결과를 하드코딩하는 것이 좋다.
11.4 코드 오염
•
다음으로 알아볼 안티 패턴은 코드 오염이다.
코드 오염은 테스트에만 필요한 제품 코드를 추가하는 것이다.
•
코드 오염은 종종 다양한 유형의 스위치 형태를 취한다. 로거를 예로 들어보자.
// 예제 11.8
public class Logger {
private final boolean _isTestEnvironment;
public Logger(boolean isTestEnvironment) { // <- 스위치
this._isTestEnvironment = isTestEnvironment;
}
public void log(String text) {
if (_isTestEnvironment) {
return;
}
/* text에 대한 로깅 */
}
}
public class Controller {
public void someMethod(Logger logger) {
logger.log("SomeMethod 호출");
}
}
Java
복사
◦
이 예제의 Logger에는 클래스가 운영 환경에서 실행되는지 여부를 나타내는 생성자 매개변수가 있다.
▪
운영 환경에서 실행되면 로거는 메시지를 파일에 기록하고, 그렇지 않으면 아무것도 하지 않는다.
▪
다음과 같이 테스트 실행중에 로거를 비활성화할 수 있다.
// 예제 11.9
@Test
@DisplayName("스위치를 사용한 테스트")
public void some_test() {
Logger logger = new Logger(true);
Controller sut = new Controller();
sut.someMethod(logger);
// 검증
}
Java
복사
▪
코드 오염의 문제는 테스트 코드와 제품 코드가 혼재돼 유지비가 증가하는 것이다. 이러한 안티 패턴을 방지하려면 테스트 코드를 제품 코드베이스와 분리해야 한다.
▪
ILogger 인터페이스를 도입해 두 가지 구현을 생성하라. 하나는 운영을 위한 진짜 구현체이고, 하나는 테스트를 목적으로 한 가짜 구현체다.
// 예제 11.10
public interface ILogger {
void log(String text);
}
public class Logger implements ILogger{
@Override
public void log(final String text) {
/* text에 대한 로깅 */
}
}
public class FakeLogger implements ILogger{
@Override
public void log(final String text) {
/* 아무것도 하지 않음 */
}
}
Java
복사
11.5 구체 클래스를 목으로 처리하기
•
지금까지 이 책에서는 인터페이스를 이용해 목을 처리하는 예를 보여줬지만, 구체 클래스를 대신 목으로 처리해서 본래 클래스의 기능 일부를 보존할 수 있는 방식도 있다.
◦
이 대안은 단일 책임 원칙을 위배하는 중대한 단점이 있다. → 안티 패턴이다.
// 예제 11.11
// 반환 값이 두 개인 문법을 간단하게 대체할 방법을 생각하다가
// 코드를 이해하는데 어려움이 없어보여 예제 코드 그대로 사용..
public class StatisticsCalculator {
public (double totalWeight, double totalCost) calculate(int customerId) {
List<DeliveryRecord> records = getDeliveries(customerId);
double totalWeight = records.Sum(x => x.Weight);
double totalCost = records.Sum(x => x.Cost);
return (totalWeight, totalCost);
}
public virtual List<DeliveryRecord> getDeliveries(int customerId) {
/* 프로세스 외부 의존성을 호출해 배달 목록 조회 */
}
}
Java
복사
◦
StatisticsCalculator는 특정 고객에게 배달된 모든 배송물의 무게와 비용 같은 고객 통계를 수집하고 계산한다.
◦
이 클래스는 외부 서비스(getDeliveries)에서 검색한 배달 목록을 기반으로 계산한다.
◦
StatisticsCalculator를 사용하는 컨트롤러가 있다고 가정하자.
// 예제 11.12
public class CustomerController {
private readonly StatisticsCalculator _calculator;
public CustomerController(StatisticsCalculator calculator) {
_calculator = calculator;
}
public string GetStatistics(int customerId) {
(double totalWeight, double totalCost) = _calculator
.Calculate(customerId);
return
$"Total weight delivered: {totalWeight}. " +
$"Total cost: {totalCost}";
}
}
Java
복사
◦
이 컨트롤러를 어떻게 테스트하겠는가?
▪
실제 StatisticsCalculator 인스턴스를 넣을 수는 없다. → 비관리 프로세스 의존성을 참조하기 때문이다. → 비관리 의존성을 스텁으로 대체해야 한다.
▪
동시에 StatisticsCalculator를 완전히 교체하기엔 중요한 계산 기능이 있으므로 그대로 둬야 한다.
◦
이 딜레마를 극복하는 한 가지 방법은 StatisticsCalculator 클래스를 목으로 처리하고, getDeliveries() 메서드만 재정의하는 것이다.
// 예제 11.13
[Fact]
public void Customer_with_no_deliveries() {
// Arrange
var stub = new Mock<StatisticsCalculator> { CallBase = true }; // 명시적으로 재정의하지 않는 한 목이 기초 클래스의 동작을 유지하도록 한다.
stub.Setup(x => x.GetDeliveries(1)) // 반드시 가상으로 돼 있어야 함.
.Returns(new List<DeliveryRecord>());
var sut = new CustomerController(stub.Object);
// Act
string result = sut.GetStatistics(1);
// Assert
Assert.Equal("Total weight delivered: 0. Total cost: 0", result);
}
Java
복사
◦
이 방식을 사용하면 클래스의 일부만 대체하고 나머지는 그대로 유지할 수 있다.
→ 앞서 말했듯이 이는 안티 패턴이다.
•
StatisticsCalculator에는 비관리 의존성과 통신하는 책임과 통계를 계산하는 책임이 서로 관련이 없음에도 결합돼 있다.
◦
예제 11.11을 다시 보면 calculate() 메서드에는 도메인 로직이 있다.
◦
getDeliveries()는 해당 로직에 대한 입력을 수집한다.
◦
StatisticsCalculator를 목으로 처리하는 대신 다음과 같이 클래스를 둘로 나눈다.
// 예제 11.14
public interface IDeliveryGateway {
List<DeliveryRecord> GetDeliveries(int customerId);
}
public class DeliveryGateway : IDeliveryGateway {
public List<DeliveryRecord> GetDeliveries(int customerId) {
/* 프로세스 외부 의존성을 호출해 배달 목록 조회 */
}
}
public class StatisticsCalculator {
public (double totalWeight, double totalCost) Calculate(List<DeliveryRecord> records) {
double totalWeight = records.Sum(x => x.Weight);
double totalCost = records.Sum(x => x.Cost);
return (totalWeight, totalCost);
}
}
Java
복사
◦
다음 예제는 리팩터링 후의 컨트롤러다.
// 예제 11.15
public class CustomerController {
private readonly StatisticsCalculator2 _calculator;
private readonly IDeliveryGateway _gateway;
public CustomerController2(StatisticsCalculator2 calculator, IDeliveryGateway gateway) {
_calculator = calculator;
_gateway = gateway;
}
public string GetStatistics(int customerId)
{
List<DeliveryRecord> records = _gateway.GetDeliveries(customerId);
(double totalWeight, double totalCost) = _calculator.Calculate(records);
return $"Total weight delivered: {totalWeight}. Total cost: {totalCost}";
}
}
Java
복사
◦
비관리 의존성과 통신하는 책임은 이제 DeliveryGateway로 넘어갔다.
▪
이 게이트웨이 뒤에 인터페이스가 있으므로 구체 클래스 대신 인터페이스를 목에 사용할 수 있다.
11.6 시간 처리하기
•
많은 애플리케이션 기능에서 현재 날짜와 시간에 대한 접근이 필요하다.
◦
그러나 시간에 따라 달라지는 기능을 테스트하면 거짓 양성이 발생할 수 있다.
◦
실행 단계의 시간 검증이 검증 단계의 시간과 다를 수 있다.
◦
이 의존성을 안정화하는 데는 세 가지 방법이 있는데 그중 하나는 안티 패턴이고, 나머지 두 가지 중에 바람직한 방법이 있다.
11.6.1 앰비언트 컨텍스트로서의 시간
•
첫 번째 방법은 앰비언트 컨텍스트 패턴으로 8장의 로거 테스트를 다룬 절에서 이 패턴을 이미 살펴 봤다.
◦
시간 컨텍스트에서 앰비언트 컨텍스트는 프레임워크의 내장 LocalDataTime.now() 대신 다음과 같이 코드에서 사용할 수 있는 사용자 정의 클래스에 해당한다.
// 예제 11.16
public static class DateTimeServer {
private static Func<DateTime> _func;
public static DateTime Now => _func();
public static void Init(Func<DateTime> func) {
_func = func;
}
}
/*
DateTimeServer.Init(() => DateTime.UtcNow); // 운영 환경 초기화 코드
DateTimeServer.Init(() => new DateTime(2016, 5, 3)); // 단위 테스트 환경 초기화 코드
*/
Java
복사
◦
로거 기능과 마찬가지로 시간을 앰비언트 컨텍스트로 사용하는 것도 안티 패턴이다.
▪
앰비언트 컨텍스트는 제품 코드를 오염시키고 테스트를 더 어렵게 한다.
▪
또한, 정적 필드는 테스트 간에 공유하는 의존성을 도입해 해당 테스트를 통합 테스트 영역으로 전환 시킨다.
11.6.2 명시적 의존성으로서의 시간
•
두 번째 방법으로(더 나은 방법) 다음 예제와 같이 서비스 또는 일반 값으로 시간 의존성을 명시적으로 주입해주는 것이 있다.
// 예제 11.17
public interface IDateTimeServer {
DateTime Now { get; }
}
public class DateTimeServer : IDateTimeServer {
public DateTime Now => DateTime.Now;
}
public class InquiryController {
private readonly DateTimeServer _dateTimeServer;
// 시간을 서비스로 주입
public InquiryController(DateTimeServer dateTimeServer) {
_dateTimeServer = dateTimeServer;
}
public void ApproveInquiry(int id) {
Inquiry inquiry = GetById(id);
// 시간을 일반 값으로 주입
inquiry.Approve(_dateTimeServer.Now);
SaveInquiry(inquiry);
}
private void SaveInquiry(Inquiry inquiry){}
private Inquiry GetById(int id) {
return null;
}
}
Java
복사
◦
두 가지 옵션 중에서 시간을 서비스로 주입하는 것보다는 값으로 주입하는 것이 더 낫다.
▪
제품 코드에서 일반 값으로 작업하는 것이 더 쉽고, 테스트에서 해당 값을 스텁으로 처리하기도 더 쉽다.
결론
•
11장에서는 꽤 유명한 실제 단위 테스트 사용 사례를 살펴봤고, 좋은 단위 테스트의 4대 요소를 사용해 그 사례들을 분석했다.
•
•
트위터로 언제든지 연락할 수 있으니 독자들의 의견을 언제든지 기다리겠다!