본문 바로가기
Book

[독서 기록] 단위 테스트, 블라디미르 코리코프

by Renechoi 2024. 1. 26.
 
단위 테스트
소프트웨어 개발에 있어 단위 테스트는 이제 선택이 아니라 필수가 됐다. 단위 테스트에 대한 오해를 바로잡고, 올바른 단위 테스트에 대한 원칙, 테스트를 작성하는 스타일과 효과적인 테스트를 위한 소프트웨어 아키텍처를 이해할 수 있다. 또한 단위 테스트를 통합 테스트와 구분하고, 둘의 차이와 각각 활용법과 적절한 작성법, 안티 패턴 등을 알 수 있다.
저자
블라디미르 코리코프
출판
에이콘출판
출판일
2021.10.20

 

 

 

단위 테스트는 단순히 테스트를 작성하는 것보다 더 큰 범주다. 단위 테스트에 시간을 투자할 때는 항상 최대한 이득을 얻도록 노력해야 ㅎ하며, 테스트에 드는 노력을 가능한 한 줄이고 그에 따르는 이득을 최대화해야 한다. 

- 29p 

 

 

모든 새로운 기술과 마찬가지로 단위 테스트도 계속 발전하고 있다, 논쟁은 '단위 테스트를 작성해야 하는가?'에서 '좋은 단위 테스트를 작성하는 것은 어떤 의미인가?'로 바뀌었다. 

- 31p 

 

 

코드 베이스에 대해 단위 테스트 작성이 필요하면 일반적으로 더 나은 설계로 이어진다. 하지만 단위 테스트의 주목표는 아니다. 더 나은 설계는 단지 좋은 부수 효과일 뿐이다.

- 32p 

 

 

그럼 단위 테스트의 목표는 무엇인가? 소프트웨어 프로젝트의 지속 가능한 성장을 가능하게 하는 것이다. 지속 가능하다는 것이 핵심이다.

- 33p 

 

 

그림 1.1은 테스트가 없는 일반 프로젝트의 성장 추이를 보여준다. 처음에는 발목을 잡을 것이 없으므로 빨리 시작핳ㄹ 수 있다. 아직 잘못된 아키텍처 결정이 없고, 걱정할 만한 코드가 있지도 않다. 그러나 시간이 지나면서 점점 더 많은 시간을 들여야 처음에 보여준 것과 같은 정도의 진척을 낼 수 있다. 결국 개발 속도가 현저히 느려지고, 심지어 전혀 진행하지 못할 정도로 느려질 수도 있다.

 

1.1 테스트 유무에 따른 프로젝트 간 성장 추이의 차이. 테스트가 없는 프로젝트의 경우 시작은 유리하지만 이내 진척이 없을 정도로 느려진다.

 

 

개발 속도가 빠르게 감소하는 이러한 현상을 소프트웨어 엔트로피라고도 한다. 엔트로피는 소프트웨어 시스템에도 적용ㅎ할 수 있는 수학적이고 과학적인 개념이다. 

 

- 33p 

 

 

 

테스트로 이러한 경향을 뒤집을 수 있다. 테스트는 안전망 역할을 하며, 대부분의 회귀에 대한 보험을 제공하는 도구라 할 수 있다. 테스트는 새로운 기능을 도입하거나 새로운 요구 사항에 더 잘 맞게 리팩터링한 후에도 기존 기능이 잘 작동하는지 확인하는 데 도움이 된다.

- 34p 

 

 

이러한 테스트는 초반에 노력이 필요하다는 것이다. 그러나 프로젝트 후반에도 잘 성장할 수 있도록 하므로 장기적으로 보면 그 비용을 메울 수 있다. 코드베이스를 지속적으로 검증하는 테스트 없이는 소프트웨어 개발이 쉽게 확장되지 않는다. 

 

지속성과 확장성이 핵심이며, 이를 통해 장기적으로 개발 속도를 유지할 수 있다. 

 

- 34p 

 

 

 

 

