본문으로 건너뛰기

Styles-Of-Unit-Testing

1 단위 테스트 스타일

1.1 단위 테스트의 세 가지 스타일

  • 단위 테스트에는 세 가지 스타일이 있다
    • 출력 기반 테스트
    • 상태 기반 테스트
    • 통신 기반 테스트
  • 출력 기반 테스트, 상태 기반 테스트, 통신 기반 테스트 순으로 테스트 품질이 좋다
  • 출력 기반 테스트가 가장 품질이 좋지만 아무데서나 사용할 수 없으며 순수 함수 방식으로 작성된 코드에만 적용된다
    • 그러나 걱정하지 마라 출력 기반 스타일로 변환하는 데 도움이 되는 기법이 있다

1.2 스타일과 단위 테스트 분파

  • 두 분파 모두 출력 기반 테스트를 사용한다
  • 고전파는 통신 기반 테스트보다 상태 기반 테스트를 선호한다
  • 런던파는 상태 기반 테스트보다 통신 기반 테스트를 선호한다

2 출력 기반 스타일

  • SUT에 입력을 넣고 생성되는 출력을 점검하는 방식
  • 출력 기반 스타일은 전역 상태나 내부 상태를 변경하지 않는 코드에만 적용되므로 반환 값만 검증하면 된다
    • 전역 상태나 내부 상태를 변경하지 않는 코드 -> 순수 함수 방식으로 작성된 코드
  • 출력 기반 단위 테스트 스타일을 함수형이라고도 한다
    • 부작용이 없는 코드 선호를 강조하는 프래그래밍 방식인 함수형 프로그래밍에 뿌리를 두고있다

[!NOTE] 순수 함수란? 전역 상태나 내부 상태를 변경하는 부작용이 없는 코드로 작업의 결과는 호출자에게 반환하는 값뿐이다. 오직 입력만이 결과에 영향을 주는 함수를 말한다.

PriceEngine.java

public class PriceEngine {
public double calculateDiscount(List<Product> products) {
double discount = products.size() * 0.01;
return Math.min(discount, 0.2);
}
}
  • PriceEngine 클래스의 calculateDiscount() 메서드를 출력 기반 스타일로 테스트해보자
  • calculateDiscount 메서드는 할인율을 계산한다
    • 상품의 수에 1%를 곱하고 그 결과를 20%로 제한한다
  • calculateDiscount 메서드는 순수 함수다
    • 다른 가변 상태를 참조하지 않고 함수 스스로도 다른 상태를 변경하지 않는다. 즉 부작용이 없다
    • 내부 컬렉션에 상품을 추가하거나 데이터베이스에 저장하지 않는다.
    • 따라서 출력 기반 스타일로 테스트가 가능하다

출력 기반 스타일 테스트

@Test
void discount_of_two_products() {
// Arrange
List<Product> products = Arrays.asList(new Product("A"), new Product("B"));
Order sut = new Order();

// Act
double discount = sut.calculateDiscount(products);

// Assert
assertThat(discount).isEqualTo(0.02);
}
  • calculateDiscount 메서드는 부작용이 없는 순수 함수이기 때문에 검증 구절에서 반환 값만 검증하고 있다.

3 상태 기반 스타일

  • 상태 기반 스타일은 작업이 완료된 후 시스템 상태를 확인하는 것이다
  • 검증할 수 있는 상태의 종류
    • SUT의 상태
    • 협력자의 상태
    • 프로세스 외부 의존성의 상태(데이터베이스, 파일 시스템 등)

Order.java

@Getter
public class Order {

private List<Product> products = new ArrayList<>();

public void addProduct(Product product) {
products.add(product);
}
}
  • Order 클래스의 addProduct 메서드는 순수 함수가 아니므로 출력 기반 스타일 테스트코드를 작성할 수 없다
  • 메서드 외부 가변 상태(products)를 변경하기 때문에 순수 함수가 아니다
  • 따라서 상태 기반 테스트를 작성해야 한다

상태 기반 스타일 테스트

@Test
void adding_a_product_to_an_order() {
// Arrange
Product product = new Product("A");
Order sut = new Order();

// Act
sut.addProduct(product);

// Assert
assertThat(sut.getProducts().size()).isEqualTo(1);
assertThat(sut.getProducts().get(0)).isEqualTo(product);
}
  • 상태 기반 스타일 작업 완료후 시스템 상태를 확인한다

