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 식별할 수 있는 동작과 구현 세부 사항
- 스텁으로 상호작용을 검증하는 것은 구현 세부 사항을 검증하는 것이며 이는 테스트와의 강한 결합을 야기합니다.
- 테스트와의 강한 결합은 테스트의 리팩터링 내성을 없애버립니다.
- 리팩터링 내성은 이 문서를 참고
- 이러한 강결합을 피하는 방법은 코드가 생성하는 최종 결과를 검증하고 구현 세부 사항과 테스트를 가능한 떨어뜨리는 것 뿐입니다.
- 그렇다면 구현 세부 사항은 정확히 무엇이며 식별할 수 있는 동작과 어떻게 다를까요?
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 육각형 아키텍쳐
- 전형적인 애플리케이션은 도메인과 애플리케이션 서비스라는 두 계층으로 구성됩니다.
- 애플리케이션 서비스 계층과 도메인 계층의 조합은 육각형을 형성하며 이 육각형은 애플리케이션을 나타냅니다.
- 다른 애플리케이션도 육각형으로 나타내며 여러 육각형이 서로 소통하며 육각형 아키텍처를 구성합니다.
- 육각형 아키텍처라는 용어는 앨리스터 코오번이 처음 소개했으며 그 목적은 중요한 세 가지 지침을 강조하는 것입니다.