사람들은 종종 제품 코드와 테스트 코드가 다르다고 생각한다. 테스트는 제품 코드에 추가된 것으로 간주되며 소유 비용이 없다. 또한 사람들은 종종 테스트가 많을수록 좋다고 생각한다. 하지만 그렇지 않다. 코드는 자산이 아니라 책임이다. 코드가 더 많아질수록, 소프트웨어 내의 잠재적인 버그에 노출되는 표면적이 더 넓어지고 프로젝트 유지비가 증가한다. 따라서 가능한 한 적은 코드로 문제를 해결하는 것이 좋다. 

 

- 36p 

 

 

 

코드 커버리지가 너무 적을 때는(예를 들면, 10%) 테스트가 충분치 않다는 좋은 증거다. 그러나 반대의 경우는 그렇지 못하다. 100% 커버리지라고 해서 반드시 양질의 테스트 스위트라고 보장하지는 않는다. 높은 커버리지의 테스트 스위트도 품질이 떨어질 수 있다.

- 37p 

 

 

 

 

가장 많이 사용되는 커버리지 지표로 코드 커버리지가 있으며, 테스트 커버리지로도 알려져 있다. 이 지표는 하나 이상의 테스트로 실행된 코드라인 수와 제품 코드 베이스의 전체 라인 수의 비율을 나타낸다. 

 

코드 커버리지(테스트 커버리지) = 실행 코드 라인 수 / 전체 라인 수 

 

- 37p 

 

 

분기 커버리지 = 통과 분기 / 전체 분기 수 

- 39p 

 

 

 

시스템의 핵심 부분은 커버리지를 높게 두는 것이 좋다. 하지만 이 높은 수준을 요구 사항으로 삼는 것은 좋지 않다. 그 차이는 미미하지만 매우 중요하다. 

- 44p 

 

 

코드 커버리지를 측정하는 것은 품질 테스트 스위트로 가는 첫걸음일 뿐이다.

- 45p 

 

 

 

무엇이 성공적인 테스트 스위트를 만드는가? 

- 개발 주기에 통합돼 있다.

- 코드베이스에서 가장 중요한 부분만을 대상으로 한다.

- 최소한의 유지비로 최대의 가치를 끌어낸다.

 

- 45p 

 

 

 

단위 테스트는 

- 작은 코드 조각을 검증하고,

- 빠르게 수행하고,

- 격리된 방식으로 처리하는 자동화된 테스트다.

 

- 52p 

 

 

 

고전적 접근법은 '디트로이트'라고도 하며, 때로는 단위 테스트에 대한 고전주의적 접근법이라고도 한다. 아마도 고전파의 입장에서 가장 고전적인 책은 켄트 백이 지은 '테스트 주도 개발(인사이트, 2014)'일 것이다. 

 

런던 스타일은 때때로 '목 추종자'로 표현된다. 

- 52p 

 

cf. Growing Object-Oriented Software, Guided by Tests (테스트 주도 개발로 배우는 객체 지향 설계와 실천) 

 

 

 

 

격리 문제에 대한 런던파의 접근 

 

코드 조각을 격리된 방식으로 검증한다는 것은 무엇을 의미하는가? 런던파에서는 테스트 대상 시스템을 협력자에게서 격리하는 것을 일컫는다. 즉, 하나의 클래스가 다른 클래스 또는 여러 클래스에 의존하면 이 모든 의존성을 테스트 대역으로 대체해야 한다. 이런 식으로 동작을 외부 영향과 분리해서 테스트 대상 클래스에만 집중할 수 있다. 

- 53p 

 

 

 

온라인 상점을 운영한다고 가정하자. 샘플 애플리케이션에는 고객이 제품을 구매할 수 있다는 간단한 유스케이스가 하나 있다. 상점에 재고가 충분하면 구매는 성공으로 간주되고, 구매 수량만큼 상점의 제품 수량이 줄어든다. 제품이 충분하지 않으면 구매는 성공하지 못하며 상점에 아무 일도 일어나지 않는다. 

 

고전적인 스타일로 작성된 테스트 

 

public void Purchage_succeeds_when_enough_inventory(){ 
	// 준비
    var store = new Store();
    store.AddInventory(Product.shampoo, 10);
    var customer = new Customer();
    
    // 실행
    bool success = customer.Purchase(store, Product.Shampoo, 5);
    
    // 검증
    Assert.True(success);
    Assert.Equals(5, store.GetInventory(Product.Shampoo)); <- 감소 
}