4 통신 기반 스타일

  • SUT의 협력자를 목으로 대체하고 SUT가 협력자를 올바르게 호출하는지 검증한다

통신 기반 스타일 테스트

@Test
void sending_a_greetings_email() {
// Arrange
EmailGateWay emailGateWay = mock(EmailGateWay.class);
EmailController sut = new EmailController(emailGateWay);

// Act
sut.greetUser("user@email.com");

// Assert
verify(emailGateWay, times(1)).sendGreetingsEmail("user@email.com");
}
  • EmailController의 협력자인 EmailGateWay를 목으로 대체
  • 검증구절에서 EmailGateWay의 sendGreetingsEmail 메서드를 정확히 한번 호출했는지 검증한다

5 단위 테스트 스타일 비교

  • 출력, 상태, 통신 기반 스타일 각각을 좋은 단위 테스트의 4대 요소로 평가해보자
  • 좋은 단위 테스트의 4대 요소는 Good-Unit-Test.md 참고

5.1 회귀 방지 지표로 스타일 비교

  • 회귀 방지 지표는 특정 스타일에 따라 달라지지 않는다
  • 회귀 방지는 다음 세 가지 특성으로 결정되기 때문이다
    • 테스트 중에 실행되는 코드의 양
    • 코드 복잡도
    • 도메인 유의성

5.2 빠른 피드백 지표와 스타일 비교

  • 테스트 스타일과 테스트 피드백 속도 사이에는 상관관계가 거의 없다
  • 테스트가 프로세스 외부 의존성과 떨어져 단위 테스트 영역에 있는 한 모든 스타일은 테스트 실행 속도가 거의 동일하다

5.3 리팩터링 내성 지표로 스타일 비교

  • 리팩터링 내성은 리팩터링 중에 발생하는 거짓 양성(허위 경보) 수에 대한 척도다
  • 거짓 양성의 결과는 식별할 수 있는 동작이 아니라 코드의 구현 세부 사항에 결합된 테스트의 결과다
  • 리팩터링 내성을 지키려면 통신 기반 테스트를 사용할 때 더 신중해야 한다

출력 기반 테스트

  • 출력 기반 테스트는 SUT의 메서드에만 결합되므로 거짓 양성 방지(리팩터링 내성)가 가장 우수하다
  • 출력 기반 테스트가 구현 세부 사항에 결합하는 경우는 SUT의 메서드가 구현 세부 사항일 때뿐이다

상태 기반 테스트

  • 상태 기반 테스트는 일반적으로 거짓 양성이 되기 쉽다(리팩터링 내성이 없다)
  • 상태 기반 테스트는 테스트 대상 메서드 외에도 클래스 상태와 함께 작동한다
  • 테스트가 클래스의 상태를 안다는 것은 결합도가 높은 것을 의미하며 그 결과 테스트가 구현 세부 사항에 얽매일 가능성이 커진다

통신 기반 테스트

  • 통신 기반 테스트는 허위 경보에 가장 취약하다
  • 스텁과의 상호 작용을 검증하는 테스트는 대부분 깨지기 쉽다
    • 이러한 상호 작용은 검증해서는 안 된다
  • 애플리케이션 경계를 넘는 상호 작용을 확인하고 해당 상호 작용의 부작용이 외부 환경에 보이는 경우에만 목이 괜찮다
    • 애플리케이션 경계를 넘는 상호 작용 -> 시스템간 통신

5.4 유지 보수성 지표로 비교

  • 유지 보수성 지표는 단위 테스트 스타일과 밀접한 관련이 있다
  • 그러나 리팩터링 내성과 달리 완화할 수 있는 방법이 많지 않다
  • 유비 보수성 측정 지표
    • 테스트를 이해하기 얼마나 어려운가?(테스트 크기)
    • 테스트를 실행하기 얼마나 어려운가?(테스트에 직접적으로 관련 있는 프로세스 외부 의존성 개수)

