본문으로 건너뛰기

Mocks-And-Test-Fragility

1 목과 테스트 취약성

  • 테스트에서 목을 사용하는 것은 논란의 여지가 있다
  • 목으로 인해 취약한 테스트, 즉 리팩터링 내성이 부족한 테스트를 초래하는 경우와 목 사용이 바람직한 경우를 살펴보자
  • 그 전에 목과 스텁의 개념에 대해서 알아보자

2 테스트 대역

  • 테스트 대역은 모든 유형의 비운영용 가짜 의존성을 설명하는 포괄적인 용어다
    • 테스트 대역의 주 목적은 테스트를 편리하게 하는 것이다
  • 테스트 대역에는 더미, 스텁, 스파이, 목, 페이크라는 다섯가지가 있다
  • 여러 유형에 겁먹을 수 있지만 실제로 목과 스텁의 두 가지 유형으로 나눌 수 있다
    • 목은 스파이를 포함한다
    • 스텁은 더미와 페이크를 포함한다

2.1 목과 스텁의 차이

  • 목은 외부로 나가는 상호 작용을 모방하고 검사하는데 도움이 된다
  • 스텁은 내부로 들어오는 상호 작용을 모방하는데 도움이 된다

3 목

  • 목은 외부로 나가는 상호 작용을 모방하고 검사하는데 도움이 된다
    • 반대로 스텁은 내부로 들어오는 상호 작용을 모방만 한다
  • 이러한 상호 작용은 SUT가 상태를 변경하기 위한 의존성을 호출하는 것에 해당된다
    • SUT의 의존성 호출로 해당 의존성의 상태를 변경한다
  • 예를 들면 이메일 발송은 SMTP 서버에 부작용(상태 변경)을 초래하는 상호 작용, 즉 외부로 나가는 상호작용 이다
    • 목은 이러한 상호 작용을 모방하는 테스트 대역에 해당된다
  • 목, 스파이는 목에 포함된다

3.1 스파이

  • 스파이는 목과 같은 역할을 하지만 스파이는 수동으로 작성하는 반면 목은 목 프레임워크의 도움을 받아 생성된다

4 스텁

  • 스텁은 내부로 들어오는 상호 작용을 모방하는데 도움이 된다
    • 반대로 목은 목은 외부로 나가는 상호 작용을 모방하고 검사까지 한다
  • 이러한 상호 작용은 SUT가 입력 데이터를 얻기 위한 의존성을 호출하는 것에 해당된다
  • 예를 들면 데이터베이스에서 데이터를 검색하는 것은 내부로 들어오는 상호 작용이다
    • 부작용을 일으키지 않는다
    • 해당 테스트 대역은 스텁이다
  • 스텁, 더미, 페이크는 스텁에 포함된다
    • 셋의 차이는 얼마나 똑똑한지에 있다

4.1 더미

  • 더미는 널 값이나 가짜 문자열과 같이 단순하고 하드코딩된 값이다
  • SUT의 메서드 시그니처를 만족시키기 위해 사용하고 최종 결과를 만드는데 영향을 주지 않는다

4.2 스텁

  • 시나리오마다 다른 값을 반환하게끔 구성할 수 있도록 필요한 것을 다 갖춘 완전한 의존성이다

4.3 페이크

  • 페이크는 대다수의 목적에 부합하는 스텁과 같다
  • 차이점은 생성에 있는데 페이크는 보통 아직 존재하지 않는 의존성을 대체하고자 구현한다