public void Purchase_fails_when_not_enough_inventory(){
	// 준비
    var store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    var customer = new Customer();
    
    // 실행
    bool success = customer.Purchase(store, Product.Shampoo, 15);
    
    // 검증
    Assert.False(success);
    Assert.Equal(10, store.GetInventory(Product.Shampoo)); <- 변화 없음 
}

public enum Product{ 
	Shampoo,
    Book
}

 

 

이 코드는 단위 테스트의 고전 스타일 예로, 테스트는 협력자를 대체하지 않고 운영용 인스턴스를 사용한다. 고전적인 방식의 자연스러운 결과로, 이제 customer만이 아니라 Cusotmer와 Store 둘 다 효과적으로 검증한다. 그러나 Customer가 올바르게 작동하더라도 Cusotmer에 영향을 미치는 Store 내부에 버그가 있으면 단위 테스트에 실패할 수 있다. 테스트에서 두 클래스는 서로 격리되어 있지 않다. 

 

- 54~57p

 

 

 

 

런던 스타일로 작성된 단위 테스트 

 

public void Purchase_succeeds_when_enough_inventory(){
	// 준비 
    var storeMock = new Mock<IStore>();
    storeMock
    	.setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
        .Returns(true);
    var customer = new Customer();
    
    // 실행
    bool success = customer.Purchase(storeMock.Object, Product.Shampoo, 5); 
    
    // 검증
    Assert.True(success);
    storeMock.Verify(
    	x => x.RemoveInventory(Product.Shampoo, 5), Times.Once); 
}
 
 
 ///

 

 

고전 스타일로 작성된 테스트와 얼마나 다른지 살펴보자. 준비 단계에서 테스트는 Store의 실제 인스턴스를 생성하지 않고 Moq의 내장 클래스인 Mock<T>를 사용해 대체한다. 

 

또한 샴푸 재고를 추가해 Store 상태를 수정하는 대신 HasEnoughInventory() 메서드 호출에 어떻게 응답하는지 목에 집적 정의한다. Store의 실제 상태와 관계없이 테스트가 요구하는 방식으로 요청에 응답한다. 사실 테스트는 더 이상 Store를 사용하지 않는다 .Store 클래스 대신 IStore 인터페이스로 목을 만들어 사용했다. 

 

- 59p 

 

 

다시 말하면, 런던 스타일은 테스트 대역(목)으로 테스트 대상 코드 조각을 분리해서 격리 요구 사항에 다가간다. 

- 60p 

 

런던파는 테스트 대상 시스템에서 협력자를 격리하는 것으로 보는 반면, 고전파는 단위 테스트끼리 격리하는 것으로 본다.

 

  격리 주체 단위의 크기 테스트 대역 사용 대상
런던파 단위 단일 클래스 불변 의존성 외 모든 의존성
고전파 단위 테스트 단일 클래스 또는 클래스 세트 공유 의존성

 

 

- 64p 

 

 

 

런던파의 접근 방식은 다음과 같은 이점을 제공한다.

- 입자성이 좋다. 테스트가 세밀해서 한 번에 한 클래스만 확인한다.

- 서로 연결된 클래스의 그래프가 커져도 테스트하기 쉽다. 모든 협력자는 테스트 대역으로 대체되기 때문에 테스트 작성 시 걱정할 필요가 없다.

- 테스트가 실패하면 어떤 기능이 실패했는지 확실히 알 수 있다. 클래스의 협력자가 없으면 테스트 대상 클래스 외에 다른 것을 의심할 여지가 없다. 물론 테스트 대상 시스템이 값 객체를 사용하는 상황이 있을 수 있으며, 이 값 객체의 변경으로 인해 테스트가 실패하게 된다. 그러나 테스트 내 다른 의존성을 모두 제거했기 때문에 이러한 경우는 흔하지 않다.

- 69p 

 

 

 

고전파와 런던파 사이에 남아있는 두 가지 차이점은 다음과 같다.

- 테스트 주도 개발을 통한 시스템 설계 방식

- 과도한 명세 문제 

 

- 72p 

 

 

