5.1 목과 스텁 구분
목은 테스트 대상 시스템(SUT)과 그 협력자 사이의 상호 작용을 검사할 수 있는 테스트 대역이다.
테스트 대역에는 또 다른 유형으로 스텁(Stub)이 있다. 5장에서는 목이 무엇이고 스텁과 어떻게 다른지 알아본다.
5.1.1 테스트 대역 유형
•
테스트 대역 : 비 운영용 가짜 의존성을 설명하는 포괄적인 용어
◦
영화 산업의 ‘스턴트 대역’ 개념에서 비롯
◦
테스트 대역에는 더미, 스텁, 스파이, 목, 페이크라는 다섯가지가 있다.
테스트 대역의 모든 변형은 목과 스텁 두 가지 유형으로 나눌 수 있다.
•
목은 외부로 나가는 상호작용을 모방하고 검사하는 데 도움이 된다.
•
스텁은 내부로 들어오는 상호 작용을 모방하는 데 도움이 된다.
•
테스트 대역 다섯 가지 변형의 차이점
◦
스파이는 목과 같은 역할. 수동적으로 작성하여 ‘직접 작성한 목’이라고 부르기도 한다.
◦
더미는 널 값이나 가짜 문자열과 같이 단순하고 하드코딩 된 값이다.
(메서드 시그니처를 만족시키기 위해 사용)
◦
스텁은 더미보다 더 정교하다.
테스트 시나리오마다 다른 값을 반환하게끔 구성할 수 있도록 필요한 것을 다 갖춘 완전한 의존성.
◦
페이크는 대다수의 목적에 부합하는 스텁과 같다. 아직 존재하지 않는 의존성을 대체하고자 구현한다.
5.1.2 도구로서의 목과 테스트 대역으로서의 목
•
목 라이브러리의 클래스는 실제 목을 만드는 데 도움이 되지만, 그 자체로는 목이 아니다.
◦
예제 5.1
◦
Mock 클래스는 도구로서의 목인 데 반해, 해당 클래스의 인스턴스인 mock은 테스트 대역으로서의 목이다.
◦
도구로서의 목을 사용해 목과 스텁, 두 가지 유형의 테스트 대역을 생성할 수 있기 때문에 도구로서의 목과 테스트 대역으로서의 목을 혼동하지 않는 것이 중요하다.
•
예제 5.2 : Mock 클래스를 사용하지만 해당 클래스의 인스턴스는 목이 아니라 스텁이다.
◦
예제 5.2
◦
내부로 들어오는 상호 작용, SUT에 입력 데이터를 제공하는 호출을 모방한다.
5.1.3 스텁으로 상호 작용을 검증하지 말라
•
예제 5.3
•
목은 SUT에서 관련 의존성으로 나가는 상화 작용을 모방/검사
•
스텁은 내부로 들어오는 상호 작용만 모방 → 검사하지 않는다.
◦
SUT에서 스텁으로의 호출은 SUT가 생성하는 최종 결과가 아니다.
◦
최종 결과가 아닌 사항을 검증하는 관행을 과잉 명세(Overspecification)라고 부른다.
5.1.4 목과 스텁 함께 쓰기
•
예제 5.4
•
테스트 대역은 목이면서 스텁이지만 목이라고 부르지 스텁이라고 부르지는 않는다.
◦
목이라는 사실이 스텁이라는 사실보다 더 중요하기 때문에 대체로 목이라고 한다.
5.1.5 목과 스텁은 명령과 조회에 어떻게 관련돼 있는가?
그림 5.3 명령 조회 분리(CQS) 원칙에서 명령은 목에 해당하는 반면, 조회는 스텁과 일치한다.
CQS 원칙에 따르면 모든 메서드는 명령이거나 조회여야 하며, 이 둘을 혼용해서는 안된다.
•
명령
◦
부작용(side effects)를 일으키고
▪
객체 상태 변경, 파일 시스템 내 파일 변경 등
◦
어떤 값도 반환하지 않는 메서드(void)
•
조회
◦
부작용이 없고
◦
값을 반환한다.
→ 메서드가 부작용을 일으키면 해당 메서드의 반환 타입이 void인지 확인하라. 그리고 메서드가 값을 반환하면 부작용이 없어야한다.
5.2 식별할 수 있는 동작과 구현 세부 사항
•
목과 테스트 취약성 간의 연관성을 찾기 위한 다음 단계 → 취약성을 일으키는 원인을 알아보자.
◦
테스트 취약성은 좋은 단위 테스트의 두 번째 특성인 리팩터링 내성에 해당
◦
테스트에 거짓 양성이 있는 주요 이유는 코드의 구현 세부 사항과 결합돼 있기 때문
◦
강결합을 피하는 방법은 코드가 생성하는 최종 결과를 검증하고 구현 세부 사항과 테스트를 가능한 한 떨어트리는 것 뿐
◦
즉, 테스트는 ‘어떻게’가 아니라 ‘무엇’에 중점을 둬야한다.
5.2.1 식별할 수 있는 동작은 공개 API와 다르다
•
모든 제품 코드는 2차원으로 분류할 수 있다.
◦
공개 API 또는 비공개 API
◦
식별할 수 있는 동작 또는 구현 세부 사항
•
식별할 수 있는 동작과 내부 구현 세부사항에는 미묘한 차이가 있다. 코드가 식별할 수 있는 동작이라면 다음 중 하나를 해야한다.
◦
클라이언트가 목표를 달성하는 데 도움이 되는 연산(operation)을 노출하라.
(연산은 계산을 수행하거나 부작용을 초래하거나 둘 다하는 메서드)
◦
클라이언트가 목표를 달성하는 데 도움이 되는 상태(state)을 노출하라.
(상태는 시스템의 현재 상태)
◦
구현 세부 사항은 이 두 가지 중 아무것도 하지 않는다.
•
코드가 식별할 수 있는 동작인지 여부는 해당 클라이언트가 누구인지, 목표가 무엇인지에 달려있다.
•
이상적으로 시스템의 공개 API는 식별할 수 있는 동작과 일치해야하며, 모든 구현 세부 사항은 클라이언트 눈에 버이지 않아야 한다. → API 설계가 잘돼 있음
•
공개 API가 식별할 수 있는 동작의 범위를 넘어서면 시스템은 구현 세부 사항을 유출한다.
5.2.2 구현 세부 사항 유출: 연산의 예
•
예제 5.5
•
이 User 클래스의 API가 잘 설계되지 않은 이유는?
◦
속성과 메서드 모두 public으로 되어있다.
◦
name 속성은 클라이언트의 목표를 달성하는데 도움이 되도록 세터를 노출한다.
◦
normalizeName() 메서드도 작업이지만 클라이언트의 목표에 직결되지 않는다.
•
이 상황을 해결하고 클래스 API를 잘 설계하려면 normalizedName() 메서드를 숨기고 속성 세터를 클라이언트 코드에 의존하지 않으면서 내부적으로 호출해야 한다.
◦
예제 5.6
◦
식별할 수 있는 동작(name 속성)만 public으로 돼있고, 구현 세부 사항(normalizeName() 메서드)은 비공개 private로 숨겨져 있다.
•
단일한 목표를 달성하고자 클래스에서 호출해야 하는 연산의 수가 1보다 크면 해당 클래스에서 구현 세부 사항을 유출할 가능성이 있다. → 이상적으로는 단일 연산으로 개별 목표를 달성해야 한다.
// before
String normalizedName = user.normalizeName(newName);
user.name = normalizedName;
// after
user.setName(newName);
Java
복사
5.2.3 잘 설계된 API와 캡슐화
•
캡슐화는 불변성 위반이라고도 하는 모순을 방지하는 조치
◦
불변성은 항상 참이어야 하는 조건
◦
이전 예제에서 User 클래스에는 사용자 이름이 50자를 초과하면 안 된다는 불변성이 있었다.
◦
불변성 위반으로 구현 세부 사항을 노출 → 구현 세부 사항을 노출하면 불변성 위반을 가져온다.
•
장기적으로 코드베이스 유지 보수에서는 캡슐화가 중요하다.
◦
코드 베이스가 복잡해질수록 작업하기 어렵고, 개발 속도가 느려지고, 버그 수가 증가한다.
◦
캡슐화는 궁극적으로 단위 테스트와 동일한 목표를 달성한다. → 프로젝트의 지속적인 성장
•
‘묻지 말고 말하라(tell don’t ask)’ - 마틴 파울러
◦
데이터를 연산 기능과 결합하는 것을 의미
◦
구현 세부 사항을 숨기면 클라이언트의 시야에서 클래스 내부를 가릴 수 있기 때문에 내부를 손상시킬 위험이 적다.
◦
데이터와 연산을 결합하면 해당 연산이 클래스의 불변성을 위반하지 않도록 할 수 있다.
5.2.4 구현 세부 사항 유출: 상태의 예
•
4장에서 살펴본 MessageRenderer 클래스
public class MessageRenderer implements IRenderer{
public List<IRenderer> subRenderers;
public MessageRenderer() {
subRenderers = new ArrayList<>();
subRenderers.add(new HeaderRenderer());
subRenderers.add(new BodyRenderer());
subRenderers.add(new FooterRenderer());
}
@Override
public String render(final Message message) {
return String.join("",
subRenderers.stream()
.map(r -> r.render(message))
.collect(Collectors.toList())
);
}
}
Java
복사
•
subRenderers List가 public이다. 그러나 이 컬렉션이 식별할 수 있는 동작인가?
◦
클라이언트의 목표가 HTML을 렌더링하는 것이라고 가정하면 → NO
◦
클라이언트에게 필요한 멤버는 render() 메서드 뿐이다.
◦
따라서 subRenderers 필드는 구현 세부 사항 유출이다.
•
좋은 단위 테스트와 잘 설계된 API 사이에는 본질적인 관계가 있다.
→ 모든 구현 세부 사항을 비공개로 하면 테스트가 식별할 수 있는 동작을 검증하는 것 외에는 다른 선택지가 없으며, 이로 인해 리팩터링 내성도 자동으로 좋아진다.
API를 잘 설계하면 단위 테스트도 자동으로 좋아진다.
•
클라이언트가 목표를 달성하는 데 직접적으로 도움이 되는 코드만 공개해야 하며, 다른 모든 것은 구현 세부 사항이므로 private로 해야한다.
표 5.1 코드의 공개 여부와 목적의 관계, 구현 세부 사항을 공개하지 말라.
5.3 목과 테스트 취약성 간의 관계
•
앞 절에서 목을 정의하고 식별할 수 있는 동작과 구현 세부 사항 간의 차이점을 살펴봤다.
•
이 절에서는 육각형 아키텍처, 내부 통신과 외부 통신의 차이점, 목과 테스트 취약성 간의 관계를 알아본다.
5.3.1 육각형 아키텍처 정의
그림 5.8 대표적인 애플리케이션은 도메인 계층과 애플리케이션 계층으로 구성된다.
•
도메인 계층은 애플리케이션의 중심부이기 때문의 도표의 중앙에 위치
◦
애플리케이션의 필수 기능으로 비즈니스 로직이 포함돼 있다.
◦
도메인 계층과 해당 비즈니스 로직은 다른 애플리케이션과 차별화하고 조직의 경잭력을 향상시킴
•
애플리케이션 서비스 계층은 도메인 계층 위에 있으며 외부 환경과의 통신을 조정
◦
애플리케이션이 RESTful API인 경우 API에 대한 모든 요청이 먼저 애플리케이션 서비스 계층에 도달
◦
도메인 클래스와 프로세스 외부 의존성 간의 작업을 조정
▪
애플리케이션 서비스에 대한 조정의 예
▪
데이터베이스를 조회하고 해당 데이터로 도메인 클래스 인스턴스 구체화
▪
해당 인스턴스에 연산 호출
▪
결과를 데이터베이스에 다시 저장
•
애플리케이션 서비스 계층과 도메인 계층의 조합은 육각형을 형성하며 애플리케이션을 나타낸다.
◦
또 다른 애플리케이션과 소통할 수 있고, 다른 애플리케이션도 육각형으로 나타낸다.
그림 5.9 육각형 아키텍처는 상호 작용하는 애플리케이션(육각형)의 집합이다.
•
육각형 아키텍처라는 용어는 앨리스터 코오번이 처음 소개했고, 그 목적은 세 가지 중요한 지침을 강조한다.
◦
도메인 계층과 애플리케이션 서비스 계층 간의 관심사 분리
▪
비즈니스 로직은 애플리케이션의 가장 중요한 부분으로 도메인 계층은 해당 비즈니스 로직에 대해서만 책임을 져야하며, 다른 모든 책임에서는 제외돼야 한다.
▪
반대로 애플리케이션 서비스에는 어떤 비즈니스 로직도 있으면 안 된다.
◦
애플리케이션 내부 통신
▪
육각형 아키텍처는 애플리케이션 서비스 계층에서 도메인 계층으로 흐르는 단방향 의존성 흐름을 규정한다. 도메인 계층 내부 클래스는 도메인 계층 내부 클래스끼리 서로 의존하고 애플리케이션 서비스 계층의 클래스에 의존하지 않는다.
▪
도메인 계층은 외부 환경에서 완전히 격리돼야 한다.
◦
애플리케이션 간의 통신
▪
외부 애플리케이션은 애플리케이션 서비스 계층에 있는 공통 인터페이스를 통해 해당 애플리케이션에 연결된다.
▪
육각형의 각 면은 애플리케이션 내외부 연결을 나타낸다.
(다른 애플리케이션을 여섯 개만 연결할 수 있는 것은 아니다.)
•
애플리케이션의 각 계층은 식별할 수 있는 동작을 나타내며 해당 구현 세부 사항을 포함하고 있다.
◦
예를 들어 도메인 계층의 식별할 수 있는 동작은 이 계층의 연산과 상태이고,
연산과 상태는 애플리케이션 서비스 계층이 적어도 하나의 목표를 달성하는 데 도움이 된다.
◦
잘 설계된 API의 원칙에는 프랙탈 특성이 있는데, 이는 전체 계층만큼 크게도, 단일 클래스만큼 작게도 똑같이 적용되는 것이다.
•
각 계층의 API를 잘 설계하면 테스트도 프랙탈 구조를 갖기 시작한다.
◦
달성하는 목표는 같지만 서로 다른 수준에서 동작을 검증한다.
◦
애플리케이션 서비스 테스트는 해당 서비스가 외부 클라이언트에게 매우 중요하고 큰 목표를 어떻게 이루는지 확인한다.
◦
도메인 클래스 테스트는 큰 목표의 하위 목표를 검증한다.
그림 5.10 서로 다른 계층의 테스트는 동일한 동작을 서로 다른 수준으로 검증하는 프랙탈 특성이 있다.
•
외부 클라이언트에게 중요한 목표는 개별 도메인 클래스에서 달성한 하위 목표로 변환된다.
따라서 도메인 계층에서 식별할 수 있는 동작은 각각 구체적인 비즈니스 유스케이스와 연관성이 있다.
•
잘 설계된 API로 코드베이스를 검증하는 테스트는 식별할 수 있는 동작만 결합돼 있기 때문에 비즈니스 요구 사항과 관계가 있다.
◦
UserController는 애플리케이션 서비스
◦
외부 클라이언트가 사용자 이름을 정규화하려는 목표가 없으면 normalizeName()은 클라이언트의 요구사항이 아님 → 구현 세부 사항이므로 비공개로 해야한다. 또한 테스트에서 직접 확인하며 안된다.
◦
클래스의 식별할 수 있는 동작 → name 속성의 Setter으로서만 검증해야한다.
public class User {
private String _name;
public String name;
public String getName() {
return _name;
}
public void setName(String name) {
this._name = normalizeName(name);
}
private String normalizeName(String name) {
/* name을 50자로 줄이기 */
}
}
public class UserController {
public void renameUser(int userId, String newName) {
User user = getUserFromDatabase(userId);
user.setName(newName);
saveUserToDatabase(user);
}
private void saveUserToDatabase(User user) {
}
private User getUserFromDatabase(int userID) {
return new User();
}
}
Java
복사
5.3.2 시스템 내부 통신과 시스템 간 통신
•
시스템 내부 통신은 애플리케이션 내 클래스 간의 통신이다.
•
시스템 간 통신은 애플리케이션이 다른 애플리케이션과 통신하는 것을 말한다.
그림 5.11 시스템 내부 통신과 시스템 간 통신
•
연산을 수행하기 위한 도메인 클래스 간의 협력은 식별할 수 있는 동작이 아니므로 시스템 내부 통신은 구현 세부 사항에 해당한다.
◦
이러한 내부의 협력은 클라이언트의 목표와 직접적인 관계가 없다.
•
시스템 외부 환경과 통신하는 방식은 전체적으로 해당 시스템의 식별할 수 있는 동작을 나타낸다.
5.3.3 시스템 내부 통신과 시스템 간 통신의 예
•
2장과 5장 앞부분에서 사용한 Customer와 Store 클래스를 예로 설명한다.
•
다음 비즈니스 유스케이스를 보자.
◦
고객이 상점에서 제품을 구매하려고 한다.
◦
매장 내 제품 수량이 충분하면
▪
재고가 상점에서 줄어든다.
▪
고객에게 이메일로 영수증을 발송한다.
▪
확인 내역을 반환한다.
◦
애플리케이션은 사용자 인터페이스가 없는 API라고 가정해보자.
•
예제 5.9 - CustomerController
◦
도메인 클래스(Customer, Product, Stroe)와 외부 애플리케이션(SMTP) 간의 작업을 조정
◦
purchase()에서 고객은 상점에 재고가 충분한지 확인하고, 충분하면 제품 수량을 감소시킨다.
◦
구매라는 동작은 시스템 내부 통신과 시스템 간 통신이 모두 있는 비즈니스 유스케이스다.
▪
시스템 간 통신 : 서드파티 애플리케이션과 이메일 게이트웨이 간의 통신
▪
시스템 내부 통신 : Customer와 Store 도메인 클래스 간의 통신이다.
•
예제 5.10 - successfulPurchase() - 취약성 야기 X
◦
SMTP 서비스에 대한 호출을 목으로 하는 이유는 타당
◦
isSuccess 플래그는 외부 클라이언트에서 확인
•
예제 5.11 - purchaseSucceedsWhenEnoughInventory() - 취약성 야기 O
◦
Customer 클래스에서 Store 클래스로의 메서드 호출은 애플리케이션 경계를 넘지 않는다.
◦
호출자와 수신자 모두 애플리케이션 내에 있다.
◦
또한 클라이언트가 목표를 달성하는 데 도움이 되는 연산이나 상태가 아니다.
◦
클라이언트는 구매를 목표로하는 CustomerController다.
▪
목표에 직접적인 관련이 있는 멤버는 customer.purchase(), store.getInventory()
▪
removeInventory() 메서드 호출은 목표로 가는 중간 단계(구현 세부 사항)
5.4 단위 테스트의 고전파와 런던파 재고
표 5.2 단위 테스트의 고전파와 런던파 간 차이점
•
2장에서 저자는 런던파보다 고전파를 더 선호한다고 했다.
◦
런던파는 불변 의존성을 제외한 모든 의존성에 목 사용을 권장하며 시스템 내 통신과 시스템 간 통신을 구분하지 않는다.
◦
그 결과, 테스트 애플리케이션과 외부 시스템 간의 통신을 확인하는 것처럼 클래스 간 통신도 확인한다.
◦
런던파를 따라 목을 무분별하게 사용하면 종종 구현 세부 사항에 결합돼 테스트에 리팩터링 내성이 없게 된다.
•
고전파는 테스트 간에 공유하는 의존성만 교체하자고 하므로 이 문제에 대해서 훨씬 유리하다.
◦
고전파 역시 시스템 간 통신에 대한 처리에 이상적이지는 않다.
◦
런던파 만큼은 아니지만, 고전파도 목 사용을 지나치게 장려한다.
5.4.1 모든 프로세스 외부 의존성을 목으로 해야하는 것은 아니다.
•
의존성 유형(자세한 내용은 2장 참조)
◦
공유 의존성 : 테스트 간에 공유하는 의존성(제품 코드가 아님)
◦
프로세스 외부 의존성 : 프로그램의 실행 프로세스 외에 다른 프로세스를 점유하는 의존성(DB, 메시지 버스, SMTP 서비스 등)
◦
비공개 의존성 : 공유하지 않는 모든 의존성
•
고전파에서는 공유 의존성을 피할 것을 권고한다.
◦
테스트가 실행 컨텍스트를 서로 방해하고, 결국 병렬 처리를 할 수 없다.
◦
공유 의존성이 프로세스 외부에 있는 것이 아니라면 해당 의존성을 새 인스턴스로 써서 재사용을 피하기 쉽다.
◦
공유 의존성이외부에 있으면 테스트가 복잡해진다.(테스트 실행 전 DB를 인스턴스화 등)
→ 일반적인 접근법은 이러한 의존성을 테스트 대역, 즉 목과 스텁으로 교체하는 것
•
그러나!!! 모든 프로세스 외부 의존성을 목으로 해야하는 것은 아니다.
◦
애플리케이션을 통해서만 접근할 수 있으면, 이러한 의존성과의 통신은 시스템에서 식별할 수 있는 동작이 아니다.
◦
외부에서 관찰할 수 없는 프로세스 외부 의존성은 애플리케이션의 일부로 작용한다.
◦
예시 : 애플리케이션에서만 사용되는 데이터베이스, 어떤 외부 시스템도 데이터베이스에 접근할 수 없다. 따라서 기존 기능을 손상시키지 않는 한 시스템과 애플리케이션 데이터베이스 간의 통신 패턴을 원하는 대로 수정할 수 있다.
▪
전혀 다른 저장 방식으로 대체할 수 있고, 그렇게 해도 아무도 모를 것
그림 5.14 리팩터링 후에 그대로 유지할 필요가 없으므로 목으로 검증해서는 안 된다.
5.4.2 목을 사용한 동작 검증
•
목표를 달성하고자 각 개별 클래스가 이웃 클래스와 소통하는 방식은 식별할 수 있는 동작과는 아무런 관계가 없다.(이는 구현 세부 사항이다.)
•
클래스 간의 통신을 검증하는 것은 두뇌의 뉴련이 서로 통과하는 신호를 측정해 사람의 행동을 유추하는 것과 유사하다.