-
단위 테스트 정리 3 - 목과 테스트 취약성책책책 책을 읽읍시다/프로그래밍 2022. 7. 4. 23:17
목과 테스트 취약성
테스트 대역은 모든 유형의 비운영용 가짜 의존성을 설명하는 포괄적인 용어다. 주 용도는 테스트를 편리하게 하는 것이고, 테스트 대상 시스템으로 실제 의존성 대신 전달되므로 설정이나 유지 보수가 어려울 수 있다.
테스트 대역은 더미, 스텁, 스파이, 목 페이크 총 5가지가 있고, 크게 목과 스텁의 두 가지 유형으로 나눌 수 있다.
- 목은 외부로 나가는 상호 작용을 모방하고 검사하는 데 도움이 된다. 이러한 상호 작용은 SUT가 상태를 변경하기 위한 의존성을 호출하는 것에 해당한다.
- 스텁은 내부로 들어오는 상호 작용을 모방하는 데 도움이 된다. 이러한 상호 작용은 SUT가 입력 데이터를 얻기 위한 의존성을 호출하는 것에 해당한다.
목은 SUT와 관련 의존성 간의 상호 작용을 모방하고 검사하는 반면, 스텁은 모방만 한다.
테스트는 ‘어떻게’가 아니라 ‘무엇’에 중점을 둬야 한다. 구현 세부 사항과 테스트를 가능한 떨어뜨리며 식별할 수 있는 동작인 최종 결과를 검증해야 한다. 식별할 수 있는 동작과 내부 구현 세부 사항에는 미묘한 차이가 있다. 코드가 시스템의 식별할 수 있는 동작이라면 다음 중 하나를 해야 한다.
- 클라이언트가 목표를 달성하는 데 도움이 되는 연산(operation)을 노출하라. 연산은 계산을 수행하거나 부작용을 초래하거나 둘 다 하는 메서드다.
- 클라이언트가 목표를 달성하는 데 도움이 되는 상태(state)를 노출하라. 상태는 시스템의 현재 상태다.
구현 세부 사항은 이 두 가지 중 아무것도 하지 않는다. 코드가 식별할 수 있는 동작인지 여부는 해당 클라이언트가 누구인지, 그리고 해당 클라이언트의 목표가 무엇인지에 달려 있다. 식별할 수 있는 동작이 되려면 코드가 이러한 목표 중 하나에라도 직접적인 관계가 있어야 한다. 클라이언트라는 단어는 코드가 있는 위치에 따라 다른 것을 의미할 수 있다. 흔한 예로 동일한 코드베이스, 외부 애플리케이션, 또는 사용자 인터페이스 등의 클라이언트 코드가 있다.
이상적으로 시스템의 공개 API는 식별할 수 있는 동작과 일치해야 하며, 모든 구현 세부 사항은 클라이언트 눈에 보이지 않아야 한다. 이러한 시스템은 API 설계가 잘돼 있다. 그러나 종종 시스템의 공개 API가 식별할 수 있는 동작의 범위를 넘어 구현 세부 사항을 노출한다.
구현 세부 사항을 유출하는 User 클래스
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 NormalizeName = user.NormalizeName(newName); user.Name = NormalizeName; SaveUserToDatabase(user); } }
UserContorller는 클라이언트 코드이며 RenameUser 메서드에서 User 클래스를 사용한다. 이 메서드의 목표는 사용자 이름을 변경하는 것이다.
속성과 메서드가 둘 다 공개인데, 클래스 APi를 잘 설계하려면 해당 멤버가 식별할 수 있는 동작이 되게 해야 한다. 이를 위해서는 다음 두 가지 중 하나를 해야 한다.
- 클라이언트가 목표를 달성하는 데 도움이 되는 작업을 노출하라.
- 클라이언트가 목표를 달성하는 데 도움이 되는 상태를 노출하라.
Name 속성만 이 요구 사항을 충족한다. UserController가 사용자 이름 변경이라는 목표를 달성할 수 있도록 하는 세터(setter)를 노출한다. NormalizeName 메서드도 작업이지만, 클라이언트의 목표에 직결되지 않는다. UserController가 이 메서드를 호출하는 유일한 이유는 User의 불변 속성을 만족시키는 것이다. NormalizeName은 클래스의 공개 API로 유출되는 구현 세부 사항이다.
public Class User { private string _name; public string Name { get => _name; set => _name = NormalizeName(value); 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); user.Name = newName; SaveUserToDatabase(user); } }
이제 User API는 식별할 수 있는 동작(Name 속성)만 공개돼있고, 구현 세부 사항(NormalizeName 메서드)은 비공개 API 뒤에 숨겨져 있어 잘 설계되었다고 할 수 있다.
클래스가 구현 세부 사항을 유출하는지 판단하는 데 도움이 되는 유용한 규칙이 있다. 단일한 목표를 달성하고자 클래스에서 호출해야 하는 연산의 수가 1보다 크면 해당 클래스에서 구현 세부 사항을 유출할 가능성이 있다. 이상적으로는 단일 연산으로 개별 목표를 달성해야 한다. 예를들어 UserCOntorller는 User의 두 가지 작업을 사용해야 했다.
string normalizeName = user.NormalizeName(newName);
user.Name = normalizeName;
리팩터링 후에 연산 수가 1로 감소했다.
user.Name = newName;
잘 설계된 API를 유지 보수하는 것은 캡슐화 개념과 관련이 있다. 캡슐화는 불변성 위반이라고도 하는 모순을 방지하는 조치다. 불변성은 항상 참이어야 하는 조건이다. User 클래스에는 사용자 이름이 50자를 초과하면 안 된다는 불변성이 있었다.
불변성 위반으로 구현 세부 사항을 노출하게 된다. 즉, 구현 세부 사항을 노출하면 불변성 위반을 가져온다. 원래 버전의 User는 구현 세부 사항을 유출할 뿐만 아니라 캡슐화를 제대로 유지하지 못했다. 클라이언트는 불변성을 우회해서 이름을 먼저 정규화하지 않고 새로운 이름을 할당할 수 있었다.
코드 API가 해당 코드로 할 수 있는 것과 할 수 없는 것을 알려주지 않으면 코드가 변경 됐을 때 모순이 생기지 않도록 많은 정보를 염두에 둬야 한다. 이는 프로그래밍 프로세스에 정신적 부담을 증대한다. 개발자 스스로가 항상 올게만 한다고 믿을 수는 없으므로 실수할 가능성을 최대한 없애라. 이렇게 하는 데 가장 좋은 방법은 캡슐화를 올바르게 유지해 코드베이스에서 잘못할 수 있는 옵션조차 제공하지 않도록 하는 것이다.
‘묻지 말고 답하라(tell-don’t-ask)라는 유사한 원칙이 있다. 마틴 파울러가 고안한 것으로, 데이터를 연산 기능과 결합하는 것을 의미한다. 이 원칙을 캡슐화 실천의 귀결로 볼 수 있다. 코드 캡슐화가 목표이지만, 구현 세부 사항을 숨기고 데이터와 기능을 결합하는 것이 해당 목표를 달성하기 위한 수단이다.
- 구현 세부 사항을 숨기면 클라이언트의 시야에서 클래스 내부를 가릴 수 있기 때문에 내부를 손상시킬 위험이 적다.
- 데이터와 연산을 결합하면 해당 연산이 클래스의 불변성을 위반하지 않도록 할 수 있다.
육각형 아키텍처
육각형 아키텍처의 목적은 세 가지 중요한 지침을 강조하는 것이다.
- 도메인 계층과 애플리케이션 서비스 계층 간의 관심사 분리 : 도메인 계층은 해당 비즈니스 로직에 대해서만 책임을 져야 하며, 다른 모든 책임에서는 제외돼야 한다. 외부 애플리케이션과 통신하거나 데이터베이스에서 데이터를 검색하는 것과 같은 책임은 애플리케이션 서비스에 귀석돼야 한다. 반대로 애플리케이션 서비스에는 어떤 비즈니스 로직도 있으면 안 된다. 요청이 들어오면 도메인 클래스의 연산으로 변환한 다음 결과를 저장하거나 호출자에게 다시 반환해서 도메인 계층으로 변환하는 책임이 있다. 도메인 계층을 애플리케이션의 도메인 지식(사용 방법) 모음으로, 애플리케이션 서비스 계층을 일련의 비즈니스 유스케이스(사용 대상)로 볼 수 있다.
- 애플리케이션 내부 통신 : 육각형 아키텍처에는 애플리케이션 서비스 계층에서 도메인 계층으로 흐르는 단방향 의존성 흐름을 규정한다. 도메인 계층 내부 클래스는 도메인 계층 내부 클래스끼리 서로 의존하고 애플리케이션 서비스 계층의 클래스에 의존하지 않는다. 도메인 계층은 외부 환경에서 완전히 격리돼야 한다.
- 애플리케이션 간의 통신 : 외부 애플리케이션은 애플리케이션 서비스 계층에 있는 공통 인터페이스를 통해 해당 애플리케이션에 연결된다. 아무도 도메인 계층에 직접 접근할 수 없다. 육각형의 각 면은 애플리케이션 내외부 연결을 나타낸다.
서로 다른 계층의 테스트는 동일한 동작을 서로 다른 수준으로 검증하는 프랙탈 특성이 있다. 애플리케이션 서비스 테스트는 전반적으로 비즈니스 유스케이스가 어떻게 실행되는지 확인한다. 도메인 클래스 테스트는 유스케이스 완료 방법에 대한 중간의 하위 목표를 검증한다.
식별할 수 있은 동작은 바깥 계층에서 안쪽으로 흐른다. 도메인 클래스의 경우 클라이언트는 애플리케이션 서비스에 해당하고, 애플리케이션 서비스이면 외부 클라이언트에 해당한다.
UserController는 애플리케이션 서비스다. 외부 클라이언트가 사용자 이름을 정규화하는 것과 같은 특정 목표가 없고 전적으로 애플리케이션의 제약에 의해 모든 이름을 정규화한다고 가정하면, User 클래스의 NormalizeNmae 메서드는 클라이언트의 요구 사항으로 추적할 수 없다. 따라서 구현 세부 사항이므로 비공개로 해야 한다. 또한 테스트에서 이 메서드를 직접 확인하면 안 된다. 클래스의 식별할 수 있는 동작(Name 속성의 세터)으로서만 검증해야 한다.
일반적인 애플리케이션에는 시스템 내부(inter-system)통신과 시스템 간(intra-system)통신이 있다. 시스템 내부 통신은 애플리케이션 내 클래스 간의 통신이다. 시스템 간 통신은 애플리ㅔ이션이 다른 애플리케이션과 통신하는 것을 말한다.
연산을 수행하기 위한 도메인 클래스 간의 협락은 식별할 수 있는 동작이 아니므로 시스템 내부 통신은 구현 세부 사항에 해당한다. 이러한 협력은 클라이언트의 목표와 직접적인 관계가 없다. 따라서 이러한 협력과 결합하면 테스트가 취약해진다.
목을 사용하면 시스템과 외부 애플리케이션 간의 통신 패턴을 확인할 때 좋다. 반대로 시스템 내 클래스 간의 통신을 검증하는 데 사용하면 테스트가 구현 세부 사항과 결합되며, 그에 따라 리팩터링 내성 지표가 미흡해진다.
시스템 내부 통신과 시스템 간 통신의 예
다음 비즈니스 유스케이스를 보라.
- 고객이 상점에서 제품을 구매하려고 한다.
- 매장 내 제품 수량이 충분하면
다음 예제에서 CustomerController 클래스는 도메인 클래스(Customer, Product, Store)와 외부 애플리케이션(SMTP 서비스의 프록시인 EmailGateway) 간의 작업을 조정하는 애플리케이션 서비스다.
public class CustomerController { public bool Purchase(int customerId, int productId, int quantity) { Customer customer = _customerRepository.GetById(customerId); Product product = _productRepository.GetById(productId); bool isSuccess = customer.Purchase( _mainStore, product, quantity); if (isSuccess) { _emailGateway.SendReceipt( customer.Email, product.Name, quantity ); } return isSuccess; } }
구매라는 동작은 시스템 내부 통신과 시스템 간 통신이 모두 있는 비즈니스 유스케이스다. 세스템 간 통신은 CustomerContorller 애플리케이션 서비스와 두 개의 외부 시스템인 서드파티 애플리케이션(유스케이스를 시작하는 클라이언트이기도 함)과 이메일 게이트웨이 간의 통신이다. 시스템 내부 통신은 Customer와 Store 도메인 클래스 간의 통신이다.
SMTP 서비스에 대한 호출은 외부 환경에서 볼 수 있는 부작용이므로 애플리케이션에 전체적으로 식별할 수 있는동작을 나타낸다. 또한 고객의 목표에 직접적인 연관이 있다. 애플리케이션의 클라이언트는 서드파티 시스템이다. 이 시스템의 목표는 구매를 하는 것이며, 고객이 성공적인 결과로써 이메일로 확인 내역을 받는 것을 기대한다.
SMTP 서비스에 대한 호출을 목으로 하는 이유는 타당하다. 리팩터링 후에도 이러한 통신 유형이 그대로 유지되도록 하기 때문에 테스트 취약성을 야기하지 않는다.
[Fact] public void Successful_purchase() { var mock = new Mock<IEmailGateway>(); var sut = new CustomerController(mock.Object); bool isSuccess = sut.Purchase(customerId: 1, productId: 2, quantity: 5); Assert.True(isSuccess); //시스템이 구매에 대한 영수증을 보는 검증 mock.Verify( x => x.SendReceipt("customer@email.com", "Shampoo", 5), Times.Once ); }
isSuccess 플래그는 외부 클라이언트에서도 확인할 수 있으며, 검증도 필요하다. 하지만 이 플래그는 목이 필요 없고, 간단한 값 비교만으로 충분하다.
다음은 취약한 테스트로 이어지는 목 사용 예이다.
[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 ); }
CustomerController의 SMTP 서비스 간의 통신과 달리, Customer 클래스에서 Store 클래스로의 메서드 호출은 애플리케이션 경계를 넘지 않는다. 호출자와 수신자 모두 애플리케이션 내에 있다. 또한 이 메서드는 클라이언트가 목표를 달성하는 데 도움이 되는 연산이나 상태가 아니다. 이 두 도메인 클래스의 클라이언트는 구매를 목표로 하는 CustomerController다. 이 목표에 직접적인 관련이 있는 멤버는 customer.Purchase()와 store.GetInventory(), 이렇게 둘뿐이다. Purchase() 메서드는 구매를 시작하고, GetInventory() 메서드는 구매가 완료된 후 시스템 상태를 보여준다. RemoveInventory() 메서드 호출은 고객의 목표로 가는 중간 단계(구현 세부 사항)에 해당한다.
런던파는 불변 의존성을 제외한 모든 의존성에 목 사용을 권장하며 시스템 내 통신과 시스템 간 통신을 구분하지 않는다. 그 결과, 테스트는 애플리케이션과 외부 시스템 간의 통신을 확인하는 것처럼 클래스 간 통신도 확인한다. 목을 무분별하게 사용하면 종종 구현 세부 사항에 결합돼 테스트에 리팩터링 내성이 없게 된다. 고전파는 테스트 간에 공유하는 의존성만 교체하자고 하므로 이 문제에 훨씬 유리하다.
모든 프로세스 외부 의존성을 목으로 대체해야 하는 것은 아니다. 애플리케이션에서만 사용되는 데이터베이스는 기존 기능을 손상시키지 않는 한 시스템과 애플리케이션 데이터베이스 간의 통신 패턴을 원하는 대로 수정할 수 있다. 해당 데이터베이스는 클라이언트의 시야에서 완전히 숨어있기 때문에 전혀 다른 저장 방식으로 대체할 수 있고, 그렇게 해도 아무도 모를 것이다. 이렇게 완전히 통제권을 가진 프로세스 외부 의존성에 목을 사용하면 깨지기 쉬운 테스트로 이어진다. 데이터베이스에서 테이블을 분할하거나 저장 프로시저에서 매개변수 타입을 변경할 때마다 테스트가 실패하는 것을아무도 원하지 않는다. 데이터베이스와 애플리케이션은 하나의 시스템으로 취급해야 한다. 하지만 피드백 속도를 저하시키기 때문에 문제가 될 수 있다.
'책책책 책을 읽읍시다 > 프로그래밍' 카테고리의 다른 글
단위 테스트 정리 6 - 통합 테스트 (0) 2022.08.03 단위 테스트 정리 5 - 가치 있는 단위 테스트를 위한 리팩터링 (0) 2022.08.03 단위 테스트 정리 4 - 목과 테스트 취약성 (0) 2022.07.04 단위 테스트 정리 2 - 좋은 단위 테스트의 4대 요소 (0) 2022.07.04 단위 테스트 정리 1 (0) 2022.06.21