테스트 주도 개발은 테스트에 의존해 프로젝트 개발을 추진하는 소프트웨어 개발 프로세스다. 이 프로세스는 세 단계로 구성되며, 각 테스트 케이스마다 반복해서 적용한다.

1. 추가해야 할 기능과 어떻게 동작해야 하는지를 나타내는 실패 테스트를 작성한다.

2. 테스트가 통과할 만큼 충분히 코드를 작성한다. 이 단계에서 크도가 깨끗하거나 명쾌할 필요는 없다.

3. 코드를 리팩터링한다. 통과 테스트 보호하에서 코드를 안전하게 정리해 좀 더 읽기 쉽고 유지하기 쉽도록 만들 수 있다.

- 72p 

 

 

엔드 투 엔드 테스트라는 개념도 따로 있다. 엔드 투 엔드 테스트는 통합 테스트의 일부다. 엔드 투 엔드 테스트도 코드가 프로세스 외부 종속성과 함께 어떻게 작동하는지 검증한다. 엔드 투 엔드 테스트와 통합 테스트 간의 차이점은 엔드 투 엔드 테스트가 일반적으로 의존성을 더 많이 포함한다는 것이다.

 

가끔 경계가 흐리지만, 일반적으로 통합 테스트는 프로세스 외부 의존성을 한 두개만 갖고 작동한다. 반면에 엔드 투 엔드 테스트는 프로세스 외부 의존성을 전부 또는 대다수 갖고 작동한다. 따라서 엔드 투 엔드라는 명칭은 모든 외부 애플리케이션을 포함해 시스템을 최종 사용자의 관점에서 검증하는 것을 의미한다. 

 

- 75p 

 

 

런던파는 테스트 대상 단위를 서로 분리해야 한다고 한다. 테스트 대상 단위는 코드의 단위, 보통 단일 클래스다. 불변 의존성을 제외한 모든 의존성을 테스트 대역으로 대체해야 한다.

 

고전파는 단위가 아니라 단위 테스트를 서로 분리해야 한다고 한다. 또한 테스트 대상 단위는 코드 단위가 아니라 동작 단위다. 따라서 공유 의존성만 테스트 대역으로 대체해야 한다. 공유 의존성은 테스트가 서로 실행 흐름에 영향을 미치는 수단을 제공하는 의존성이다. 

 

- 77p 

 

 

 

런던파의 장점이 처음에는 매력적으로 보인다. 그러나 몇 가지 문제가 있다. 먼저 테스트 대상 클래스에 대한 초점이 잘못됐다. 테스트는 코드 단위가 아니라 동작 단위를 검증해야 한다. 더욱이 코드 조각을 단위 테스트할 수 없다는 것은 코드 설계에 문제가 있다는 사실을 알려주는 강한 징후다. 

- 77p 

 

 

 

테스트 내 if문 피하기 

 

준비, 실행, 검증 구절이 여러 차례 나타나는 것과 비슷하게, if 문이 있는 단위 테스트를 만날 수 있다. 이것도 안티 패턴이다. 단위 테스트든 통합테스트든 분기가 없는 간단한 일련의 단계여야 한다. 

- 83p 

 

 

 

테스트 간의 높은 결합도는 안티 패턴이다.

- 93p 

 

 

 

공통 초기화 코드를 비공개 팩토리 메서드로 추출해 테스트 코드를 짧게 하면서 동시에 테스트 진행 상황에 대한 전체 맥락을 유지할 수 있다. ... 예를 들어, 

Store store = CreateStoreWithInventory(Product.Shampoo, 10);

- 95p 

 

 

should be 문구는 또 다른 일반적인 안티 패턴이다. 이 장의 앞부분에서 하나의 테스트는 동작 단위에 대해 단순하고 원자적인 사실이라고 했다. 사실을 서술할 때는 소망이나 욕구가 들어가지 않는다. 여기에 따라 테스트 이름을 짓자. should be를 다음과 같이 is로 바꿔보자. 

...

public void Delivery_with_a_past_date_is_invalid()

 

- 101

 

 

 

 

좋은 단위 테스트의 4가지 특성 

- 회귀 방지 (소프트웨어 버그 방지) 

- 리팩터링 내성

- 빠른 피드백

