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);
}
}