5.4.1 출력 기반 스타일

  • 출력 기반 테스트는 거의 항상 짧고 간결하므로 유지 보수가 쉽다
  • 출력 기반 테스트의 기반 코드는 전역 상태나 내부 상태를 변경할 리 없으므로 프로세스 의존성을 다루지 않는다
  • 따라서 테스트를 이해하기 쉽고 또 실행하기도 쉬워 유지 보수성 측면에서 가장 좋은 스타일이다

5.4.2 상태 기반 스타일

  • 일반적으로 출력 기반 테스트보다 유지 보수가 쉽지 않다
  • 상태 검증은 종종 출력 검증보다 더 많은 공간을 차지하기 때문이다
    • AAA 패턴의 Assert 구절이 길어진다
  • 상태 기반 스타일 검증 구절 단축하기
    • 헬퍼 메서드로 대부분의 검증 코드를 숨기고 테스트를 단축할 수 있지만 헬퍼 메서드를 작성하고 유지하는데 노력이 필요하다
    • 검증 대상 클래스의 동등 멤버를 정의해 비교하기
    • 이 두 가지 기법은 가끔만 적용할 수 있다

검증부가 긴 테스트 예시

@Test
void adding_a_comment_to_an_article_long() {
Article sut = new Article();
String text = "text";
String author = "young three";
LocalDate now = LocalDate.of(2021, 4, 12);

sut.addComment(text, author, now);

assertThat(sut.getComments().size()).isEqualTo(1);
assertThat(sut.getComments().get(0).getText()).isEqualTo(text);
assertThat(sut.getComments().get(0).getAuthor()).isEqualTo(author);
assertThat(sut.getComments().get(0).getDate()).isEqualTo(now);
}
  • 앞서 상태 기반 스타일은 검증 부분이 길어질 수 있다고 했다 아래 검증부가 긴 테스트를 단축시켜보자

상태 기반 스타일 검증 대상 클래스의 동등 멤버를 정의해 비교하기 예시

@Test
void adding_a_comment_to_an_article() {
Article sut = new Article();
Comment comment = new Comment("text", "young three", LocalDate.of(2021, 4, 12));

sut.addComment(comment.getText(), comment.getAuthor(), comment.getDate());
assertThat(sut.getComments().contains(comment));
}
  • 상태 기반 테스트의 검증 구절을 단축하는 방법으로 아래의 코드와 같이 검증 대상 클래스의 동등 멤버를 정의해 비교하는 것이 있다
  • 검증 구절에서 댓글을 개별 속성으로 지정하지 않고 동등 멤버로 검증하고 있다
  • 본질적으로 클래스가 값에 해당하고 값 객체로 변환할 수 있을 때만 동등 멤버를 정의해 비교할 수 있다
    • 값 객체는 인스턴스를 참조가 아니라 값으로 비교하는 객체를 말한다
  • 클래스가 값에 해당하지 않으면 코드 오염으로 이어지며 이는 단위 테스트 안티 패턴이다

5.4.3 통신 기반 테스트

  • 세 가지 스타일중 유지 보수성이 가장 낮다
  • 테스트 대역과 상호 작용 검증을 설정해야 하며 이는 공간을 많이 차지한다
  • 목이 사슬 형태로 있을 때 테스트는 더 커지고 유지 보수하기가 어려워진다

5.5 결론

  • 출력 기반 테스트가 가장 좋다 이 스타일은 구현 세부 사항과 거의 결합되지 않으므로 리팩터링 내성을 유지하고자 주의를 많이 기울일 필요가 없다
  • 하지만 안타깝게도 출력 기반 스타일은 함수형으로 작성된 코드에만 적용할 수 있다
    • 대부분의 객체지향 프로그래밍 언어에는 해당하지 않는다
    • 그래도 테스트를 출력 기반 스타일로 변경하는 기법이 있다
출력 기반상태 기반통신 기반
리팩터링 내성을 위한 노력낮음중간중간
유지비낮음중간높음

6 함수형 아키텍처 이해

  • 테스트를 출력 기반 스타일로 변경하는 기법을 알아보기 전에 약간의 기초 지식이 필요하다 함수형 아키텍처를 먼저 이해해보자

6.1 함수형 프로그래밍

  • 함수형 프로그래밍수학적 함수를 사용한 프로그래밍이다