- 유지 보수성 

 

- 114p 

 

 

 

 

좋은 단위 테스트의 두 번째 특성은 리팩터링 내성이다. 이는 테스트를 '빨간색'으로 바꾸지 않고 기본 애플리케이션 코드를 리팩터링할 수 있는지에 대한 척도다.

 

- 116p 

 

 

 

테스트와 대상 시스템의 구현 세부 사항이 많이 결합할수록 허위 경보가 더 많이 생긴다. 거짓 양성이 생길 가능성을 줄이는 방법은 해당 구현 세부 사항에서 테스트를 분리하는 것뿐이다. 테스트를 통해 SUT가 제공하는 최종 결과를 검증하는지 확인해야 한다. 테스트는 최종 사용자의 관점에서 SUT를 검증해야 하고 최종 사용자에게 의미 있는 결과만 확인해야 한다. 다른 모든 것은 무시해야 한다.

- 119p 

 

 

 

즉, 코드의 내부 작업과 테스트 사이를 가능한 한 멀리 떨어뜨리고 최종 결과를 목표로 하는 것이다. 

- 123p 

 

 

MessageRendere에서 생성하는 결과 검증 

 

[Fact]
public void Rendering_a_message(){ 
	var sut = new MessageRenderer();
    var message = new Message{
    	Header = "h",
        Body = "b",
        Footer ="f"
    };
    String html = sut.Render(message);
    Assert.Equal("<h1>h</h1><b>b</b><i>f</<i>", html);
}

 

 

이 테스트는 MessageRenderer를 블랙박스로 취급하고 식별할 수 있는 동작에만 신경쓴다. 결과적으로 테스트는 리팩터링 내성이 부쩍 늘었다. 즉 Html 출력을 똑같이 지키지만 SUT의 변경 사항은 테스트에 영향을 미치지 않는다. 

 

-124p 

 

 

 

회귀 방지와 리팩터링 내성은 테스트 스위트의 정확도를 극대화하는 것을 목표로 한다. 정확도 지표는 다음 두 가지 요소로 구성된다.

- 테스트가 버그 있음을 얼마나 잘 나타내는가(거짓 음성(회귀 방지 영역) 제외).

- 테스트가 버그 없음을 얼마나 잘 나타내는가(거짓 양성(리팩터링 내성 영역) 제외).

 

 

- 127p 

 

 

 

- 테스트가 얼마나 이해하기 어려운가

- 테스트가 얼마나 실행하기 어려운가

- 130p 

 

 

 

 

엔드 투 엔드 테스트 - 리팩터링 내성 x 회귀 방지 

간단한 테스트 - 리팩터링 내성 x 빠른 피드백 

깨지기 쉬운 테스트 - 회귀 방지 x 빠른 피드백 

 

=> 좋은 단위 테스트의 처음 세 가지 특성은 상호 배타적이다. 이 세 가지 특성 중 두 가지를 극대화하는 테스트를 만들기는 매우 쉽지만, 나머지 특성 한 가지를 희생해야만 가능하다.

- 136p 

 

 

리팩터링 내성은 타협할 수 없다. 테스트에 이 속성이 있는지 여부는 대부분 이진 선택, 즉 리팩터링 내성을 갖고 있거나 갖고 있지 않거나 둘 중 하나이기 때문이다. 특성 간의 절충은 회귀 방지와 빠른 피드백 사이의 선택으로 귀결된다.

- 145p 

 

 

 

테스트 대역 

- 목 (목, 스파이)

- 스텁( 스텁, 더미, 페이크)

 

- 149p 

 

- 목은 외부로 나가는 상호 작용을 모방하고 검사하는 데 도움이 된다. 이러한 상호작용은 SUT가 상태를 변경하기 위한 의존성을 호출하는 것에 해당한다.

- 스텁은 내부로 들어오는 상호작용을 모방하는 데 도움이 된다. 이러한 상호 작용은 SUT가 입력 데이터를 얻기 위한 의존성을 호출하는 것에 해당한다.

- 149p 

 

 

 

 

