////
Search
Duplicate
🎉

7. 가치 있는 단위 테스트를 위한 리팩토링

7장에서 다루는 내용
네 가지 코드 유형 알아보기
험블 객체 패턴 이해
가치 있는 테스트 작성
1장에선 좋은 단위 테스트 스위트의 속성을 정의했다.
4장에선 좋은 단위 테스트의 속성을 정의했다.
우리는 가치 있는 테스트를 알아보고 작성하기 위해 노력해왔다.
5장에선 리팩토링 내성에 대해서 자세하게 알아보았다.
6장에선 출력 기반 테스트 스타일로 테스트를 작성하기 위해 코드베이스를 전환해봤다.
이 장에서는 대다수 소프트웨어 프로젝트에서 가치 있는 테스트를 작성하는 방법과 관련해 실용적인 지침을 알아본다.

1. 리팩토링할 코드 식별하기

테스트 대상 코드를 리팩토링하지 않고서는 테스트 스위트를 개선하는 것은 불가능하다.
테스트 코드와 프로덕션 코드는 연관성이 높기 때문에 다른 방도가 없다.
이 절에서는 리팩토링의 방향을 설명하고자 코드를 네 가지로 분류하는 방법을 소개한다.
1.
코드의 네 가지 유형
모든 제품 코드는 2차원으로 분류할 수 있다.
1.
복잡도 또는 도메인 유의성
2.
협력자 수
코드 복잡도는 코드 내 분기문의 수로 정의한다. 이 숫자가 클수록 복잡도는 높아진다.
도메인 유의성은 코드가 프로젝트의 비즈니스 로직에 얼마나 의미있는지를 나타낸다.
일반적으로 도메인 계층의 모든 코드는 클라이언트의 목표와 직접적인 연관성이 있으므로 도메인 로직 연관성이 높다.
반면 유틸 함수 같은 코드들은 연관성이 낮으므로 유의성이 낮다고 볼 수 있다.
일반적으로 도메인 복잡한 코드와 높은 도메인 유의성을 가진 코드가 회귀 방지에 뛰어나기 때문에 도메인 로직 연관성과 복잡한 코드를 갖는 코드가 단위 테스트에서 가장 이롭다.
도메인 코드가 굳이 복잡할 필요는 없으므로 유의성이 나타나지 않더라도 테스트하는 것이 좋다.
협력자는 가변 의존성이거나 프로세스 외부 의존성이다.
협력자가 많을수록 코드가 길어지므로 테스트 코드도 길어서 유지 보수성이 떨어진다.
협력자의 유형도 중요하다.
도메인 모델이라면 프로세스 외부 의존성을 제거하여 이를 대체하는 목을 줄여 테스트 유지 비용을 최소화해야 한다.
애플리케이션의 경계를 넘는 상호 작용에 대해서만 목을 사용하여 리팩토링 내성을 지켜야 한다.
프로세스 외부 의존성을 가진 모든 통신은 도메인 계층 외부의 클래스에 위임하는 것이 좋다. 그러면 도메인 클래스는 프로세스 내부 의존성에서만 작동하게 된다.
코드 복잡도, 도메인 로직 연관성, 협력자 수의 조합으로 네 가지 코드 유형을 분류할 수 있다.
도메인 모델 및 알고리즘
보통 복잡한 코드는 도메인 모델이지만 100%는 아니다.
문제 도메인과 직접적으로 관련이 없는 복잡한 알고리즘이 존재할 수 있다.
간단한 코드
매개변수가 없는 생성자나 한 줄 속성 등이 있다. 협력자가 있는 경우가 거의 없고 복잡도나 도메인 유의성도 거의 없다.
컨트롤러
이 코드는 복잡하거나 비즈니스에 중요한 작업을 하는 것이 아니라 도메인 클래스와 외부 애플리케이션 같은 다른 구성 요소의 작업을 조정한다.
지나치게 복잡한 코드
협력자가 많으며 복잡하고 중요한 비즈니스 로직을 많이 가지고 있는 코드다. 한 가지 예로 덩치가 큰 컨트롤러가 있다.
도메인 모델과 알고리즘의 단위 테스트를 작성하는 것이 가성비가 가장 좋다.
이러한 단위 테스트는 매우 가치 있으며 저렴하다.
해당 코드가 복잡하거나 중요한 로직을 수행하므로 테스트의 회귀 방지가 향상되기 때문에 가치있다.
또한 코드에 협력자가 거의 없어서 테스트 유지비용이 낮기 때문에 저렴하다.
간단한 코드는 테스트할 필요가 없다.
이러한 테스트는 가치가 0에 가깝다.
컨트롤러의 경우, 포괄적인 통합 테스트의 일부로써 간단하게 테스트해야 한다.
지나치게 복잡한 코드가 가장 문제가 된다.
단위 테스트가 어렵지만 테스트하지 않고 두는 것은 위험하다.
지나치게 복잡한 코드를 피하고 도메인 모델과 알고리즘만 단위 테스트하는 것이 매우 가치 있고 유지 보수가 쉬운 테스트 스위트로 가는 지름길이다.
좋지 않은 테스트를 작성하는 것보다는 테스트를 전혀 작성하지 않는 편이 낫다.
2.
험블 객체 패턴을 사용해 지나치게 복잡한 코드 분할하기
지나치게 복잡한 코드를 쪼개려면 험블 객체 패턴을 써야 한다.
어려운 의존성과 결합된 코드는 테스트하는 것이 어렵다. 유지비용이 증가해서인데 테스트는 해당 의존성도 다뤄야하기 때문이다.
비동기, 멀티스레드 실행, 프로세스 외부 의존성과의 통신 등이 있다.
테스트 대상 코드의 로직을 테스트하려면 테스트가 가능한 부분을 추출해야 한다.
결과적으로 테스트 대상 코드는 테스트 가능한 부분을 둘러싼 얇은 험블 래퍼가 된다.
험블 래퍼엔 테스트하기 어려운 의존성과 새로 추출된 구성 요소가 들어가지만 자체적인 로직이 없으므로 테스트할 필요가 없다.
함수형 아키텍처는 여기서 더 나아가 프로세스 외부 의존성뿐만 아니라 모든 협력자와의 커뮤니케이션에서 비즈니스 로직을 분리해버린다.
험블 객체 패턴을 작성하는 것은 단일 책임 원칙을 수행하는 것으로 볼 수도 있다.
이는 각 클래스가 하나의 책임만 가져야한다는 원칙이다.
험블 객체 패턴을 사용하면 비즈니스 로직만 분리해낼 수 있다.
또 다른 예시로 도메인 주도 설계의 집계 패턴이 있다.
이는 클래스를 클러스터로 묶어서 클래스 간 결합도를 낮추는 것으로 클래스는 클러스터 내부에 강결합되어 있대 클러스터 자체는 느슨하게 결합시키는 것이다.
이처럼 비즈니스 로직을 분리하는 것은 단순히 테스트를 편하게하는 목적으로 사용되지 않는다.
비즈니스 로직의 분리는 코드 복잡도를 낮추고 프로젝트의 장기적인 성장에도 영향을 주기 때문이다.