4.4 스텁으로 상호 작용 검증하지 말라

  • 스텁과의 상호 작용을 검증하는 것은 취약한 테스트를 야기하는 안티 패턴이다
  • 따라서 스텁은 SUT 내부로 들어오는 상호 작용을 모방하고 검사하지 않는다
  • 검사를 하지 않는 이유는 SUT에서 스텁으로의 호출은 SUT가 생성하는 최종 결과가 아니기 때문이다
  • 스텁과의 상호 작용을 검증하는 것은 더 이상 블랙박스 테스트가 아니다
    • 스텁의 호출은 최종 결과를 산출하기 위한 수단일 뿐이다
    • 따라서 스텁으로 상호 작용을 검증하면 SUT의 세부 구현 사항과 테스트와의 결합도가 올라가 리팩터링 내성이 없어진다
    • 식별 가능한 동작의 최종 결과를 검증해야지 수단을 검증해서는 안된다
  • 최종 결과가 아닌 사항을 검증하는 이러한 관행을 과잉 명세라고 부른다

5 식별할 수 있는 동작과 구현 세부 사항

  • 스텁으로 상호작용을 검증하는 것은 구현 세부 사항을 검증하는 것이며 이는 테스트와의 강한 결합을 야기한다
    • 테스트와의 강한 결합은 테스트의 리팩터링 내성을 없앤다
    • Good-Unit-Test.md 리팩터링 내성 참조
  • 이러한 강결합을 피하는 방법은 코드가 생성하는 최종 결과를 검증하고 구현 세부 사항과 테스트를 가능한 떨어뜨리는 것 뿐이다
  • 그렇다면 구현 세부 사항은 정확히 무엇이며 식별할 수 있는 동작과 어떻게 다를까?

5.1 식별할 수 있는 동작과 공개 API는 다르다

  • 모든 제품 코드는 2차원으로 분류할 수 있다
    • 공개 API 또는 비공개 API
    • 식별할 수 있는 동작 또는 구현 세부 사항
  • 메소드는 공개 API와 비공개 API 둘 다에 속할 수 없다
  • 마찬가지로 코드는 내부 구현 세부 사항이거나 시스템의 식별할 수 있는 동작이지만 둘 다는 아니다
  • 이상적으로 시스템의 공개 API는 식별할 수 있는 동작과 일치해야 하며 모든 구현 세부 사항은 클라이언트 눈에 보이지 않아야 한다
    • 이러한 시스템을 API 설계가 잘돼 있다라고 한다

공개 API와 비공개 API

  • 대부분 프로그래밍 언어는 코드베이스의 공개 API와 비공개 API를 구별할 수 있는 메커니즘을 제공한다
  • 자바의 경우 접근 제한자로 API를 공개하거나 비공개할 수 있다

식별할 수 있는 동작 또는 구현 세부 사항

  • 코드가 식별할 수 있는 동작인지 여부는 클라이언트가 누구인지, 그리고 목표가 무엇인지에 달려있다
  • 식별할 수 있는 동작과 내부 구현 세부 사항에는 미묘한 차이가 있다
  • 식별할 수 있는 동작이란 클라이언트가 목표를 달성하는데 도움이 되는 연산을 의미한다
    • 연산은 계산을 수행하거나 부작용을 초래하거나 둘 다 하는 메서드이다
  • 구현 세부 사항은 클라이언트가 목표를 달성하는데 직결되지 않는다
  • 따라서 클라이언트의 목표와 직결되지 않는 구현 세부 사항에 해당하는 연산은 노출하지 말자

5.2 구현 세부 사항 유출

  • 좋은 단위 테스트와 잘 설계된 API 사이에는 본질적인 관계가 있다
    • 모든 구현 세부 사항을 비공개로 하면(잘 설계된 API) 테스트가 식별할 수 있는 동작을 검증하는 것 외에는 다른 선택지가 없다
    • 이로 인해 리팩터링 내성도 자동으로 좋아진다
  • 클래스가 구현 세부 사항을 유출하는지 판단하는 데 도움이 되는 유용한 규칙이 있다
  • 단일한 목표를 달성하고자 클래스에서 호출해야하는 연산의 수가 1보다 크면 해당 클래스에서 구현 세부 사항을 유출할 가능성이 있다
  • 이 규칙은 비즈니스 로직이 포함된 대부분의 경우 적용된다

5.2.1 예시