각 계층의 API를 잘 설계하면(즉, 구현 세부 사항을 숨기면) 테스트도 프랙탈 구조를 갖기 시작한다. 즉, 달성하는 목표는 같지만 서로 다른 수준에서 동작을 검증한다. 애플리케이션 서비스를 다루는 테스트는 해당 서비스가 외부 클라이언트에게 매우 중요하고 큰 목표를 어떻게 이루는지 확인한다. 그리고 도메인 클래스 테스트는 그 큰 목표의 하위 목표를 검증한다.

- 168p 

 

 

연산을 수행하기 위한 도메인 클래스 간의 협력은 식별할 수 있는 동작이 아니므로 시스템 내부 통신은 구현 세부 사항에 해당한다. 이러한 협력은 클라이언트의 목표와 직접적인 관계가 없다. 따라서 이러한 협력과 결합하면 테스트가 취약해진다.

- 171p 

 

 

 

목을 사용하면 시스템과 외부 애플리케이션 간의 통신 패턴을 확인할 때 좋다. 반대로 시스템 내 클래스 간의 통신을 검증하는 데 사용하면 테스트가 구현 세부 사항과 결합되며, 그에 따라 리팩터링 내성 지표가 미흡해진다.

- 172p 

 

 

취약한 테스트로 이어지지 않는 목 사용 

[Fact]
public void Successful_purchase(){
	var mock = new Mock<IEmailGateway>();
    var sut = new CustomerController(mock.Object);
    
    bool isSuccess = sut.Purchase(ustomerId: 1, productId: 2, quantity: 5); 
    
    Assert.True(isSuccess);
    mock.Verify(x => x.SendRecipt("customer@email.com", "Shampoo", 5), Times.Once);
}

 

isSucess 플래그는 외부 클라이언트에서도 확인할 수 있으며, 검증도 필요하다. 하지만 이 플래는 목이 필요 없고 간단한 값 비교만으로 충분하다. 

 

이제 Customer 클래스와 Store 클래스 간의 통신에 목을 사용한 테스트를 살펴보자. 

 

[Fact]
public void Purchase_succeeds_when_enough_inventory(){
	var storeMock = new Mock<IStore>();
    storeMock.Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
    .Returns(true);
    var customer = new Customer();
    
    bool success = customer.Purchase(storeMock.Object, Product.Shampoo, 5);
    Assert.True(success);
    storeMock.Verify( x=> x.RemoveInventory(Product.Shampoo, 5), Times.Once);
}

 

- 174~176p 

 

 

 

 

2장에서 나는 런던파보다 고전파를 더 선호한다고 했다. 이제 그 이유를 알 수 있길 바란다. 런던파는 불변 의존성을 제외한 모든 의존성에 목 사용을 권장하며 시스템 내 통신과 시스템 간 통신을 구분하지 않는다. 그 결과, 테스트는 애플리케이션과 외부 시스템 간의 통신을 확인하는 것처럼 클래스 간 통신도 확인한다.

 

런던파를 따라 목을 무분별하게 사용하면 종종 구현 세부 사항에 결합돼 테스트에 리팩터링 내성이 없게 된다. 

 

- 177p 

 

 

 

단위 테스트의 세 가지 스타일

- 출력 기반 테스트

- 상태 기반 테스트

- 통신 기반 테스트 

 

- 184p 

 

 

 

함수형 아키텍처는 모든 코드를 함수형 코어와 가변 셸이라는 두 가지 범주로 나눈다. 가변 셸은 입력 데이터를 함수형 코어에 공급하고, 코어가 내린 결정을 부작용으로 변환한다.

- 224p 

 

 

함수형 아키텍처와 육각형 아키텍처의 차이는 부작용의 처리에 있다. 함수형 아키텍처는 모든 부작용을 도메인 계층 밖으로 밀어낸다. 이와 반대로, 육각형 아키텍처는 도메인 계층에만 한정돼 있는 한은 도메인 계층에 의해 만들어진 부작용도 괜찮다. 극단적으로 함수형 아키텍처는 육각형 아키텍처다.

- 225p 

 

 

 

험블 객체 패턴을 사용해 지나치게 복잡한 코드 분할하기 

- 233p 

 

 

 

1단계: 암시적 의존성을 명시적으로 만들기

2단계 애플리케이션 서비스 계층 도입

3단계: 애플리케이션 서비스 복잡도 낮추기

