TDD/단위 테스트

[단위 테스트] 단위 테스트 리팩토링

로파이 2022. 12. 23. 18:04

어떤 코드를 리팩토링 할 것인가?

-> 어떤 코드가 중요한 지 파악한다.

 

복잡도 혹은 도메인 유의성

- 코드의 분기가 얼마나 많은가

- 프로젝트의 문제 도메인에 대해 얼마나 의미 있는가

 

협력자 수

- 클래스 또는 메서드가 가진 협력자 수

- 가변 의존성 혹은 외부 의존성

 

크게 4가지로 분류되는 코드 유형

 

리팩토링 목표

지나치게 복잡한 코드를 리팩토링 하여 협력자수를 줄이거나 복잡도를 줄이는 방향으로 함

 

험블 객체

험블 객체 : 테스트 하기 어려운 부분을 험블 객체로 이동하여 의존성을 분리
-> 지나치게 어려운 코드를 만드는 의존성을 테스트 로직에서 제거해야함

-> 험블 객체라는 얇은 래퍼 클래스를 통해 의존성을 분리

 

함수형 아키텍처에서 코드 유형 분류

  • 도메인 모델 및 알고리즘 : 함수형 코어 및 도메인 계층
  • 컨트롤러 : 가변 셸 및 어플리케이션 계층

비즈니스 로직과 오케스트레이션

  • 비지니스 로직은 복잡한 코드 깊이를 가지나 의존성은 가지지 않거나 적다.
  • 오케스트레이션은 단순한 코드 깊이를 가지나 의존성이 많다.

 

단위 테스트 리팩토링 예제

- 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