2. 가치있는 단위 테스트를 위한 리팩토링

지나치게 복잡한 코드도메인 모델 및 알고리즘컨트롤러로 리팩토링하는 절차를 밟아보자.
앞서 우리는 출력 기반 테스트와 함수형 아키텍처를 통해 이러한 리팩토링을 수행했다.
이번 장에서는 험블 객체 패턴을 이용해서 리팩토링을 일반화할 수 있는 방법에 대해 알아본다.
1.
고객 관리 시스템 소개
고객 관리 시스템은 사용자의 등록을 처리하며 모든 사용자가 데이터베이스에 저장된다.
현재 시스템은 사용자 이메일 변경이라는 단 하나의 유스케이스만 지원하며 이 유스케이스에는 세 가지 비즈니스 규칙이 있다.
사용자 이메일이 회사 도메인인 경우, 해당 사용자는 직원으로 표시한다. 그렇지 않으면 고객으로 간주한다.
시스템은 회사의 직원 수를 추적해야 한다. 사용자 유형이 직원에서 고객으로 또는 그 반대로 변경되면 이 숫자도 변경해야 한다.
이메일이 변경되면 시스템은 메시지 버스로 메시지를 보내 외부 시스템에 알려야 한다.
다음은 고객 관리 시스템의 초기 구현 코드다.
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) { object[] data = Database.GetUserById(userId); // Retrieves the user’s current email and type from the database UserId = userId; Email = (string)data[1]; Type = (UserType)data[2]; if (Email == newEmail) return; object[] companyData = Database.GetCompany(); // Retrieves the organization’s domain name and the number of employees from the database string companyDomainName = (string)companyData[0]; int numberOfEmployees = (int)companyData[1]; string emailDomain = newEmail.Split('@')[1]; bool isEmailCorporate = emailDomain == companyDomainName; UserType newType = isEmailCorporate // Sets the user type depending on the new email’s domain name ? UserType.Employee : UserType.Customer; if (Type != newType) { int delta = newType == UserType.Employee ? 1 : -1; int newNumber = numberOfEmployees + delta; Database.SaveCompany(newNumber); // Updates the number of employees in the organization, if needed } Email = newEmail; Type = newType; Database.SaveUser(this); // Persists the user in the database MessageBus.SendEmailChangedMessage(UserId, newEmail); // Sends a notification to the message bus } } public enum UserType { Customer = 1, Employee = 2 }
C#
복사
이 클래스의 ChangeEmail() 메소드는 네 가지 의존성을 가지고 있다.
이 중 명시적인 의존성은 userIdnewEmail 인수이지만 값이므로 협력자라고 볼 수 없다.
이 중 암시적인 의존성은 DatebaseMessageBus로 프로세스 외부 협력자이다.
따라서 User 클래스는 지나치게 복잡한 코드로 분류된다.
User 클래스처럼 도메인 로직을 가진 클래스가 스스로 데이터를 검색하고 저장하는 방식을 활성 레코드 패턴이라고 한다.
단순한 프로젝트라면 정상적으로 잘 동작하겠지만 코드베이스가 커지면 확장이 어려워지는 경우가 많다.
그 이유는 정확히 두 책임, 즉 비즈니스 로직과 프로세스 외부 의존성과의 통신 사이에 분리가 없기 때문이다.
2.
1단계: 암시적 의존성을 명시적으로 만들기
테스트를 용이하게하는 일반적인 방법은 암시적 의존성을 명시적으로 만드는 것이다.
여기선 Database와 MessageBus에 대한 인터페이스를 두고 이 인터페이스를 User에 주입한 후 테스트에서 목으로 처리한다.
이러한 클래스를 테스트하려면 목이 필요한데, 여기서 테스트 유지비용이 증가한다. 그리고 목을 데이터베이스 의존성에 사용하면 테스트 취약성을 야기할 수 있다.
데이터베이스는 외부에 노출되지 않는다. 따라서 프로세스 외부 의존성이지만 내부 의존성같이 동작한다. 이를 검증하게 된다면 리팩토링 내성이 약해진다.
결국 도메인 모델은 직접적으로든 간접적으로든 프로세스 외부 협력자에게 의존하지 않는 것이 바람직하다. 도메인 모델은 외부 시스템과의 통신에 책임이 없어야 한다.
3.
2단계: 애플리케이션 서비스 계층 도입
도메인 모델이 외부 시스템과 직접 통신하는 문제를 해결하려면 다른 클래스인 험블 컨트롤러로 책임을 옮겨야 한다.
일반적으로 도메인 클래스는 다른 도메인 클래스나 단순 값과 같은 프로세스 내부 의존성에만 의존해야 한다.
다음은 외부 시스템과 통신하는 책임을 가져간 UserController다.
public class UserController { private readonly Database _database = new Database(); private readonly MessageBus _messageBus = new MessageBus(); public void ChangeEmail(int userId, string newEmail) { 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]; int newNumberOfEmployees = user.ChangeEmail(newEmail, companyDomainName, numberOfEmployees); _database.SaveCompany(newNumberOfEmployees); _database.SaveUser(user); _messageBus.SendEmailChangedMessage(userId, newEmail); } }
C#
복사
좋은 시도이지만 여기엔 몇 가지 문제가 존재한다.
프로세스 외부 의존성이 주입되지 않고 직접 인스턴스화한다. 이는 이 클래스를 위해 작성할 통합 테스트에서 이슈가 발생할 것이다.
컨트롤러는 데이터베이스에서 받은 원시 데이터를 User 인스턴스로 재구성한다. 만약 데이터베이스의 로직이 달라진다면 User 인스턴스를 생성하는 로직도 변경되어야할 것이다.
회사 데이터도 마찬가지다 이 데이터의 다른 문제는 다음과 같다. User는 이제 업데이트된 직원 수를 반환하는데, 이 부분이 이상해보인다. 회사 직원수는 특정 사용자와 관련이 없다.
컨트롤러는 새로운 이메일이 전과 다른지 여부와 관계없이 무조건 데이터를 수정해서 저장하고 메시지 버스에 알림을 보낸다.
UserController로 프로세스 외부 의존성이 떨어져나간 User 코드는 다음과 같아질 것이다.
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; } } public enum UserType { Customer = 1, Employee = 2 }
C#
복사
User는 더 이상 프로세스 외부 의존성인 협력자를 처리하지 않기 때문에 도메인 모델 및 알고리즘의 영역으로 이동했다.
UserControllerUser가 어느정도 복잡한 로직을 가져갔으나 아직 불충분하다. 따라서 컨트롤러 영역에 있으나 개선이 필요한 수준이다.
3.
애플리케이션 서비스의 복잡도 제거
UserController의 복잡도를 낮추려면 User의 재구성 로직을 추출해서 분리해야 한다.
ORM을 이용해 데이터베이스를 데이터 모델에 매핑하거나 원시 데이터 베이스 데이터로 도메인 클래스를 인스턴스화하는 팩토리 클래스를 작성하라.
UserFactory를 만든다면 이 코드는 도메인 유의성이 없으며 어느정도 복잡하므로 유틸리티에 속한다.
4.
4단계: 새 Company 클래스 소개
앞서 전체 직원 수를 반환하는 메소드를 User에서 분리해내어 불필요한 책임을 분리하기 위한 작업이다.
잘못 가지고 있던 책임을 이관하기 위해 Company 클래스를 만들었고 다음과 같다.
public class Company { public string DomainName { get; private set; } public int NumberOfEmployees { get; private set; } public void ChangeNumberOfEmployees(int delta) { Precondition.Requires(NumberOfEmployees + delta >= 0); NumberOfEmployees += delta; } public bool IsEmailCorporate(string email) { string emailDomain = email.Split('@')[1]; return emailDomain == DomainName; } }
C#
복사
User, UserController 클래스는 다음과 같아졌을 것이다.
public class User { public int UserId { get; private set; } public string Email { get; private set; } public UserType Type { get; private set; } public void ChangeEmail(string newEmail, Company company) { if (Email == newEmail) return; UserType newType = company.IsEmailCorporate(newEmail) ? UserType.Employee : UserType.Customer; if (Type != newType) { int delta = newType == UserType.Employee ? 1 : -1; company.ChangeNumberOfEmployees(delta); } Email = newEmail; Type = newType; } }
C#
복사
public class UserController { private readonly Database _database = new Database(); private readonly MessageBus _messageBus = new MessageBus(); public void ChangeEmail(int userId, string newEmail) { object[] userData = _database.GetUserById(userId); User user = UserFactory.Create(userData); // HERE object[] companyData = _database.GetCompany(); Company company = CompanyFactory.Create(companyData); // HERE user.ChangeEmail(newEmail, company); _database.SaveCompany(company); _database.SaveUser(user); _messageBus.SendEmailChangedMessage(userId, newEmail); } }
C#
복사
이제 모든 복잡도가 팩토리로 이동했기 때문에 UserController는 컨트롤러 유형에 해당하는 형태가 되었다.

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

이제 험블 객체 패턴을 이용해 리팩토링을 마쳤으니 프로젝트의 어느 부분이 어떤 코드 범주에 속하는지와 해당 부분을 어떻게 테스트해야하는 지 분석해보자.
협력자가 거의 없음
협력자가 많음
복잡도와 도메인 유의성이 높음
User.ChangeEmail Company.ChangeNumberOfEmployees Company.isEmailCorporate CompanyFactory.Create
복잡도와 도메인 유의성이 낮음
User.new Company.new
UserController.ChangeEmail
비즈니스 로직과 오케스트레이션을 완전히 분리하면 코드베이스의 어느 부분을 테스트 단위로 할지 쉽게 결정할 수 있다.
1.
도메인 계층과 유틸리티 코드 테스트하기
협력자가 거의 없으면서 복잡도와 유의성이 높은 메소드의 테스트는 가성비가 매우 좋다.
코드 복잡도, 도메인 유의성이 높으면 회귀 방지가 뛰어나고 협력자가 거의없어 유지비도 가장 낮다.
2.
나머지 세 사분면에 대한 코드 테스트하기
복잡도가 낮고 협력자가 거의 없는 코드는 테스트할 필요가 없다.
복잡도가 높고 협력자가 많은 코드를 리팩토링으로 제거했으므로 컨트롤러에 대한 테스트는 다음 장에서 살펴본다.
3.
전제 조건을 테스트해야 하는가?
일반적으로 권장하는 지침은 도메인 유의성이 있는 모든 전제조건은 테스트하라는 것이다.
그러나 도메인 유의성이 없는 전제 조건을 테스트하는데 시간을 들이지 말라.

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

조건부 로직을 처리하면서 동시에 외부 협력자 없이 도메인 계층을 유지 보수하는 것은 까다롭고 상충관계가 발생하기 마련이다.
이 절에서는 해당 상충관계가 무엇인지 살펴보고 프로젝트에서 어떤 것을 선택할지 결정하는 방법을 소개한다.
비즈니스 로직과 오케스트레이션의 분리는 다음과 같이 비즈니스 연산이 세 단계로 있을 때 가장 효과적이다.
저장소에서 데이터 검색
비즈니스 로직 실행
데이터를 다시 저장소에 저장
이렇게 명확하지 않은 경우도 많다. 첫 조회값만으로 비즈니스 로직 연산이 충분하지 않을때다.
어쨌든 외부에 대한 모든 읽기와 쓰기를 가장 자리로 밀어낸다. 즉 비즈니스 로직이 모든 경우의 수에 테스트하기 용이하도록 필요할 가능성이 있는 모든 값을 넘겨주는 것이다.
이는 read-decide-act 구조를 유지하지만 성능이 저하된다. 필요가 없어도 조회를 하므로 성능이 떨어진다.
도메인 모델에 프로세스 외부 의존성을 주입하고 비즈니스 로직이 해당 의존성을 호출할 시점을 직접 결정할 수 있게 한다.
의사 결정 프로세스를 세분화하고 각 단계별로 컨트롤러를 실행하도록 한다.
문제는 다음 세 가지 특성의 균형을 맞추는 것이다.
도메인 모델 테스트 유의성: 도메인 클래스의 협력자 수와 유형에 따른 함수다.
컨트롤러 단순성: 의사 결정 분기 지점이 존재하는지에 따라 다르다.
성능: 프로세스 외부 의존성에 대한 호출 수로 정의한다.
위에서 언급한 세 방법들은 위 세 특성 중 두 가지 특성 씩만 가질 수 있다. 즉 상충 관계에 있는 것이다.
가장 자리로 밀어내기
컨트롤러 단순성테스트 유의성을 유지하지만 성능이 저하된다.
도메인 모델에 프로세스 외부 의존성 주입하기
성능컨트롤러 단순성을 유지하지만 도메인 모델 테스트 유의성이 저하된다.
의사 결정 프로세스 단계를 세분화하기
성능도메인 모델 테스트 유의성에 도움을 주지만 컨트롤러가 복잡해진다.

5. 결론

이 장에서 다뤘던 주제는 프로세스 외부 의존성에 대한 애플리케이션의 사이드 이펙트를 추상화하는 것이 주였다.
비즈니스 연산이 끝날때까지 이러한 사이드 이펙트를 메모리에 둬서 추상화하고 프로세스 외부 의존성 없이 단순한 단위 테스트로 테스트할 수 있다.

요약

코드 복잡도는 코드에서 의사 결정 지점 수에 따라 명시적으로 그리고 암시적으로 정의된다.
도메인 유의성은 프로젝트의 문제 도메인에 대해 얼마나 코드가 중요한지를 보여준다.
복잡한 코드는 종종 도메인 유의성이 높고 그 반대의 경우도 있지만 언제나 예외란 존재한다.
복잡한 코드와 도메인 유의성을 갖는 코드는 해당 테스트의 회귀 방지가 뛰어나므로 테스트하는 것이 좋다.
협력자가 많은 코드를 다루는 단위 테스트는 유지 비용이 높다.
이러한 테스트는 협력자를 특정한 상태로 가정하고 나서 상태나 상호 작용을 확인하고자 하므로 공간을 많이 필요로 한다.
모든 제품 코드는 복잡도 또는 도메인 유의성, 협력자 수에 따라 네 가지 유형의 코드로 분류할 수 있다.
도메인 모델 및 알고리즘
간단한 코드
컨트롤러
지나치게 복잡한 코드
코드가 중요하거나 복잡할수록 협력자가 적어야 한다.
험블 객체 패턴은 해당 코드에서 비즈니스 로직을 별도의 클래스로 추출하여 복잡한 코드를 테스트하는 데 도움을 준다.
그 결과 나머지 코드는 비즈니스 로직을 둘러싼 얇은 험블 래퍼, 즉 컨트롤러가 된다.
헥사고날 아키텍처와 함수형 아키텍처는 험블 객체 패턴을 구현한다.
헥사고날 아키텍처는 비즈니스 로직과 프로세스 외부 의존성과의 통신을 분리하도록 한다.
코드는 깊을 수도(복잡하고 어려울 수도) 있고 넓을 수도(협력자가 많을수도) 있지만 둘 다는 아니다.
도메인 유의성이 있으면 무조건 테스트하라 그 외의 경우에는 하지마라
비즈니스 로직을 추출할 땐 다음과 같이 중요한 세 가지 특성을 유의해야 한다.
도메인 모델 테스트 유의성: 도메인 클래스 내 협력자 수와 유형에 대한 함수
컨트롤러 단순성: 컨트롤러에 의사 결정 지점이 존재하는 지에 따라 다름
성능: 프로세스 외부 의존성에 대한 호출 수로 정의
비즈니스 로직을 추출해 내는 방법은 대표적인 다음과 같은 세가지가 있다. 각 방법은 2가지 특성을 항상 지킨다.
외부에 대한 모든 읽기와 쓰기를 비즈니스 연산 가장자리로 밀어내기
컨트롤러 단순성, 도메인 모델 테스트 유의성
도메인 모델에 프로세스 외부 의존성 주입하기
컨트롤러 단순성, 성능
의사 결정 프로세스 단계를 더 세분화하기
도메인 모델 테스트 유의성, 성능
의사 결정 프로세스 단계를 더 세분화하는 것을 권장한다.
이때 증가하는 컨트롤러 복잡도를 완화할 수 있는 방법은 다음과 같다.
CanExecute/Execute 패턴
도메인 이벤트는 도메인 모델의 변경 사항을 추적해 이를 외부 의존성의 호출로 변환한다.
추상화할 것을 테스트하기보다는 추상화를 테스트하라.
도메인 이벤트는 프로세스 외부 의존성 호출 위의 추상화에 해당한다.
도메인 클래스의 변경은 데이터 저장소의 향후 수정에 대한 추상화에 해당한다.