4단계: 새 Company 클래스 소개 

- 244p 

 

 

테스트 용이성을 개선하는 일반적인 방법은 암시적 의존성을 명시적으로 만드는 것이다. 즉, 데이터베이스와 메시지 버스에 대한 인터페이스를 두고, 이 인터페이스를 User에 주입한 후 테스트에서 목으로 처리한다.

- 239p 

 

 

결국 도메인 모델은 직접적으로든 간접적으로든 (인터페이스를 통해) 프로세스 외부 협력자에게 의존하지 않는 것이 훨씬 깔끔하다. 이것이 바로 육각형 아키텍처에서 바라는 바다. 도메인 모델은 외부 시스템과의 통신을 책임지지 않아야 한다.

-239p 

 

 

 

통합 테스트에서 실제 데이터베이스를 사용할 수 없으면 어떻게 할까?

 

관리 의존성임에도 불구하고 데이터베이스를 목으로 처리해야 할까? 그렇지 않다. 관리 의존성을 목으로 대체하면 통합 테스트의 리팩터링 내성이 저하되기 때문이다. 게다가 이렇게 하면 테스트는 회귀 방지도 떨어진다. 그리고 데이터베이스가 프로젝트에서 유일한 외부 의존성이면, 통합 테스트는 회귀 방지에 있어 기존 단위 테스트 세트와 다를 바 없다.

... 

데이터베이스를 그대로 테스트할 수 없으면 통합 테스트를 아예 작성하지 말고 도메인 모델의 단위 테스트에만 집중하라. 항상 모든 테스트를 철저히 검토해야 한다. 가치가 충분하지 않은 테스트는 테스트 스위트에 있어서는 안 된다. 

- 281p 

 

 

 

 

스파이는 목과 같은 목적을 수행하는 테스트 대역이다. 스파이는 수동으로 작성하는 반면에 목은 목 프레임워크의 도움을 받아 생성한다는 것이 유일한 차이점이다. 실제로 스파이는 종종 직접 작성한 목이라고도 한다.

 

시스템 끝에 있는 클래스의 경우 스파이가 목보다 낫다. 스파이는 검증 단계에서 코드를 재사용해 테스트 크기를 줄이고 가독성을 향상시킨다. 다음 예제는 IBus 위에서 작동하는 스파이다. 

 

스파이(직접 작성한 목이라고도 함) 

 

public interface IBus{
	void Send(string message);
}

public class BusSpy : IBus{
	private List<String> _sentMessage =
    	new List<string><();
        
    public void Send(string message){
    	_sentMessages.Add(message);
    }
    
    ...
}

 

 

스파이 사용 

 

[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
	var busSpy = new BusSpy();
    var messageBus = new MessageBus(busSpy);
    var loggerMock = new Mock<<IDomainLogger>();
    var sut = new UserController(db, messageBus, loggerMock.Object);
    
    ...
    
    busSpy.shouldSendNumberOfMessages(1)
    	.withEmailChangedMessage(user.UserId, "new@gmail.com");
}

 

- 320~322p 

 

 

- 통합 테스트에서만 목을 사용하고 단위 테스트에서는 하지 않기

- 항상 목 호출 수 확인하기

- 보유 타입만 목으로 처리하기

 

- 325p 

 

 

비즈니스 연산은 데이터를 원자적으로 업데이트해야 한다. 원자성을 얻으려면 데이터베이스 트랜잭션 메커니즘에 의존하라.

- 366p 

 

테스트 구절마다 데이터베이스 트래잭션이나 작업 단위를 재사용하지 말라. 준비, 실행, 검증 구절에 각각 고유의 트랜잭션이나 작업 단위가 있어야 한다.

- 366p 

 

 

 

단위 테스트 안티 패턴 

- 비공개 메서드 단위 테스트 

- 단위 테스트를 하기 위한 비공개 메서드 노출

- 테스트로 유출된 도메인 지식

- 구체 클래스 목 처리 

 

- 371p 

 

 

 