UserV1.java

  • 구현 세부 사항을 유출하는 UserV1
  • 이 클래스에는 사용자 이름이 50자를 초과하면 안 되는 불변성이 있다
  • 불변성은 항상 참이어야 하는 조건이다
public class UserV1 {
private String name;

public UserV1(String name) {
this.name = name;
}

public String normalizeName(String name) {
String result = name.trim();

if (result.length() > 50)
return result.substring(0, 50);

return result;
}

public void changeName(String name) {
this.name = name;
}
}

UserController,java

public class UserController {

public void renameUser(int userId, String newName) {
UserV1 user = getUserFromDatabase(userId);

String normalizedName = user.normalizeName(newName);
user.changeName(normalizedName);

saveUserToDatabase(user);
}

private void saveUserToDatabase(UserV1 user) {
return;
}

private UserV1 getUserFromDatabase(int userId) {
return null;
}
}
  • UserController는 클라이언트로 renameUser 메서드에서 UserV1 클래스를 사용한다
  • renameUser의 메서드의 목표는 사용자 이름을 변경하는 것이다
  • UserV1 클래스의 API는 잘 설계되어 있지 않다. 그 이유는 무엇일까?
  • UserV1 클래스는 두 가지의 공개 API를 가지고 있다
    • normalizeName()
    • changeName()
  • 잘 설계된 API는 식별할 수 있는 동작만 공개하고 구현 세부 사항은 비공개하는 것이다
  • normalizeName() 메서드는 클라이언트의 목표인 이름 변경에 직결되지 않는다
    • 클라이언트가 이 메서드를 호출하는 이유는 User의 불변 속성을 만족시키기 위해서다
    • 따라서 normalizeName() 메서드는 구현 세부 사항으로 비공개 해야 한다

UserV2.java

  • 구현 세부 사항인 normalizeName 메서드를 비공개 했으며 메서드 호출을 클라이언트 코드에 의존하지 않으며 changeName 메서드 내부에서 직접 호출한다
  • UserV2는 식별할 수 있는 동작만 공개하고 구현 세부 사항을 비공개함으로써 잘 설계된 API라고 할 수 있다
public class UserV2 {
private String name;

public UserV2(String name) {
this.name = name;
}

private String normalizeName(String name) {
String result = name.trim();

if (result.length() > 50)
return result.substring(0, 50);

return result;
}

public void changeName(String name) {
this.name = normalizeName(name);
}
}

5.2.2 구현 세부 사항 유출의 조짐

  • 클래스가 구현 세부 사항을 유출하는지 판단하는 데 도움이 되는 유용한 규칙이 있다
  • 단일한 목표를 달성하고자 클래스에서 호출해야하는 연산의 수가 1보다 크면 해당 클래스에서 구현 세부 사항을 유출할 가능성이 있다
    • 이 규칙은 비즈니스 로직이 포함된 대부분의 경우에 적용된다
  • 위 예시에서 UserV1을 사용하는 클라이언트 UserController는 사용자의 이름을 변경하고자 UserV1의 메서드 2개를 호출 했다
    • 이는 UserV1 이 구현 세부 사항을 유출한다는 것으로 이를 수정한 UserV2를 만들었다
    • 따라서 클라이언트 코드가 아래와 같이 바뀐다
// 수정 전 클라이언트 코드 연산의 수가 1보다 크다 -> 클래스가 구현 세부 사항을 유출하고 있다
String normalizedName = user.normalizeName(newName);
user.changeName(normalizedName);
// 수정 후 유출된 구현 세부 사항 normalizeName 메서드를 공개 API changeName 뒤로 숨김
user.changeName(newName);

6 육각형 아키텍쳐

  • 전형적인 애플리케이션은 도메인과 애플리케이션 서비스라는 두 계층으로 구성된다
  • 애플리케이션 서비스 계층과 도메인 계층의 조합은 육각형을 형성하며 이 육각형은 애플리케이션을 나타낸다
  • 다른 애플리케이션도 육각형으로 나타내며 여러 육각형이 서로 소통하며 육각형 아키텍처를 구성한다
  • 육각형 아키텍처라는 용어는 앨리스터 코오번이 처음 소개했으며 그 목적은 중요한 세 가지 지침을 강조하는 것이다

