[단위 테스트] 목과 테스트 취약성
Mock 목과 테스트 취약성
목과 스텁의 구분
- 테스트 대역 유형
테스트 대역 : 더미, 스텁, 스파이, 목, 페이크
목 : 외부로 나가는 상호작용을 모방하고 검사하는 것에 도움. SUT가 상태를 변경하기 위해 의존성을 호출.
스텁 : 내부로 들어오는 상호작용을 모방하는데 도움. SUT가 입력 데이터를 얻기 위한 의존성을 호출.
ex) 이메일 발송 -> SMTP 서버 (목 : STMP 서버 상태를 변경)
데이터 검색 <- 데이터 베이스 (스텁 : 내부로 들어오는 상호작용)
Mock, 도구로써의 목
목 라이브러리 (Mocking Library)를 통해 목과 스텁을 생성할 수 있다.
목 : 테스트 대역을 통해 외부로의 호출을 모방한다.
- SendGreetingEmail은 이메일을 전송한다는 사이드 이펙트를 일으킨다.
TEST(TestEmailGateway, SendGreetingEmail) {
// given
MockEmailGateway mock = {};
EmailController sut(mock);
EXPECT_CALL(mock, SendGreetingEmail(_)).Times(1);
// when
sut.GreetUser("user@email.com");
// then
}
스텁 : 테스트 대역이 갖는 의존성을 입력 데이터로써 제공한다.
- DatabaseController가 갖는 의존성을 준비한다.
TEST(TestDatabase, CreateReportResultIsCorrect) {
// given
MockDatabase mock = {};
DatabaseController sut(mock);
ON_CALL(mock, GetNumberOfUsers()).WillByDefault(Return(10));
// when
int report = sut.CreateReport();
// then
EXPECT_EQ(report, 10);
}
스텁으로 상호작용을 검증하지 말라.
-> SUT가 갖는 의존성을 준비시키는 것이지, 의존성을 준비하는 것을 검증해야할 만큼 의미가 있는 행위가 아니다.
-> 과잉 명세, 거짓 양성으로 이어지며 리팩토링 내성을 약화 시킨다.
CQS Command Query Separation (명령 조회 분리)
모든 메서드는 명령이거나 조회이어야만 한다.
- 명령 : 사이드 이펙트를 일으키고 어떤 값도 반환하지 않는 메서드
- 조회 : 사이드 이펙트가 없고 값을 반환하는 메서드
식별할 수 있는 동작을 구분하라
-> 클라이언트가 목표를 달성하기 위해 호출해야하는 중요한 메서드
-> 이상적으로 식별할 수 있는 동작만 공개 하고 나머지는 비공개 API로 설계하는 것이 바람직하다.
API를 적절하게 캡슐화하라. 잘 설계된 API가 좋은 단위 테스트를 만들어 낸다.
육각형 아키텍처
상호작용하는 어플리케이션의 집합
어플리케이션 계층
: 도메인 계층 위에서 외부 환경과 통신을 조정하는 역할
ex) RESTful API 호출 -> 데이터 베이스 조회 -> 값 연산 -> 데이터 베이스 쓰기
특징
도메인 계층과 어플리케이션 계층간 관심사 분리
-> 도메인 계층은 비즈니스 로직에 책임을 진다.
-> 외부 통신은 어플리케이션 서비스가 책임을 진다.
어플리케이션 내부 통신
-> 어플리케이션 서비스 계층에서 도메인 계층으로 흐르는 단방향 의존성 흐름을 규정
어플리케이션간 외부 통신
-> 어플리케이션 서비스 계층에있는 공통 인터페이스를 통해 서로 통신한다. 외부 어플리케이션은 도메인에 접근할 수 없다.
테스트 목표
API로 정의된 시스템간 통신을 검증하는 것.
- 외부 클라이언트 <-> 어플리케이션 서비스
- 어플리케이션 서비스 <-> 도메인 클래스 1
- 어플리케이션 서비스 <-> 도메인 클래스 2
시스템 내부 통신 (inter-system)과 시스템 간 통신 (intra-system)
시스템 내부 통신은 구현 세부 사항이고 시스템 간 통신은 그렇지 않다.
외부 어플리케이션에서 단방향으로 통신되는 API는 식별할 수 있는 동작이어야 한다.
ex) SMTP 서비스로 메시지 알림이 가는 SendMessage() 메서드는 식별 가능한 동작이어야 한다.
목을 사용하면 시스템과 외부 어플리케이션 간 통신 패턴을 확인할 때 좋다.
시스템 내부 통신과 시스템 간 통신의 예
class CustomerController {
private:
CustomerRepository _customerRepository;
ProductRepository _productRepository;
Store& _mainStore;
EmailGateway& _emailGateway;
public:
CustomerController(Store& store, EmailGateway& emailGateway)
: _mainStore(store), _emailGateway(emailGateway) {}
bool Purchase(int customerId, int productId, int quantity) {
Customer customer = _customerRepository.GetById(customerId);
Product product = _productRepository.GetById(productId);
bool isSuccess = customer.Purchase(_mainStore, product, quantity);
if (isSuccess) {
_emailGateway.SendReceipt(customer.emailAddress, GetProductName(product), quantity);
}
return isSuccess;
}
};
- 클라이언트는 CustomerController의 Purchase API를 호출하여 성공했는 지를 알아낸다. (시스템 간 통신)
- Purchase API가 호출 되면 내부에서 Customer 인스턴스를 꺼내 Store 인스턴스 상태를 변경한다. (시스템 내부 통신)
- Purchase API 호출 중 세부 구현 사항으로 SendReceipt()를 호출하게 된다. (시스템 내부 통신)
적절한 목을 사용한 단위 테스트
외부 통신을 목으로 대체하여 단위 테스트를 수행한다. (SMTP 서비스와 통신하는 EmailGateway)
TEST(TestCusotmerController, 충분한_인벤_구매_성공_그리고_영수증_발행) {
// given
MockStore store = {};
MockEmailGateway gateway = {};
CustomerController sut(store, gateway);
ON_CALL(store, HasEnoughInventory(Product::Shampoo, 5))
.WillByDefault(Return(true));
EXPECT_CALL(gateway, SendReceipt("customer@email.com", "Shampoo", 5)).Times(1);
// when
bool isSuccess = sut.Purchase(1, (int)Product::Shampoo, 5);
ASSERT_TRUE(isSuccess);
}
-> Store의 개체는 실제 인스턴스를 사용하여 단위 테스트를 수행하는 것이 세부 구현 사항을 신경쓰지 않으므로
Purchase API를 더 유연히 테스트 할 수 있다. (리팩토링 내성이 증가한다.)
고전파와 런던파에서 테스트 대역에 대한 관점
- 고전파에서는 공유 의존성이 생기는 부분만 테스트 대역으로 대체하고자한다.
- 런던파에서는 모든 의존성을 테스트 대역으로 대체하고자 한다.
프로세스 외부 의존성을 모두 목으로 대체하여야 하는 가
- 공유 의존성 : 테스트 간에 공유하는 의존성
- 프로세스 외부 의존성 : 실행 프로세스 외에 다른 프로세스를 점유하는 의존성
- 비공개 의존성 : 공유하지 않는 모든 의존성
고전파에서는 공유 의존성은 없애야 한다고 한다.
-> 테스트가 실행 컨텍스트를 병렬로 처리할 수 없기 때문이다. (테스트 격리가 필요함)
-> 프로세스 외부 의존성을 항상 목으로 대체해야하는 것은 아니다.
-> 항상 메인 어플리케이션에서만 데이터 베이스를 접근한다면, 클라이언트 어플리케이션은 데이터 베이스 접근에 대한 공유 의존성을 가지는 것은 아니다.
-> 따라서 클라이언트가 메인 어플리케이션 API 호출은 데이터 베이스 접근에 대해 전혀 몰라도 된다.
결론
- 테스트 대역은 목과 스텁이 있다.
- 목은 외부로 나가는 상호작용, 스텁은 내부로 들어오는 상호작용이다.
- 스텁과의 상호작용 검증은 취약한 테스트로 이어진다.
- 잘 설계된 API는 식별할 수 있는 동작으로 공개 API로 되어 있어 내부 구현 사항은 숨겨지게 된다.
- 육각형 아키텍처는 어플리케이션의 집합이며 도메인과 어플리케이션을 구분하고 각 외부 및 내부 서비스 계층간 통신을 정의한다.
- 시스템 내 통신은 구현 세부 사항이다.
- 시스템 내 통신을 목으로 대체하면 취약한 테스트로 이어진다. 시스템 간 통신을 목으로 대체하라.
참고
Unit Testing 단위 테스트, 블라디미르 코리코프 지음
https://www.sandordargo.com/blog/2019/04/24/parameterized-testing-with-gtest#parameterizedtests
https://github.com/jinsnowy/UnitTest_Study