복잡한 비공개 메서드가 있는 클래스 

 

 public class Order
    {
        private Customer _customer;
        private List<Product> _products;

        public string GenerateDescription()
        {
            return $"Customer name: {_customer.Name}, " +
                $"total number of products: {_products.Count}, " +
                $"total price: {GetPrice()}";
        }

        private decimal GetPrice()
        {
            decimal basePrice = /* Calculate based on _products */ 0;
            decimal discounts = /* Calculate based on _customer */ 0;
            decimal taxes = /* Calculate based on _products */ 0;
            return basePrice - discounts + taxes;
        }
    }

 

 

GenerateDescription() 메서드는 매우 간단하며, 주문에 대한 일반적인 설명을 반환한다. 그러나 훨씬 더 복잡한 GetPrice() 비공개 메서드를 사용한다. 중요한 비즈니스 로직이 있기 때문에 테스트를 철저히 해야 한다. 이 로직은 추상화가 누락됐다. GetPrice 메서드를 노출하기보다는 다음 예제와 같이 추상화를 별도의 클래스로 도출해서 명시적으로 작성하는 것이 좋다. 

 

public class OrderV2
    {
        private Customer _customer;
        private List<Product> _products;

        public string GenerateDescription()
        {
            var calculator = new PriceCalculator();

            return $"Customer name: {_customer.Name}, " +
                $"total number of products: {_products.Count}, " +
                $"total price: {calculator.Calculate(_customer, _products)}";
        }
    }

    public class PriceCalculator
    {
        public decimal Calculate(Customer customer, List<Product> products)
        {
            decimal basePrice = /* Calculate based on products */ 0;
            decimal discounts = /* Calculate based on customer */ 0;
            decimal taxes = /* Calculate based on products */ 0;
            return basePrice - discounts + taxes;
        }
    }

 

 

import java.util.List;

public class Order {
    private Customer customer;
    private List<Product> products;

    public String generateDescription() {
        return "Customer name: " + customer.getName() + 
               ", total number of products: " + products.size() +
               ", total price: " + getPrice();
    }

    private BigDecimal getPrice() {
        BigDecimal basePrice = /* Calculate based on products */ BigDecimal.ZERO;
        BigDecimal discounts = /* Calculate based on customer */ BigDecimal.ZERO;
        BigDecimal taxes = /* Calculate based on products */ BigDecimal.ZERO;
        return basePrice.subtract(discounts).add(taxes);
    }
}

public class OrderV2 {
    private Customer customer;
    private List<Product> products;

    public String generateDescription() {
        PriceCalculator calculator = new PriceCalculator();
        return "Customer name: " + customer.getName() + 
               ", total number of products: " + products.size() +
               ", total price: " + calculator.calculate(customer, products);
    }
}

public class PriceCalculator {
    public BigDecimal calculate(Customer customer, List<Product> products) {
        BigDecimal basePrice = /* Calculate based on products */ BigDecimal.ZERO;
        BigDecimal discounts = /* Calculate based on customer */ BigDecimal.ZERO;
        BigDecimal taxes = /* Calculate based on products */ BigDecimal.ZERO;
        return basePrice.subtract(discounts).add(taxes);
    }
}

** 자바 코드 버전 

 

 

 

- 373~374p 

 

 

 

 

 

시간 먼텍스트에서 앰비언트 컨택스트로(now)로 사용하는 것도 안티 패턴 

- 388p 

 

 

 

테스트를 작성할 때 특정 구현을 암시하지 말라. 블랙박스 관점에서 제품 코드를 검증하라. 또한 도메인 지식을 테스트에 유출하지 않도록 하라.

- 391p 

 

코드 오염은 테스트에만 필요한 제품 코드를 추가하는 것이다. 

- 391p 

 

기능을 지키려고 구체 클래스를 목으로 처리해야 한다면, 이는 단일 책임 원칙을 위반하는 결과다. 해당 클래스를 두 가지 클래스, 즉 도메인 로직이이 있는 클래스와 프로세스 외부 의존성과 통신하는 클래스로 분리하라.

- 391p 

 

현재 시간을 앰비언트 컨텍스트로 하면 제품 코드가 오염되고 테스트하기가 더 어려워진다. 서비스나 일반 값의 명시적인 의존성으로 시간을 주입하라. 가능하면 항상 일반 값이 좋다.

- 391p 

 

 

반응형