6.1.1 수학적 함수(순수 함수)

  • 숨은 입출력이 없는 함수다
  • 호출 횟수에 상관없이 주어진 입력에 대해 동일한 출력을 생성한다
  • 부작용이 없다
    • 함수 외부에서 관찰 가능한 상태 변화가 없는 것

순수 함수 예시

public class Order {
public double calculateDiscount(List<Product> products) {
double discount = products.size() * 0.01;
return Math.min(discount, 0.2);
}
}
  • Order 클래스의 calculateDiscount 메서드는 동일한 입력에 대해 항상 같은 결과를 반환한다
    • 결과에 영향을 주는 것은 오직 배열의 길이
  • calculateDiscount 부작용이 없다
    • double discount = products.size() * 0.01;
    • 로컬 변수의 상태를 변경하지만 이는 메서드 외부에서는 보이지 않다
  • 따라서 calculateDiscount 메서드는 수학적 함수가 된다

6.1.2 숨은 입출력

  • 숨은 입출력의 유형으로 부작용, 예외, 내외부 상태에 대한 참조가 있다

부작용

  • 부작용은 메서드 정의의 반환 값으로 표시되지 않은 출력을 의미한다.
  • 클래스 인스턴스의 상태를 변경하거나 디스크의 파일을 업데이트하는 것 등을 의미합니다.

예외

  • 메서드가 예외를 던지면 프로그램 흐름에 메서드 시그니처에 설정된 계약을 우회하는 경로를 만든다

내외부 상태에 대한 참조

  • 메서드 정의 표시되지 않은 입력을 참조하는 것으로 대표적으로 아래와 같다
    • 데이터베이스에서 데이터를 질의해서 가져오기
    • LocalDateTime.now();를 사용해 현재 날짜와 시간을 가져오기
    • 비공개 가변 필드 참조하기
    • 이들은 모두 메서드 정의에 없는 숨어있는 입력이다

6.1.3 숨은 입출력 판별하기

  • 메서드가 수학적 함수인지 판별하는 가장 좋은 방법은 해당 메서드에 대한 호출을 반환 값으로 대체할 수 있는지 확인하는 것
  • 메서드 호출을 해당 값으로 바꾸는 것을 참조 투명성이라고 한다

예시

public int increment(int x) {
return x + 1;
}
  • 위 메서드는 수학적 함수다
  • 숨은 입출력이 없음
int result1 = increment(4);
int result2 = 5;
  • 따라서 메서드에 대한 호출을 반환 값으로 대체할 수 있으므로 위 두 구문은 서로 동일하다
public class Increment {
private int x;
public int increment() {
x++;
return x;
}
}
  • 반면에 위 메서드는 수학적 함수가 아니다
  • 반환 값이 메서드의 출력을 모두 나타내지 않는다
  • 숨은 출력으로 x 필드의 변경이 있다
  • 부작용은 숨은 출력의 가장 일반적인 유형이다

6.2 함수형 아키텍처

  • 어떠한 부작용도 일으키지 않는 애플리케이션은 만들 수 없다
    • 결국 사용자 정보 업데이트, 장바구니에 새로운 주문 추가 등의 부작용이 필요하다
  • 함수형 아키텍처는 부작용을 다루는 코드를 최소화하면서 순수 함수 방식으로 작성한 코드의 양을 극대화 하는 것

함수형 프로그래밍의 목표

  • 부작용을 완전히 제거하는 것이 아니라 비즈니스 로직을 처리하는 코드와 부작용을 일으키는 코드를 분리하는 것

코드 유형

  • 코드 유형을 구분해서 비즈니스 로직과 부작용을 분리할 수 있다
  • 결정을 내리는 코드
    • 부작용이 필요없어 수학적 함수를 사용해 작성할 수 있다
    • 결정을 내리는 코드를 종종 함수형 코어라고 한다
  • 해당 결정에 따라 작용하는 코드
    • 수학적 함수에 의해 이뤄진 모든 결정을 데이터베이스의 변경이나 메시지 버스로 전송된 메시지와 같이 가시적인 부분으로 변환
    • 해당 결정에 따라 작용하는 코드는 가변 셸이라고 한다

협력 방식

  • 가변 셸은 모든 입력을 수집한다.
  • 함수형 코어는 결정을 생성한다.
  • 셸은 결정을 사이드 이펙트로 변환한다.