카테고리 없음
Unit Testing 5장
eumjo_o
2023. 6. 30. 18:33
5장 목과 테스트 취약성
5.1 목과 스텁 구분
💡 목 - 데스트 대상 시스템(SUT)과 그 협력자 사이의 상호 작용을 검사할 수 있는 테스트 대역
5.1.1 테스트 대역 유형
💡 테스트 대역 - 테스트를 편리하게 하는 것이 목표인 모든 유형의 비운영용 가짜 의존성을 설명하는 포괄적인 용어
목 (목, 스파이 spy) 스텁 (스텁, 더미 dummy, 페이크 fake)
- 테스트 대역의 모든 변형은 목과 스텁의 두 가지 유형으로 나눌 수 있다.
- 목 (목, 스파이)
- 외부로 나가는 상호작용을 모방하고 검사하는데 도움
- SUT가 상태를 변경하기 위한 의존성을 호출하는 것에 해당
- SUT와 관련 의존성 간의 상호작용을 모방하고 검사
- 스텁 (스텁, 더미, 페이크)
- 내부로 들어오는 상호작용을 모방하는 데 도움
- SUT가 입력 데이터를 얻기 위한 의존성을 호출하는 것에 해당
- SUT가 관련 의존성 간의 상호작용을 모방만 한다.
- **더미** - null 값이나 가짜 문자열과 같이 단순하고 하드코딩된 값
- **스텁** - 더 정교, 시나리오마다 다른 값을 반환하게끔 구성할 수 있도록 필요한 것을 다 갖춘 완전한 의존성
- **페이크** - 대다수의 목적에 부합하는 스텁이지만, 보통 아직 존재하지 않는 의존성을 대체하고자 구현
- 목 (목, 스파이)
테스트 대상 시스템 ——→ 이메일 발송 (목) ——-→ SMTP 서버
- 서버에 부작용을 초래하는 상호작용
- 목 - 외부로 나가는 상호 작용을 모방하는 테스트 대역
테스트 대상 시스템 ——→ 데이터 검색 (스텁) ——-→ 데이터베이스
- 데이터베이스에서 데이터를 검색하는 것은 부작용을 일으키지 않는 상호작용
- 스텁 - 내부로 들어오는 상호 작용을 모방하는 테스트 대역
5.1.2 도구로서의 목과 테스트 대역으로서의 목
- 목 라이브러리의 클래스도 목, 해당 클래스는 실제 목을 만드는 데 도움이 되지만, 그 자체로는 목이 아니다.
- Mock 클래스 - 도구로서의 목
- 인스턴스 mock - 테스트 대역으로서의 목
[Fact] public void Sending_a_greetings_email() { // Mock(도구)로 mock(목) 생성 var emailGatewayMock = new Mock<IEmailGateway>(); var sut = new Controller(emailGatewayMock.Object); sut.GreetUser("user@email.com"); // 테스트 대역으로 하는 SUT의 호출을 검사 (외부로 나가는 상호작용) // 목적은 부작용을 일으키는 것 (이메일 발송) emailGatewayMock.Verify( x => x.SendGreetingsEmail("user@email.com"), Times.Once); } [Fact] public void Creating_a_report() { // Mock(도구)를 사용해 스텁 생성 var stub = new Mock<IDatabase>(); // 준비한 응답 설정 // 테스트 대역은 내부로 들어오는 상호작용, 즉 SUT에 입력 데이터를 제공하는 호출을 모방 stub.Setup(x => x.GetNumberOfUsers()).Returns(10); var sut = new Controller(stub.Object); Report report = sut.CreateReport(); Assert.Equal(10, report.NumberOfUsers); }
5.1.3 스텁으로 상호 작용을 검증하지 말라
- 스텁은 SUT가 출력을 생성하도록 입력을 제공한다
- 스텁과의 상호 작용을 검증하는 것은 취약한 테스트를 야기하는 일반적인 안티 패턴이다.
- 테스트에서 거짓 양성을 피하고 리팩터링 내성을 향상시키는 방법은 구현 세부 사항이 아니라 최종결과(이상적으로 비개발자들에게 의미가 있어야 함)를 검증하는 것뿐이다.
5.1.4 목과 스텁 함께쓰기
- 목과 스텁의 특성을 모두 나타내는 테스트 대역을 만들 필요도 있다.
- 1, 2 두가지의 서로 다른 메서드를 사용하기 때문에 스텁과의 상호 작용을 검증하지 말라는 규칙은 위배되지 않는다.
- 테스트 대역은 목이면서 스텁이지만, 목이라고 부르지 스텁이라고 부르지는 않는다.
- 목이라는 사실이 스텁이라는 사실보다 더 중요하기 때문에 대체로 목이라고 부른다.
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
// Arrange
var storeMock = new Mock<IStore>();
**// 1. 준비된 응답을 설정**
storeMock
.Setup(x => x.**HasEnoughInventory**(Product.Shampoo, 5))
.Returns(true);
var customer = new Customer();
// Act
bool success = customer.Purchase(storeMock.Object, Product.Shampoo, 5);
// Assert
Assert.True(success);
**// 2. SUT에서 수행한 호출을 검사**
storeMock.Verify(x => x.**RemoveInventory**(Product.Shampoo, 5), Times.Once);
}
5.1.5 목과 스텁은 명령과 조회에 어떻게 관련돼 있는가?
- 명령 조회 분리(CQS) 원칙에 의하면, 모든 메서드가 명령 또는 조회 중 하나여야 하지만 둘 다는 안된다.
- 명령을 대체하는 테스트 대역은 목이다.
- 조회를 대체하는 테스트 대역은 스텁이다.
5.2 식별할 수 있는 동작과 구현 세부 사항
테스트 취약성 - 회귀방지, 리펙터링 내성, 빠른 피드백, 유지 보수성
- 테스트는 코드가 생성하는 **최종결과(식별할 수 있는 동작)**을 검증해야 한다.
- 구현 세부사항과 테스트를 가능한한 떨어뜨리는 것이 중요.
- 테스트는 어떻게가 아니라 무엇에 중점을 둬야 한다.
5.2.1 식별할 수 있는 동작은 공개 API와 다르다
- 모든 제품 코드는 2차원으로 분류할 수 있다. (둘 다 될 수는 없다)
- 공개 API or 비공개 API
- 식별할 수 있는 동작 or 구현 세부 사항
- 코드의 공개성은 private, public, internal등 접근 제한자에 의헤 제어된다.
- 다음 요구 사항을 하나라도 충족하면 코드가 시스템의 식별할 수 있는 동작이다.
- 클라이언트가 목표를 달성하는데 도움이 되는 연산을 노출하라.
- 연산은 계산을 수행하거나 부작용을 초래하거나 둘 다 하는 메서드
- 클라이언트가 목표를 달성하는데 도움이 되는 상태를 노출하라.
- 상태는 시스템의 현재 상태
- 동일한 코드베이스, 외부 어플리케이션, 또는 사용자 인터페이스
- 클라이언트가 목표를 달성하는데 도움이 되는 연산을 노출하라.
- 잘 설계된 API에서 식별할 수 있는 동작은 공개 API와 일치하는 반면, 모든 구현 세부 사항은 비공개 API 뒤에 숨어 클라이언트 눈에 보이지 않아야 한다.
- 공개 API가 식별할 수 있는 동작의 범위를 넘어서면, 시스템은 구현 세부 사항을 유출한다.
5.2.2 구현 세부 사항 유출: 연산의 예
- User 클래스 API는 잘 설계되어 있지 않으며, 식별할 수 있는 동작이 아닌 Normalize Name 메서드를 노출하고 있다.
public class User
{
**public** string Name { get; set; }
public string NormalizeName(string name)
{
string result = (name ?? "").Trim();
if (result.Length > 50)
return result.Substring(0, 50);
return result;
}
}
public class UserController
{
public void RenameUser(int userId, string newName)
{
User user = GetUserFromDatabase(userId);
string normalizedName = user.NormalizeName(newName);
user.Name = normalizedName;
SaveUserToDatabase(user);
}
}
- API가 잘 설계된 User 클래스, 식별할 수 있는 동작만 공개돼 있고, 구현 세부 사항은 이제 비공개
public class User
{
**private** string _name;
// 식별할 수 있는 동작 Name 속성만 고액
public string Name
{
get => _name;
// 클래스의 식별할 수 있는 동작(setter)으로만 테스트에서 확인해야 함.
set => _name = NormalizeName(value);
}
// 구현 세부사항 NormalizeName 메서드는 비공개 API 뒤에 숨음.
private string NormalizeName(string name)
{
string result = (name ?? "").Trim();
if (result.Length > 50)
return result.Substring(0, 50);
return result;
}
}
// 애플리케이션 서비스
public class UserController
{
public void RenameUser(int userId, string newName)
{
User user = GetUserFromDatabase(userId);
user.Name = newName;
SaveUserToDatabase(user);
}
}
5.2.3 잘 설계된 API와 캡슐화
<aside> 💡 캡슐화는 코드를 불변성 위반으로부터 보호하는 행위
</aside>
- 구현 세부 사항을 숨기면 클라이언트의 시야에서 클래스 내부를 가릴 수 있기 때문에 내부를 손상시킬 위험이 적다.
- 테이터와 연산을 결합하면 해당 연산이 클래스의 불변성을 위반하지 않도록 할 수 있다.
- 클라이언트는 구현 세부 사항을 사용해 코드의 불변성을 우회할 수 있기 때문에 구현 세부 사항을 노출하면 캡슐화가 위반되는 경우가 종종 있다.
5.2.4 구현 세부 사항 유출:상태의 예
- 모든 구현 세부 사항을 비공개로 하면 테스트가 식별할 수 있는 동작을 검증하는 것 외에는 다른 선택지가 없으며, 이로 인해 리펙터링 내성도 자동으로 좋아진다.
- API를 잘 설계하면 단위 테스트도 자동으로 좋아진다.
- 클라이언트가 목표를 달성하는 데 직접적으로 도움이 되는 코드만 공개해야 하며, 다른 모든 것은 구현 세부 사항이므로 비공개 API 뒤에 숨겨야 한다.
- 구현 세부 사항(클라이언트가 사용하지 말아야 하는 메서드나 클래스)을 노출할 수 있지만, 식별할 수 있는 동작을 숨길 수는 없다.
- 코드의 공개 여부와 목적의 관계. 구현 세부 사항을 공개하지 말라식별할 수 있는 동작 구현 세부 사항
공개 좋음 나쁨 비공개 해당 없음 좋음
5.3 목과 테스트 취약성 간의 관계
5.3.1 육각형 아키텍쳐 정의
<aside> 💡 육각형 아키텍처는 상호 작용하는 애플리케이션의 집합, 각 애플리케이션은 육각형으로 표시 두 개의 계층 도메인 - 비지니스 로직을 포함 애플리케이션 - 도메인 계층 위에 있음.
</aside>
- 육각형의 각 계층은 식별할 수 있는 동작을 나타내며, 각각의 구현 세부 사항이 있다.
- 식별할 수 있는 동작은 바깥 계층에서 안쪽으로 흐른다.
- 코드 조각이 식별할 수 있는 동작이 되려면 클라이언트가 목표를 달성하도록 도울 필요가 있다.
- 도메인 클래스의 경우 클라이언트는 애플리케이션 서비스에 해당하고, 애플리케이션 서비스이면 외부 클라이언트에 해당한다.
3가지 관점을 강조
- 도메인과 애플리케이션 서비스 계층 간의 영향 분리
- 도메인 계층은 비즈니스 로직을 책임져야 하고, 애플리케이션 서비스 계층의 클래스에 의존해서는 안된다.
- 도메인 계층을 애플리키에션의 도메인 지식(사용방법) 모음으로, 애플리케이션 서비스 계층을 일련의 비즈니스 유스케이스(사용 대상)으로 볼 수 있다.
- 애플리케이션 서비스 계층에서 도메인 계층으로의 단방향 의존성 흐름
- 도메인 계층 내 클래스는 서로에게만 의존해야 하고, 애플리케이션 서비스 계층의 클래스에 의존해서는 안 된다.
- 애플리케이션 서비스 계층이 도메인 계층에 대해 알고 있지만, 도메인 계층은 외부 환경에서 완전히 격리돼야 한다.
- 외부 애플리케이션은 애플리케이션 서비스 계층이 유지하는 공통 인터페이스를 통해 연결된다.
- 아무도 도메인 계층에 직접 액세스할 수 없다.
5.3.2 시스템 내부 통신과 시스템 간 통신
- 애플리케이션
- 시스템 내부 통신 - 애플리케이션 내 클래스 간의 통신
- 시스템 간 통신 - 애플리케이션이 외부 에플리케이션과 통신할 때를 이른다.
- 시스템 내부 통신은 구현 세부 사항이고, 시스템 간 통신은 전체적으로 애플리케이션의 식별할 수 있는 동작을 나타낸다.
5.3.3 시스템 내부 통신과 시스템 간 통신의 예
- 시스템 내 통신은 구현 세부 사항
- 애플리케이션을 통해서만 접근할 수 있는 외부 시스템과의 상호 작용도 구현 세부 사항
- 그 결과의 부작용을 외부에서 확인할 수 없기 때문
- 애플리케이션을 통해서만 접근할 수 있는 외부 시스템과의 상호 작용도 구현 세부 사항
- 애플리케이션을 통해서만 접근할 수 있는 외부 시스템을 제외하고 시스템 간 통신은 식별할 수 있는 동작
5.4 단위 테스트의 고전파와 런던파 재고
격리 주체 단위의 크기 테스트 대역 사용 대상
런던파 | 단위 | 단일 클래스 | 불변 의존성 외 모든 의존성 |
고전파 | 단위 테스트 | 단일 클래스 또는 클래스 세트 | 공유 의존성 |
- 런던파는 불변 의존성을 제외한 모든 의존성에 목 사용을 권장하며, 시스템 내 통신과 시스템 간 통신을 구분하지 않고, 그 결과 테스트는 애플리케이션과 외부 시스템 간의 통신을 확인하는 것처럼 클래스 간 통신도 확인한다.
- 런던파 처럼 목을 무분별하게 사용하면 구현 세부 사항에 결합돼 테스트 리펙터링 내성이 없게 됨.
- 고전파는 테스트 간에 공유하는 의존성(대부분 SMTP 서비스나 메시지 버스 등 프로세스 외부 의존성만 해당)만 교체하자고 하기에 런던파보다 better!
- 하지만 런던파 만큼은 아니지만, 고전파도 시스템 간 통신 처리가 이상적이진 않고, 목 사용을 지나치게 장려.
5.4.1 모든 프로세스 외부 의존성을 목으로 해야 하는 것은 아니다
- 프로세스 외부 의존성과의 통신은 외부에서 관찰할 수 없으면 구현 세부 사항이다.
- 리팩터링 후에 그대로 유지할 필요가 없으므로 목으로 검증해서는 안된다.
5.4.2 목을 사용한 동작 검증
- 목은 애플리케이션의 경계를 넘나드는 상호 작용을 검증할 떄
- 이러한 상호 작용의 부작용이 외부 환경에서 보일 때 만 동작과 관련이 있다.