본문 바로가기
책/단위 테스트

[단위 테스트] 7장 가치 있는 단위 테스트를 위한 리팩터링

by 오오오오니 2025. 3. 18.

7장 가치 있는 단위 테스트를 위한 리팩터링

2024년 5월 11일
💡
7장에서 다루는 내용
네 가지 코드 유형 알아보기
험블 객체 패턴 이해
가치 있는 테스트 작성

리팩터링할 코드 식별하기

제품 코드를 2차원으로 분류할 수 있다.
복잡도 또는 도메인 유의성
코드 복잡도는 코드에서 의사 결정 지점 수에 따라 명시적으로 그리고 암시적으로 정의된다.
도메인 유의성은 프로젝트의 문제 도메인에 대해 코드가 얼마나 중요한지를 보여준다. 복잡한 코드는 종종 도메인 유의성이 높고 그 반대의 경우도 있지만, 모든 경우에 100% 해당하지는 않는다.
복잡한 코드와 도메인 유의성을 갖는 코드는 해당 테스트의 회귀 방지가 뛰어나기 때문에 단위 테스트에서 가장 이롭다.
협력자 수
협력자가 많은 코드를 다루는 단위테스트틑 유지비가 많이 든다. 이러한 테스트는 협력자를 예상 상태로 만들고 나서 상태나 상호작용을 확인하고자 공간을 많이 필요로 한다.
모든 제품 코드는 복잡도 또는 도메인 유의성과 협력자 수에 따라 네 가지 유형의 코드로 분류 할 수 있다.
도메인 모델 및 알고리즘은 단위 테스트에 대한 노력 대비 가장 이롭다.
간단한 코드는 테스트할 가치가 전혀 없다. (매개 변수 없는 생성자)
컨트롤러는 통합 테스트를 통해 간단히 테스트해야 한다.
지나치게 복잡한 코드는 컨트롤러와 복잡한 코드로 분할해야 한다. (덩치가 큰 컨트롤러)
가장 문제가 되는 코드 유형은 지나치게 복잡한 코드이다.
험블 객체 패턴을 사용해 지나치게 복잡한 코드 분할하기
험블 객체 패턴은 해당 코드에서 비지니스 로직을 별도의 클래스로 추출해 복잡한 코드를 테스트할 수 있는 데 도움이 된다.
그 결과, 나머지 코드는 비즈니스 로직을 둘러싼 얇은 험블 래퍼, 즉 컨트롤러가 된다.
육각형 아키텍처와 함수형 아키텍처는 험블 객체 패턴을 구현한다.
육각형 아키텍처는 비즈니스 로직프로세스 외부 의존성과의 통신을 분리하도록 한다.
함수형 아키텍처는 프로세스 외부 의존성뿐만 아니라 모든 협력자와의 통신비즈니스 로직을 분리한다.
SRP원칙을 이 패턴에 적용하면 비지니스 로직오케스트레이션을 분리하는 경우로 볼 수 있다.
코드의 깊이와 너비의 관점에서 비즈니스 로직과 오케스트레이션 책임을 생각하라. 코드는 깊을 수도 있고 넓을 수도 있지만, 둘 다는 아니다.
분리 해야 하는 이유 : 테스트 용이성, 코드 복잡도 해결, 쉬운 유지보수
도메인 주도 설계에 나오는 집계 패턴을 예로 들 수 있다.
애그리거트 내부는 강결합, 애그리거트 끼리는 느슨한 결합을 가지면 코드베이스의 통신수를 줄이고 테스트 용이성이 향상된다.

가치 있는 단위 테스트를 위한 리팩터링하기