6.1 도메인

  • 애플리케이션의 필수 기능으로 비즈니스 로직이 포함돼 있다

6.2 애플리케이션 서비스

  • 애플리케이션 서비스는 도메인의 비즈니스 로직을 유스케이스와 결합한다
  • 애플리케이션 서비스 계층은 외부 환경과의 통신을 조정한다
    • 예를 들어 애플리케이션이 Restful API인 경우 API에 대한 모든 요청이 먼저 애플리케이션 서비스 계층에 도달한다
    • 이 계층은 도메인 클래스프로세스 외부 의존성 간의 작업을 조정한다
  • 애플리케이션 서비스에 대한 조정 예시
    • 데이터베이스를 조회하고 해당 데이터로 도메인 클래스 인스턴스 구체화
    • 해당 인스턴스에 연산 호출
    • 결과를 다시 데이터베이스에 저장

유스케이스

목적을 달성하기 위해 액터가 시스템을 사용하는 텍스트 형식의 스토리이다

6.3 육각형 아키텍쳐의 지침

  • 육각형 아키텍쳐에는 세 가지의 중요한 지침이 있다
    • 도메인 계층과 애플리케이션 서비스 계층 간의 관심사 분리
    • 애플리케이션 내부 통신
    • 애플리케이션 간의 통신

6.3.1 도메인 계층과 애플리케이션 서비스 계층 간의 관심사 분리

  • 도메인 계층은 해당 비즈니스 로직에 대해서만 책임을 져야 한다
  • 외부 애플리케이션과 통신하거나 데이터베이스에서 데이터를 검색하는 것과 같은 책임은 애플리케이션 서비스에 귀속된다
  • 애플리케이션 서비스에는 어떤 비즈니스 로직도 있으면 안된다
  • 애플리케이션 서비스는 요청이 들어오면 도메인 클래스의 연산으로 변환한다음 결과를 저장하거나 호출자에게 다시 반환한다

6.3.2 애플리케이션 내부 통신

  • 육각형 아키텍처는 애플리케이션 서비스 계층에서 도메인 계층으로 흐르는 단방향 의존성 흐름을 규정한다
  • 도메인 계층 내부 클래스는 도메인 계층 내부 클래스 끼리 서로 의존하지만 애플리케이션 서비스 계층의 클래스에 의존하지 않는다
  • 애플리케이션 서비스 계층은 도메인 계층을 의존한다, 반대는 아니다
  • 도메인 계층은 외부 환경에서 완전히 격리돼야 한다

6.3.3 애플리케이션 간의 통신

  • 외부 애플리케이션은 애플리케이션 서비스 계층에 있는 공통 인터페이스를 통해 해당 애플리케이션에 연결된다
  • 아무도 도메인 계층에 직접 접근할 수 없다

7 시스템 내부 통신과 시스템간 통신

  • 시스템 내부 통신은 구현 세부 사항이고 시스템 간 통신은 그렇지 않다
  • 따라서 시스템 간 통신과 해당 통신의 부작용이 외부 환경에서 보일 때만 목을 사용하는 것이 타당하다

7.1 시스템 내부 통신

  • 시스템 내부 통신이란 애플리케이션 내 클래스 간의 통신이다
  • 연산을 수행하기 위한 도메인 클래스간의 협력은 식별할 수 있는 동작이 아니므로 시스템 내부 통신은 구현 세부 사항에 해당한다
  • 이러한 협력은 클라이언트의 목표와 직접적인 관계가 없다
  • 시스템 내 클래스 간의 통신을 검증하는데 목을 사용하면 안된다
    • 테스트가 구현 세부 사항과 결합되며 리팩터링 내성이 없어진다

