통합 테스트
단위 테스트가 아닌 모든 테스트
ex) 여러 의존성을 걸친 로직을 테스트 하는 것
vs 단위 테스트
: 빠르게 수행되는 단일 동작 단위를 검증
좋은 통합 테스트는 높은 회귀 방지율과 훌륭한 리팩토링 내성을 가진다.
테스트 피라미드
단위 테스트로 갈 수록 테스트 개수가 많아야한다. 위로 갈수록 만드는 비용이 커지지만 잘 만든 테스트는 시스템 전체의 정확성을 보장한다.
빠른 실패 원칙과 통합 테스트
지난 테스트를 위한 리팩토링 예제의 마지막에서 변경될 이메일 주소가 같은 경우 이메일을 변경할 필요가 없으므로 이른 확인을 통해 Company 데이터 베이스 접근을 안하게 하는 로직으로 최적화 하였다.
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;
}
위의 UserController를 이용하여 통합 테스트를 만든다면, 해당 ChangeEmailV3의 결과 값에 따라 여러 케이스에 대한 통합 테스트를 만들어야 한다.
결국, 데이터 베이스와 같은 의존성과 UserController가 같이 테스트되는 통합 테스트에서
1) isSuccess = true
2) isSuccess = false
두 케이스에 대해 모두 통합 테스트 케이스를 만들어야 한다.
이는 자칫 단위 테스트 보다 통합 테스트 개수가 많아지는 테스트 상황이 되고 분기에 따라(이메일 주소가 같은 경우) 여러 의존성을 거치는 로직을 커버할 수 있는 로직이 테스트 되지 않기 때문에 통합 테스트 자체의 테스트 가치를 떨어뜨린다.
따라서 다음과 같이 이메일 확인 부분을 User 도메인에 포함시키고 이메일 주소가 같을 경우 예외를 발생시켜 어플리케이션의 종료로 이어지게 하여 버그를 확인하도록 유도해야한다.
이렇게 버그를 빨리 나타내도록 하는 것을 빠른 실패 원칙(Fast Failure Principle)이라고 하며 오류가 발생하자마자 어플리케이션을 중단 시킨다.
void User::ChangeEmailV4(string newEmail, Company& company, IMessageBus* messageBus)
{
if (newEmail == _email) {
throw UserEmailAddressIsSame(newEmail);
}
// 나머지 코드 부분
}
void UserController::ChangeEmailV4(int userId, string newEmail)
{
string email = std::get<0>(data);
UserType type = (UserType)(std::get<1>(data));
User user = User::CreateUser(userId, email, type);
auto companyData = _database->GetCompany();
string companyDomainName = std::get<0>(companyData);
int numberOfEmployees = std::get<1>(companyData);
Company company = Company::CreateCompany(companyDomainName, numberOfEmployees);
// Fast Failure Principle
user.ChangeEmailV4(newEmail, company, _messageBus);
// ...
}
- 통합 테스트에서 기존 UserController의 반환값에 따른 분기를 고려하여 작성할 필요가 없어졌다.
- 대신 User를 SUT로써 이메일 변경 부분에서 같은 이메일을 받을 경우 어플리케이션 종료로 이어지는 지에 대한 케이스를 단위 테스트로 확인 가능할 것이다.
외부 의존성과 통합 테스트
어떤 외부 의존성을 테스트 해야하는 것인가
- 관리 의존성 : 어플리케이션을 통해서만 접근하며 해당 의존성과 상호 작용은 외부 환경에서 볼 수 없다.
- 비관리 의존성 : 해당 의존성과의 상호작용을 외부에서 볼 수 있는 것.
실제 인스턴스 혹은 목으로 대체할 것인 지에 대한 전략
- 관리 의존성 -> 구현 세부 사항 -> 실제 인스턴스를 사용
- 비관리 의존성 -> 식별할 수 있는 동작 -> 목으로 대체
실질적으로는 외부 의존성에 해당하는 실제 인스턴스를 할 건지 목으로 대체할 것 인지는 복잡성 및 실효성을 고려해서
사용하는 것이 좋다.
데이터 베이스를 활용한 통합 테스트는 가장 흔한 예이며 관리 의존성(구현 세부사항)이면서 비관리 의존성(외부 클라이언트에서 데이터베이스를 접근할 수 있음)이 될 수 있으므로 되도록이면 실제 인스턴스를 활용하여 테스트를 하는 것이 좋다.
데이터 베이스를 활용할 수 없는 테스트는 목으로 대체해도 통합 테스트 의미가 무색해진다. 회귀 방지성도 떨어지고 리팩토링 내성도 저하되기 때문에 단위 테스트와 다를 바가 없다.
CRM 예제에서의 통합 테스트
주어진 의존성을 가지고 최대한 많은 로직을 커버할 수 있는 케이스를 생각해본다.
의존성이 만들어내는 사이드 이펙트가 많은 케이스
-> 고객이 직원 이메일로 변경할 때 (데이터 베이스 및 메시지 버스로의 외부 사이드 이펙트가 생긴다.)
데이터 베이스는 실제 인스턴스로 메시지 버스는 상호 작용만 검증하면 되므로 목으로 대체한다.
vs 엔드 투 엔드 테스트
모든 목을 실제 인스턴스로 대체하여 테스트 하는 것을 의미. 통합 테스트보다 더 복잡하고 가치가 있는 테스트를 수행할 수 있으나 실과득을 잘 판단하여야 한다.
첫번째 통합 테스트
TEST(TestUserController, 통합_테스트_이메일_변경_사내도메인에서_외부도메인으로) {
// given
auto db = new Database(string("connection"));
auto insertUser = User::CreateUser("user@mycorp.com", UserType::Employee);
auto insertCompany = Company::CreateCompany("mycorp.com", 1);
db->SaveUser(insertUser);
db->SaveCompany(insertCompany);
auto messageBusMock = new MockMessageBus();
auto sut = UserController(db, messageBusMock);
// check behavior of message bus
EXPECT_CALL(*messageBusMock, SendEmailChangedMessage(insertUser._userId, "new@gmail.com")).Times(1);
// when
sut.ChangeEmailV4(insertUser._userId, "new@gmail.com");
// then
auto userData = db->GetUserById(insertUser._userId);
User userFromDB = User::CreateUser(insertUser._userId, std::get<0>(userData), (UserType)std::get<1>(userData));
ASSERT_EQ("new@gmail.com", userFromDB._email);
ASSERT_EQ(UserType::Customer, userFromDB._type);
auto companyData = db->GetCompany();
Company companyFromDB = Company::CreateCompany(std::get<0>(companyData), std::get<1>(companyData));
ASSERT_EQ(0, companyFromDB._numberOfEmployees);
}
-> 준비를 위한 DB 쓰기 및 읽기 그리고 결과 확인에서 읽기가 모두 포함
-> 메시지 버스는 목으로 상호작용을 검증한다.
-> ChangeEmailV4 버전은 항상 이메일 변경에 대한 테스트를 진행한다.
의존성 추상화를 위한 인터페이스 사용
인터페이스를 사용하는 이유?
흔한 이유
- 프로세스 외부 의존성을 추상화하여 느슨한 결합을 만듦
- 기존 코드를 변경하지 않고 새로운 기능을 추가하기 위한 OCP 원칙을 지킬 수 있음
단일 구현체만 되어있는 인터페이스는 큰 의미를 지니지 않음. YAGNI (You are not gonna need it) 구현체가 필요하지 않은데 인터페이스를 사용하는 의미가 무엇이 있겠는가.
인터페이스를 사용하는 이유 -> [목을 사용하기 위해]
- 인터페이스가 없으면 테스트 대역을 만들 수 없으므로 SUT와 프로세스 외부 의존성간 상호 작용을 확인할 수 없다.
- 목으로 만들 필요가 없다면 인터페이스를 만들 이유가 없음.
적용
Database는 실제 인스턴스
MessageBus를 목으로 대체하기 위한 인터페이스를 정의한다.
class UserController
{
private:
Database* _database;
IMessageBus* _messageBus;
//...
};
통합 테스트 모범 사례
- 도메인 모델 경계 명시하기
- 애플리케이션 내 계층 줄이기
- 순환 의존성 제거하기
도메인 모델 경계 명시하기
- 도메인 모델은 프로젝트가 해결하고자 하는 문제에 대한 도메인 지식의 모음
- 단위 테스트 -> 도메인과 알고리즘에 집중
- 통합 테스트 -> 컨트롤러
계층 수 줄이기
각 기능을 추상화하여 일반화 시켜 관리 되는 코드들이 많다. 흔하게도 비지니스 로직이 여러 추상 계층을 거쳐 표현되는 경우가 많다.
추상 계층이 너무 많으면 숨은 로직을 파악하기가 어려워진다.
- 또한 도메인과 컨트롤러 사이의 경계가 불명확해져서 단위 테스트와 통합 테스트에 도움이 되지 않는다.
- 잘 나뉘어진 백엔드 시스템은 도메인/어플리케이션 서비스/인프라 계층이면 충분하다.
- 인프라 계층은 독립적인 알고리즘이나 외부 의존성에 접근하는 코드를 의미한다.
순환 의존성 제거하기
- 순환 의존성은 코드를 이해하기 어렵게 할 뿐만아니라 계층이 분리되지 않는다.
- 콜백과 같은 패턴은 콜백을 등록한 뒤, 콜백에서 처리 주체를 다시 참조해서 순환 의존성이 생긴다.
- 되도록 이면 순환 의존성을 제거하는 것이 좋다.
테스트에서 다중 실행 구절 사용
보통의 테스트 코드 경우
준비 -> 실행 -> 검증으로 이루어지는데 이러한 패턴이 두 번 이상 나타나면 코드 악취로 이어진다.
예를 들면 사용자 생성과 삭제를 순서대로 이어가는 형태
사용자 정보 준비 -> 생성 -> 생성 검증 -> 삭제 -> 삭제 검증
생성과 삭제를 분리하여 두 케이스로 나누어 테스트를 한다.
로깅을 테스트 해야하는 가
꼭 확인해야하는 로그를 남기는지 확인하기 위해 목을 사용하여 행동을 검증한다.
도메인 이벤트로 로그 처리하기
class UpdateUserEmailEvent : public DomainEvent {
public:
UpdateUserEmailEvent(IMessageBus* messageBus, int userId, string email);
};
class ILogger;
class UpdateUserTypeEvent : public DomainEvent {
private:
ILogger* logger;
public:
UpdateUserTypeEvent(int userId, UserType prev, UserType next);
};
- Logger는 목으로 대체되서 테스트 되어야 하기 때문에 ILogger 인터페이스를 정의해야한다.
UpdateUserEmailEvent::UpdateUserEmailEvent(IMessageBus* messageBus, int userId, string email)
{
action = [=]() {
messageBus->SendEmailChangedMessage(userId, email);
};
}
UpdateUserTypeEvent::UpdateUserTypeEvent(int userId, UserType prev, UserType next)
:
logger(Logger::GetInstance())
{
action = [=]() {
logger->Log(Format("UserId(%d) changed type from %d to %d", userId, (int)prev, (int)next));
};
}
- User 타입이 변경되었을 때 로깅을 하기 위해서 도메인 이벤트로 처리하고자 한다.
void User::ChangeEmailV4(string newEmail, Company& company, IMessageBus* messageBus)
{
if (newEmail == _email) {
throw UserEmailAddressIsSame(newEmail);
}
UserType newType = company.IsEmailCorporate(newEmail) ? UserType::Employee : UserType::Customer;
if (newType != _type) {
int delta = newType == UserType::Employee ? 1 : -1;
company.ChangeNumberOfEmployees(delta);
AddDomainEvent(new UpdateUserTypeEvent(_userId, _type, newType));
}
_email = newEmail;
_type = newType;
AddDomainEvent(new UpdateUserEmailEvent(messageBus, _userId, _email));
}
- 도메인 이벤트로 처리하기 위해 User 객체 쪽으로 IMessageBus에 대한 메서드 형 의존성 주입이 필요하다.
결론
좋은 통합 테스트를 위한 방법론
- 실제 인스턴스를 사용할 것 인가 목으로 대체할 것인가
- 통합 테스트는 도메인 계층이 잘 분리되어 있다는 가정 하에 주로 컨트롤러를 테스트한다.
- 가치 있는 통합 테스트를 만들기 위해 노력하라. 단위 테스트로 대체될 수 있는 케이스에 대해 빠른 실패의 원칙을 적용하라.
- 관리 의존성 (외부에서 보이지 않는 세부 구현 사항)과 비관리 의존성 (외부에서 보이는 식별 가능한 동작)을 각각 실제 인스턴스와 목을 사용하여 대체해라
- 통합 테스트는 가능한 거치는 로직이 많아야한다. (커버리지가 높아야한다.)
- 구현체가 하나뿐인 인터페이스는 의미가 없다. 목을 위한 인터페이스를 정의하라.
- 도메인 분리 / 간접 계층 줄이기 / 순환 계층 참조 없애기를 위해 노력하라
- 여러 실행 구절은 올바르지 못한 테스트이다. 이를 분리해서 테스트 해라.
참고
Unit Testing 단위 테스트, 블라디미르 코리코프 지음
https://www.sandordargo.com/blog/2019/04/24/parameterized-testing-with-gtest#parameterizedtests
https://github.com/jinsnowy/UnitTest_Study
'TDD > 단위 테스트' 카테고리의 다른 글
[단위 테스트] 데이터베이스 테스트 (0) | 2022.12.25 |
---|---|
[단위 테스트] 목 활용하기 (0) | 2022.12.24 |
[단위 테스트] 단위 테스트 리팩토링 (0) | 2022.12.23 |
[단위 테스트] 단위 테스트 스타일 (0) | 2022.12.20 |
[단위 테스트] 목과 테스트 취약성 (0) | 2022.12.19 |