Search

11장 단위 테스트 안티 패턴

작성자
진행일
2023/01/16
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대 요소를 사용해 그 사례들을 분석했다.
이 책의 모든 아이디어와 지침을 한 번에 적용하기 시작하는 것은 부담스러울 수 있다. 그리고 상황이 뚜렷하지 않은 독자도 있을 수 있다. 저자의 블로그에서 다른 사람의 코드 리뷰(단위 테스트와 코드 디자인에 관련된)와 질문 및 답변을 볼 수 있으며, 직접 질문할 수도 있다.
또한 온라인 과정에 관심이 있다면 이 책에서 설명한 모든 원칙을 적용해 처음부터 애플리케이션을 만드는 방법을 배울 수 있다.
트위터로 언제든지 연락할 수 있으니 독자들의 의견을 언제든지 기다리겠다!