7.2 시스템간 통신

  • 시스템간 통신은 애플리케이션이 외부 애플리케이션과 통신할 때를 말한다
  • 애플리케이션을 통해서만 접근할 수 있는 외부 시스템을 제외하고 시스템 간 통신은 식별할 수 있는 동작이다
    • 모든 프로세스 외부 의존성을 목으로 대체해야 하는 것은 아니다
    • 애플리케이션을 통해서만 접근할 수 있는 외부 시스템은 구현 세부 사항으로 목으로 대체하지 않는다
    • 시스템간 통신에는 목을 사용하고 시스템과 외부 애플리케이션 간의 통신 패턴을 확인할 때 좋다

7.3 예시

  • 시스템 내부 통신과 시스템 간 통신의 차이점을 예시를 통해 알아보자
  • Customer와 Store 클래스를 예로 들어 설명한다.
  • 비즈니스 유스케이스
    • 고객이 상점에서 제품을 구매하려 한다
    • 매장 내 제품 수량이 충분하면
      • 재고가 상점에서 줄어든다
      • 고객에게 이메일로 영수증을 발송한다
      • 확인 내역을 반환한다

CustomerController.java

@RestController
@RequiredArgsConstructor
public class CustomerController {
private final CustomerRepository customerRepository;
private final ProductRepository productRepository;
private final EmailGateway emailGateway;
private final Store mainStore;

public boolean purchase(int customerId, int productId, int quantity) {
Customer customer = customerRepository.findById(customerId).get();
Product product = productRepository.findById(productId).get();

boolean isSuccess = customer.purchase(mainStore, product, quantity);

if (isSuccess) {
emailGateway.sendReceipt(customer.getEmail(), product.getName(), quantity);
}

return isSuccess;
}
}
  • CustomerController 클래스는 도메인 클래스(Customer, Product, Store)와 외부 애플리케이션(SMTP 서비스의 프록시인 EmailGateway) 간의 작업을 조정하는 애플리케이션 서비스다

시스템 간 통신

  • customerController.purchase()
    • 해당 메서드에서 고객은 상점에 재고가 충분한지 확인하고 충분하면 제품 수량을 감소 시킨다
    • 해당 메서드는 서드 파티 애플리케이션(유스케이스를 시작하는 클라이언트)과 CustomerController 애플리케이션 서비스 간의 통신이다
    • isSuccess 플래그를 외부 클라이언트에서 확인할 수 있어 검증도 필요하다
  • emailGateway.sendReceipt()
    • SMPT 서비스에 대한 호출은 외부 환경에서 볼 수 있는 부작용이므로 애플리케이션에 전체적으로 식별할 수 있는 동작을 나타낸다
    • 클라이언트의 목표는 구매를 하는 것이며 성공적인 결과로서 이메일로 확인 내역을 받는 것을 기대한다
    • 따라서 고객의 목표에 직접적인 연관이 있으므로 식별할 수 있는 동작이다

시스템 내부 통신

customer.purchase()

  • 고객은 상점에서 재고가 충분한지 확인하고 충분하면 제품 수량을 감소시킨다
  • 이는 Customer와 Store 도메인 클래스간의 통신으로 시스템 내부 통신이다

8 단위 테스트의 고전파와 런던파 재고

고전파와 런던파 간 차이점 요약

격리 주체단위의 크기테스트 대역 사용 대상
런던파단위단일 클래스불변 의존성 외 모든 의존성
고전파단위 테스트단일 클래스 또는 클래스 세트공유 의존성

8.1 런던파의 문제점

  • 런던파는 불변 의존성을 제외한 모든 의존성에 목 사용을 권장하며 시스템 내 통신과 시스템 간 통신을 구분하지 않는다
  • 그 결과, 테스트는 외부 시스템 간의 통신을 확인하는 것처럼 시스탬 내부 통신도 확인한다
  • 런던파를 따라 목을 무분별하게 사용하면 종종 구현 세부 사항에 결합돼 테스트에 리팩터링 내성이 없어진다
  • 고전파는 테스트 간에 공유하는 의존성만 교체하므로 이 문제에 대해 훨씬 유리하다