고객 관리 시스템 CRM을 리팩터링 해보자
복사
public class User { public int UserId { get; private set; } public string Email { get; private set; } public UserType Type { get; private set; } public void ChangeEmail(int userId, string newEmail) { //1 object[] data = Database.GetUserById(userId); UserId = userId; Email = (string)data[1]; Type = (UserType)data[2]; if (Email == newEmail) return; object[] companyData = Database.GetCompany(); string companyDomainName = (string)companyData[0]; int numberOfEmployees = (int)companyData[1]; string emailDomain = newEmail.Split('@')[1]; bool isEmailCorporate = emailDomain == companyDomainName; UserType newType = isEmailCorporate ? UserType.Employee : UserType.Customer; if (Type != newType) { int delta = newType == UserType.Employee ? 1 : -1; int newNumber = numberOfEmployees + delta; Database.SaveCompany(newNumber); } Email = newEmail; Type = newType; Database.SaveUser(this); //1 MessageBus.SendEmailChangedMessage(UserId, newEmail); } } public enum UserType { Customer = 1, Employee = 2 }
1단계 : 암시적 의존성을 명시적으로 만들기
데이터베이스와 메시지 버스에 대한 인터페이스를 두고, 인터페이스를 User에 주입하자.
테스트에서는 목으로 처리한다.
💡
도메인 모델이 프로세스 외부 협력자에게 의존하지 않는 것이 깔끔하다. 도메인 모델은 외부 시스템과의 통신을 책임지지 않아야 한다.
2단계: 애플리케이션 서비스 계층 도입
외부와 직접 통신하지 않기 위해 책임을 옮긴다.
복사
public class UserController { // 외부 의존성을 직접 인스턴스화 함 -> 주입해야 함 private readonly Database _database = new Database(); private readonly MessageBus _messageBus = new MessageBus(); public void ChangeEmail(int userId, string newEmail) { //원시데이터로 User인스턴스 만듬 -> 복잡한 로직이므로 애플리케이션 서비스에 있으면 안된다. // 애플리케이션 서비스의 역할은 오케스트레이션이다. // 3단계에서 ORM으로 해결할 것 object[] data = _database.GetUserById(userId); string email = (string)data[1]; UserType type = (UserType)data[2]; var user = new User(userId, email, type); object[] companyData = _database.GetCompany(); string companyDomainName = (string)companyData[0]; int numberOfEmployees = (int)companyData[1]; //유저가 업데이트된 직원수를 반환한다. 회사 직원수는 특정 사용자와 관련이 없다. //이 책임은 다른곳에 있어야 한다. //4단계에서 Company를 만들어 해결 할 것 int newNumberOfEmployees = user.ChangeEmail(newEmail, companyDomainName, numberOfEmployees); //변경과 관련없이 저장하고 베시지 버스에 알림을 보낸다. _database.SaveCompany(newNumberOfEmployees); _database.SaveUser(user); _messageBus.SendEmailChangedMessage(userId, newEmail); } }
외부 의존성과 통신할 필요가 없어져서 테스트하기 쉬워졌다.
복사
public class User { public int UserId { get; private set; } public string Email { get; private set; } public UserType Type { get; private set; } public int ChangeEmail(string newEmail, string companyDomainName, int numberOfEmployees) { if (Email == newEmail) return numberOfEmployees; string emailDomain = newEmail.Split('@')[1]; bool isEmailCorporate = emailDomain == companyDomainName; UserType newType = isEmailCorporate ? UserType.Employee : UserType.Customer; if (Type != newType) { int delta = newType == UserType.Employee ? 1 : -1; int newNumber = numberOfEmployees + delta; numberOfEmployees = newNumber; } Email = newEmail; Type = newType; return numberOfEmployees; } }
UserController의 복잡도를 해결해보자
3단계: 애플리케이션 서비스 복잡도 낮추기
ORM을 통해 도메인 모델 매핑
팩토리 클래스
예제 7.3 도메인 유의성이 없어 유틸리티 코드임
숨은 분기가 있을 수 있으므로 재구성 로직을 테스트해볼만 하다.
클래스 정적 메서드
4단계: 새 Company 클래스 소개
Company를 만들어서 업데이트 된 직원 수를 반환하게 한다.
User가 Company에게 작업을 수행하게 한다.
묻지 말고 말하라
결과
User가 협력자가 생겼으므로(Company) 오른쪽으로 이동
함수형 아키텍처인 감사 시스템
CRM
공통점 : 외부 의존성과 통신하지 않음
차이점 : 사이드 이펙트 없음
차이점 : CRM : 사이드 이펙트가 도메인 모델 내부에 남아있고 db에 저장할 때만 도메인 모델의 경계를 넘음
테스트 용이성이 향상됨.
외부 의존성 검사X, 출력,상태기반 테스트 O

최적의 단위 테스트 커버리지 분석

협력자가 거의 없음
협력자가 많음
높음
User의 ChangeEmail() Company의ChangeNumberOfEmployees() IsEmailCorporate() CompnayFactory의 Create()
낮음
User와 Compnay의 생성자
UserController.ChangeEmail()
좌측 상단 태스트는 비용측면에서 최상의 결과를 가져다 준다.
회귀방지가 뛰어나고 유지비도 낮다.
User 테스트
복사
[InlineData("mycorp.com", "email@mycorp.com", true)] [InlineData("mycorp.com", "email@gmail.com", false)] [Theory] public void Differentiates_a_corporate_email_from_non_corporate( string domain, string email, bool expectedResult) { var sut = new Company(domain, 0); bool isEmailCorporate = sut.IsEmailCorporate(email); Assert.Equal(expectedResult, isEmailCorporate); }

전제 조건을 테스트해야 하는가?

도메인 유의성이 있으면 전제 조건을 테스트 하고, 그 외의 경우에는 테스트하지 않는다.

컨트롤러에서 조건부 로직 처리

조건부 로직을 처리하면서 외부 협력자도 없게 하면서 유지보수하는 것은 어렵다
이에 대한 절충을 알아보자
비즈니스 로직오케스트레이션을 분리할 때는 다음과 같이 세 가지 중요한 특성이 있다.
도메인 모델 테스트 유의성 : 도메인 클래스 내 협력자 수와 유형에 대한 함수
컨트롤러 단순성 : 컨트롤러에 의사 결정 지점이 있는 지에 따라 다름
성능 : 프로세스 외부 의존성에 대한 호출 수로 정의
항상 세 가지 특성 중 최대 두 가지를 가질 수 있다.
1,2 : 외부에 대한 모든 읽기와 쓰기를 비즈니스 연산 가장자리로 밀어내기 : 컨트롤러를 단순하게 유지하고 도메인 모델 테스트 유의성을 지키지만, 성능이 저하된다.
2,3 : 도메인 모델에 프로세스 외부 의존성을 주입하기 : 성능을 유지하고 컨트롤러를 단순하게 하지만, 도메인 모델의 테스트 유의성이 떨어진다.
지나치게 복잡한 사분면 코드가 된다. 테스트와 유지보수가 어려워진다.
1,3 : 의사 결정 프로세스 단계를 더 세분화하기 : 성능과 도메인 모델 테스트 유의성을 지키지만, 컨트롤러의 단순함을 포기한다.

컨트롤러의 복잡도를 관리해보자

의사 결정 프로세스 단계를 더 세분화하는 것이 장단점을 고려할 때 가장 효과적인 절충이다. 다음 두 가지 패턴을 사용해 컨트롤러 복잡도 증가를 완화할 수 있다.
CanExecute/Execute 패턴은 각 Do()메서드에 대해 CanDo()를 두고, CanDo()가 성공적으로 실행되는 것을 Do()의 전제 조건으로 한다. 이 패턴은 Do() 전에 CanDo()를 호출하지 않을 수 없기 때문에 컨트롤러의 의사 결정을 근본적으로 제거한다.
컨트롤러에 if문이 있어도 if문을 테스트할 필요는 없다.
도메인 이벤트는 모델의 중요한 변경사항을 추적하고 해당 변경사항을 프로세스 외부 의존성에 대한 호출로 변환한다. 이 패턴으로 컨트롤러에서 추적에 대한 책임이 없어진다.
추상화할 것을 테스트하기보다는 추상화를 테스트하는 것이 더 쉽다. 도메인 이벤트는 프로세스 외부 의존성 호출 위의 추상화에 해당한다. 도메인 클래스의 변경은 데이터 저장소의 향후 수정에 대한 추상화에 해당한다.