[단위 테스트] 단위 테스트 리팩토링
어떤 코드를 리팩토링 할 것인가?
-> 어떤 코드가 중요한 지 파악한다.
복잡도 혹은 도메인 유의성
- 코드의 분기가 얼마나 많은가
- 프로젝트의 문제 도메인에 대해 얼마나 의미 있는가
협력자 수
- 클래스 또는 메서드가 가진 협력자 수
- 가변 의존성 혹은 외부 의존성
리팩토링 목표
지나치게 복잡한 코드를 리팩토링 하여 협력자수를 줄이거나 복잡도를 줄이는 방향으로 함
험블 객체
험블 객체 : 테스트 하기 어려운 부분을 험블 객체로 이동하여 의존성을 분리
-> 지나치게 어려운 코드를 만드는 의존성을 테스트 로직에서 제거해야함
-> 험블 객체라는 얇은 래퍼 클래스를 통해 의존성을 분리
함수형 아키텍처에서 코드 유형 분류
- 도메인 모델 및 알고리즘 : 함수형 코어 및 도메인 계층
- 컨트롤러 : 가변 셸 및 어플리케이션 계층
비즈니스 로직과 오케스트레이션
- 비지니스 로직은 복잡한 코드 깊이를 가지나 의존성은 가지지 않거나 적다.
- 오케스트레이션은 단순한 코드 깊이를 가지나 의존성이 많다.
단위 테스트 리팩토링 예제
- CRM (고객 관리 시스템)
시나리오
-> 고객의 이메일 주소를 변경하는 로직을 테스트
-> 해당 로직을 단위 테스트를 위한 좋은 코드를 위해 리팩토링 하자
Version 0
void User::ChangeEmailV0(int userId, string newEmail)
{
auto data = _database->GetUserById(userId);
string email = std::get<0>(data);
UserType type = (UserType)(std::get<1>(data));
User user = User(userId, email, type);
auto companyData = _database->GetCompany();
string companyDomainName = std::get<0>(companyData);
int numberOfEmployees = std::get<1>(companyData);
string emailDomain = Split(newEmail, "@")[1];
bool isEmailCorporate = emailDomain == companyDomainName;
UserType newType = isEmailCorporate ? UserType::Employee : UserType::Customer;
if (newType != _type) {
int delta = newType == UserType::Employee ? 1 : -1;
int newNumber = numberOfEmployees + delta;
numberOfEmployees = newNumber;
}
_email = newEmail;
_type = newType;
_database->SaveCompany(numberOfEmployees);
_database->SaveUser(user);
_messageBus->SendEmailChangedMessage(userId, newEmail);
}
- User 라는 도메인 객체 내부에서 DB 정보를 가져온다.
- 기존 이메일이 바꾸고자 하는 이메일과 다른 경우 변경한다. (고객 혹은 직원으로써)
- 변경 후 내용을 DB에 저장하고 이메일 알림 (SendEmailChangedMessage)를 보낸다.
문제점
- 도메인 클래스의 로직에서 외부 의존성(DB, 메시지 버스)이 지나치게 많다. (복잡한 코드)
- 외부 의존성을 도메인 로직에서 분리해야한다.
- 험블 객체 (UserController)를 도입하여 외부 의존성을 호출하는 부분을 도메인에서 분리한다.
Version 1
class UserController
{
private:
Database* _database;
MessageBus* _messageBus;
public:
UserController(Database* database, MessageBus* messageBus)
:
_database(database), _messsageBus(messageBus)
{}
//...
};
void UserController::ChangeEmailV1(int userId, string newEmail)
{
auto data = _database->GetUserById(userId);
string email = std::get<0>(data);
UserType type = (UserType)(std::get<1>(data));
User user = User(userId, email, type);
auto companyData = _database->GetCompany();
string companyDomainName = std::get<0>(companyData);
int numberOfEmployees = std::get<1>(companyData);
int newNumberOfEmployees = user.ChangeEmailV1(newEmail, companyDomainName, numberOfEmployees);
_database->SaveCompany(newNumberOfEmployees);
_database->SaveUser(user);
_messageBus->SendEmailChangedMessage(userId, newEmail);
}
- UserController 어플리케이션 서비스 계층을 도입
- 이제 외부 의존성을 통한 호출 어플리케이션 서비스 계층을 통해 이루어진다.
- UserController에 외부 의존성은 외부에서 주입되어야만 한다.
문제점
- 이메일 변경 이후 User 클래스가 회사 직원 수를 반환하는 것은 이상하다. (회사 직원 수와 User 도메인은 관련이 적다.)
- 변경 성공 여부와 관계 없이 DB 변경과 메시지 알림을 보내는 것은 이상하다.
- 코드를 복잡도를 더 낮출 여지가 있다.
Version 2
bool UserController::ChangeEmailV2(int userId, string newEmail)
{
auto data = _database->GetUserById(userId);
auto companyData = _database->GetCompany();
string email = std::get<0>(data);
UserType type = (UserType)(std::get<1>(data));
string companyDomainName = std::get<0>(companyData);
int numberOfEmployees = std::get<1>(companyData);
User user = User::CreateUser(userId, email, type);
Company company = Company::CreateCompany(companyDomainName, numberOfEmployees);
bool isSuccess = user.ChangeEmailV2(newEmail, company);
if (isSuccess) {
_database->SaveCompany(company);
_database->SaveUser(user);
_messageBus->SendEmailChangedMessage(userId, newEmail);
}
return isSuccess;
}
bool User::ChangeEmailV2(string newEmail, Company& company)
{
if (newEmail == _email) {
return false;
}
UserType newType = company.IsEmailCorporate(newEmail) ? UserType::Employee : UserType::Customer;
if (newType != _type) {
int delta = newType == UserType::Employee ? 1 : -1;
company.ChangeNumberOfEmployees(delta);
}
_email = newEmail;
_type = newType;
return true;
}
- Company 도메인 클래스를 도입하였다.
- 이제 각각 도메인 클래스는 팩터리 메서드를 이용하여 DB 정보로 부터 생성 가능하다.
- User의 ChangeEmailV2 내부에서 유저 타입이 변경되었을 때, Company 인스턴스를 통해 전체 직원 수가 변경된다. (ChangeNumberOfEmployees)
- 실제 이메일 주소가 변경되었을 때만 DB 변경 및 메시지 알림이 보내진다.
도메인 계층과 유틸리티 코드 테스트 하기
- 처음 버전에서 가지고 있던 도메인 계층에서 외부 의존성은 어플리케이션 서비스 계층으로 밀려났으며
- 이제 비지니스 로직은 의존성이 없으므로 도메인만 오로지 테스트 할 수 있는 단위 테스트로 거듭났다.
다음은 도메인 계층에 대한 테스트 코드의 예시이다.
TEST(TestUserController, 이메일_변경_외부도메인_에서_사내도메인으로) {
auto company = Company("mycorp.com", 1);
auto sut = User(1, "user@gmail.com", UserType::Customer);
bool isSuccess = sut.ChangeEmailV2("new@mycorp.com", company);
ASSERT_TRUE(isSuccess);
ASSERT_EQ(2, company._numberOfEmployees);
ASSERT_EQ("new@mycorp.com", sut._email);
ASSERT_EQ(UserType::Employee, sut._type);
}
TEST(TestUserController, 사내_도메인_체크_유틸_테스트) {
auto sut = Company("mycorp.com", 1);
bool isCompanyDomain = sut.IsEmailCorporate("user@mycorp.com");
ASSERT_TRUE(isCompanyDomain);
}
Version 3
추가 고민거리
- 최적화 여지
-> ChangeEmailV2에서 변경해야하는 이메일 주소가 기존 이메일 주소가 같을 때의 케이스에서 Company 정보를 DB에서 반드시 가져와야한다.
-> 컨트롤러에서 CanExecute/Execute 패턴을 사용하여 User 객체를 검사해서 (IsEmailSame과 같은) 이메일이 같은 경우을 하는 이른 반환을 하는 최적화가 가능하다.
-> 도메인 로직이 컨트롤러 쪽으로 파편화가 생기는 부작용이 생길 수 있다.
- 도메인 변경 사항에 대한 이벤트 처리
-> 현재 컨트롤러에서 수행되는 외부 사이드 이펙트는 DB에 변경 사항 반영 및 메시지 알림이 있다.
-> 추후 도메인 변경에 대한 이벤트 종류가 많아지면 컨트롤러의 이벤트 처리 로직이 복잡해 질 수 있다.
-> 도메인 변경에 대한 추적을 컨트롤러에서 간단하게 처리할 수 있는 방법이 필요할 수 있다.
-> 도메인 이벤트(Domain Event)로 일반화하고 이를 처리하는 로직으로 일반화가 가능하다.
bool UserController::ChangeEmailV3(int userId, string newEmail)
{
auto data = _database->GetUserById(userId);
string email = std::get<0>(data);
UserType type = (UserType)(std::get<1>(data));
// Can Execute -> Execute 패턴
User user = User::CreateUser(userId, email, type);
if (user.isEmailEquals(newEmail) == true) {
return false;
}
auto companyData = _database->GetCompany();
string companyDomainName = std::get<0>(companyData);
int numberOfEmployees = std::get<1>(companyData);
Company company = Company::CreateCompany(companyDomainName, numberOfEmployees);
bool isSuccess = user.ChangeEmailV3(newEmail, company);
if (isSuccess) {
_database->SaveCompany(company);
_database->SaveUser(user);
for (auto& domainEvent : user._domainEvents) {
domainEvent->Execute();
delete domainEvent;
}
}
return isSuccess;
}
- database 변경 사항에 대한 이벤트는 ORM을 사용한다고 가정했을 때, 도메인 객체의 변경사항이 있을 때만 DB를 다녀온다.
- 그 외 ChangeEmail에서 일어나는 도메인 객체에 대한 변경점은 이제 UserController에서 도메인 이벤트로 추적 가능해졌다.
결론
- 좋은 단위 테스트를 위한 리팩토링을 수행한다.
- 복잡한 코드를 도메인 단위와 협력자를 분리한 어플리케이션 서비스 계층으로 나눈다.
- 험블 래퍼, 테스트 하기 어려운 외부 의존성을 비지니스 로직에서 분리하기 위한 클래스. CRM 예제에서 컨트롤러는 험블 래퍼로써 사용된다.
- 비지니스 로직과 오케스트레이션 분리
- - 도메인 모델 테스트 유의성, 컨트롤러 단순성, 프로세스 외부 의존성 호출 수 (성능)을 고려
- 전체 로직에 대한 단계를 더 세분화하고 최적화하기
참고
Unit Testing 단위 테스트, 블라디미르 코리코프 지음
https://www.sandordargo.com/blog/2019/04/24/parameterized-testing-with-gtest#parameterizedtests
https://github.com/jinsnowy/UnitTest_Study