9 프로세스 외부 의존성과 목

고전파의 공유 의존성

  • 공유 의존성은 테스트 간의 격리를 무너뜨려 테스트를 병렬적으로 실행할 수 없게 한다
  • 따라서 고전파에서는 공유 의존성을 목으로 대체하는 것을 권고한다
  • 그러나 모든 프로세스 외부 의존성을 목으로 대체하는 것은 아니다
  • 공유 의존성은 프로세스 내부 또는 외부에 있을 수 있으며 그에 따라 목으로 대체하는 방법이 다르다

프로세스 내부 공유 의존성의 경우

  • 프로세스 내부 공유 의존성의 변경 사항을 동일한 프로세스 내에서 실행되는 모든 단위 테스트에서 볼 수 있다
  • 프로세스 내부 공유 의존성의 전형적인 예는 정적 가변 필드싱글턴이 있다
  • 공유 의존성이 프로세스 내부에 있으면 각 테스트에서 별도의 인스턴스를 공급할 수 있어 비공개 의존성으로 만들 수 있다

프로세스 외부 공유 의존성의 경우

  • 프로세스 외부 공유 의존성의 대표적인 예로 데이터베이스나, 메시지 버스, SMTP 서비스가 있다
  • 각 테스트 실행 시 데이터베이스를 인스턴스화하거나 메시지 버스를 새로 준비할 수 없다
    • 이렇게 하면 테스트 스위트가 현저하게 느려짐
  • 일반적인 접근법은 이러한 의존성을 테스트 대역, 목과 스텁으로 교체하는 것이다
  • 그러나 모든 프로세스 외부 의존성을 목으로 교체해야 하는 것은 아니다
  • 프로세스 외부 의존성이 애플리케이션을 통해서만 접근할 수 있으면 이러한 의존성과의 통신은 시스템에서 식별할 수 있는 동작이 아니다
    • 외부에서 관찰할 수 없는 프로세스 외부 의존성은 애플리케이션의 일부로 작용한다
    • 리팩터링 후에 그대로 유지할 필요가 없으므로 목으로 검증해서는 안 된다

9.1 애플리케이션을 통해서만 접근할 수 있는 외부 시스템

  • 애플리케이션을 통해서만 접근할 수 있는 외부 시스템과의 통신이 구현 세부 사항인 이유는 통신 결과의 부작용을 외부에서 확인할 수 없기 때문이다
  • 애플리케이션이 외부 시스템에 대한 프록시 같은 역할을 하고 클라이언트가 직접 접근할 수 없기 때문에 하위 호환성 요구 사항이 사라진다
  • 서버(외부 시스템)에 대한 클라이언트(애플리케이션)가 하나만 존재하기 때문에 서버는 통신 인터페이스의 하위 호환성이 필요없어진다
  • 이제 이 외부 시스템과 애플리케이션을 같이 배포할 수 있으면 애플리케이션의 클라이언트에게 영향을 미치지 않는다
  • 좋은 예로 애플리케이션 데이터베이스가 있는데 이는 애플리케이션만 사용하는 데이터베이스르 말한다
    • 따라서 기존 기능을 손상시키지 않고 애플리케이션과 애플리케이션 데이터베이스 간의 통신 패턴을 원하는 대로 수정할 수 있다
    • 애플리케이션의 클라이언트의 시야에서 완전히 숨어있기 때문에 이와 같은 일이 가능하다
  • 따라서 완전한 제어권을 가진 프로세스 외부 의존성에 목을 사용하면 깨지기 쉬운 테스트가 된다
  • 이러한 프로세스 외부 의존성을 관리 의존성이라고 한다
  • 관리 의존성 그대로 사용하면서 피드백 속도를 저하시키지 않으려면 어떻게 해야될까?