•
8장에서 다루는 내용
◦
통합 테스트의 역할 이해
◦
테스트 피라미드의 개념 자세히 살펴보기
◦
가치 있는 통합 테스트 작성
•
단위 테스트에만 사용해선 시스템이 정상적으로 동작하는지 확신할 수 없다.
◦
단위 테스트는 비즈니스 로직을 확인하는 것에는 좋지만 비즈니스 로직을 외부와 독립된 상태에서 확인하는 것만으로는 시스템의 신뢰성을 확보할 수 없다.
◦
각 부분이 데이터베이스나 메시지 버스 등의 외부 시스템과 어떻게 통합되는지 확인해야 한다.
•
이 장에서는 통합 테스트의 역할, 즉 언제 적용해야 하는지와 일반적인 단위 테스트, 빠른 실패 원칙과 같은 다른 기법에 의존하는 것이 좋을지 등을 알아본다.
◦
프로세스 외부 의존성 중에서 어느 것을 통합 테스트에서 사용하고 모킹할지 알 수 있다.
◦
도메인 모델 경계를 명시하고 애플리케이션 계층을 줄이고 순환 의존성을 제거하는 등 코드를 개선하는 데 도움이 되는 통합 테스트 모범 사례도 찾아볼 것이다.
◦
마지막으로 구현이 하나 뿐인 인터페이스 사용을 자제해야하는 이유와 로깅 기능을 언제, 어떻게 테스트하는지 알아본다.
1. 통합 테스트는 무엇인가?
•
통합 테스트는 테스트에서 중요한 역할을 하며 단위 테스트와 균형을 맞추는 것이 중요하다.
•
먼저 통합 테스트와 단위 테스트의 차이점을 알아보자.
1.
통합 테스트의 역할
•
통합 테스트는 다음 세 가지 요구 사항 중 하나라도 충족하지 않는 테스트다.
◦
단일 동작 단위를 검증하고
◦
빠르게 수행하며
◦
다른 테스트와 별도로 처리한다.
•
통합 테스트는 시스템이 프로세스 외부 의존성과 통합해 어떻게 동작하는지를 검증한다. 다시 말해 이 테스트는 컨트롤러 사분면에 속하는 코드를 테스트한다.
•
단위 테스트는 도메인 모델을 다루므로 코드 복잡도를 테스트하는 반면 통합 테스트는 프로세스 외부 의존성 즉 협력자를 주로 다룬다.
2.
다시 보는 테스트 피라미드
•
통합 테스트는 다음과 같은 장, 단점들을 가진다.
•
단점
◦
통합 테스트는 프로세스 외부 의존성에 의존하므로 느리며 유지비용이 증가한다.
▪
프로세스 외부 의존성 세팅이 필요하다
▪
관련된 협력자가 많아서 테스트가 커진다.
•
장점
◦
코드를 더 많이 수행하므로 회귀 방지가 단위 테스트보다 우수하다.
◦
진정한 동작 단위를 검증하므로 제품 코드와의 결합도가 낮아서 리팩토링 내성도 우수하다.
3.
통합 테스트와 빠른 실패
•
통합 테스트에서 프로세스 외부 의존성과의 상호 작용을 모두 확인하려면 가장 긴 시나리오를 선택하라.
•
부득이하다면 외부 시스템과의 통신을 모두 확인하는데 필요한만큼 통합 테스트를 추가로 작성하면 된다.
2. 직접 테스트해야 하는 프로세스 의존성은 무엇인가?
•
통합 테스트는 시스템이 프로세스 외부 의존성과 어떻게 통합되는지를 검증한다.
•
이러한 검증을 구현하는 방식은 다음과 같이 두 가지 방법이 있다.
◦
실제 프로세스 외부 의존성을 사용한다.
◦
해당 의존성을 목으로 대체한다.
1.
프로세스 외부 의존성의 두 가지 유형
•
모든 프로세스 외부 의존성은 두 가지 범주로 나뉜다.
◦
관리 의존성
▪
어플리케이션을 통해서만 접근할 수 있으며 해당 의존성과의 상호작용은 외부에서 확인할 수 없다.
▪
대표적인 예로 데이터베이스가 있다. 외부 시스템은 보통 데이터베이스에 직접 접근하지 못하고 어플리케이션에서 제공하는 API를 통해 접근한다.
◦
비관리 의존성
▪
해당 의존성과의 상호작용은 외부에서 확인 가능하다.
▪
예를 들어 SMTP 서버와 메시지 버스 등이 있다. 둘 다 다른 어플리케이션에서 확인 가능한 사이드 이펙트를 발생시킨다.
•
앞서 관리 의존성과의 통신은 구현 세부 사항이라고 했다. 반대로 비관리 의존성과의 통신은 시스템의 식별할 수 있는 동작이다.
•
이러한 차이로 인해 통합 테스트에서 프로세스 외부 의존성의 처리가 달라진다.
◦
관리 의존성은 실제 인스턴스를 사용하고 비관리 의존성은 목으로 대체하라.
•
비관리 의존성에 대한 통신 패턴을 우리가 마음대로 변경할 수 없는 이유는 하위 호환성을 지켜야하기 때문이다.
◦
이는 모킹이 유용하다. 목을 사용하면 모든 가능한 리팩토링을 고려해서 통신 패턴의 영속성을 보장할 수 있다.
•
통합 테스트에서 관리 의존성의 실제 인스턴스를 사용하면 외부 클라이언트 관점에서 최종 상태를 확인할 수 있다.
2.
관리 의존성이면서 비관리 의존성인 프로세스 외부 의존성 다루기
•
때로는 이 둘의 성격을 모두 가지는 프로세스 외부 의존성이 있다.
◦
좋은 예로 다른 어플리케이션도 접근 가능한 데이터베이스가 있다. 아마 MSA 구조의 데이터베이스.. 아닐까?
◦
데이터베이스의 테이블이 팀 간 공유된다면 이는 비관리 의존성으로 취급해야 한다.
•
이러한 테이블은 사실상 메시지 버스 역할을 하고 각 행이 메시지 역할을 한다.
◦
이러한 테이블은 목을 사용하라. 나머지 데이터베이스를 관리 의존성으로 처리하고 데이터베이스와의 상호 작용을 검증하지 말고 최종 상태를 확인하라
•
데이터베이스에서 이 두 부분을 분리하는 것이 중요하다.
3.
통합 테스트에서 실제 데이터베이스를 사용하는 것이 불가능하다면?
•
때로는 통합 테스트에서 관리 의존성을 실제 데이터베이스로 사용할 수 없는 경우도 있다.
•
관리 의존성임에도 불구하고 목으로 처리해야할까? 불가하다 이는 리팩토링 내성을 약화시키는 행위다.
•
데이터베이스를 테스트하는 것이 불가능하다면 단위 테스트에 집중하는 것이 좋다.
3. 통합 테스트: 예제
•
다음 코드에 대한 통합 테스트를 작성한다고 생각해보자.
public class UserController {
private readonly Database _database = new Database();
private readonly MessageBus _messageBus = new MessageBus();
public string ChangeEmail(int userId, string newEmail) {
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null)
return error;
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
foreach (EmailChangedEvent ev in user.EmailChangedEvents) {
_messageBus.SendEmailChangedMessage(ev.UserId, ev.NewEmail);
}
return "OK";
}
}
C#
복사
•
여기서 시스템은 사용자의 이메일 변경 기능만 수행한다. 나머지는 데이터베이스, 메시지 버스 등의 프로세스 외부 의존성에게 동작을 위임한다.
1.
어떤 시나리오를 테스트할까?
•
통합 테스트의 일반적인 지침은 가장 긴 주요 흐름과 단위 테스트로 수행할 수 없는 모든 예외 상황을 다루는 것이다.
◦
가장 긴 주요 흐름이란 모든 프로세스 외부 의존성을 거치는 것이다.
•
CRM 프로젝트에서 가장 긴 주요 흐름은 기업 이메일에서 일반 이메일로 변경하는 것이다. 이 시나리오가 가장 많은 사이드 이펙트를 만들어낸다.
◦
데이터베이스에서 사용자와 회사가 모두 업데이트된다. 사용자는 유형이 변경되며 회사는 직원 수를 변경한다.
◦
메시지 버스로 메시지를 보낸다.
2.
데이터베이스와 메시지 버스 분류하기
•
두 프로세스 외부 의존성에 대해서 어떤 것을 모킹하고 어떤 것을 실제로 사용할지 결정해보자.
•
먼저 어플리케이션 데이터베이스는 어떤 시스템도 접근할 수 없으므로 관리 의존성이다. 따라서 실제 인스턴스를 사용해야 한다.
◦
통합 테스트는 데이터베이스에 사용자와 회사를 삽입하고
◦
해당 데이터베이스에서 이메일 변경 시나리오를 실행하며
◦
데이터베이스 상태를 검증하게 된다.
•
반면 메시지 버스는 비관리 의존성이다. 따라서 통합 테스트는 메시지 버스를 목으로 대체하고 컨트롤러와 목 간의 상호 작용을 검증하게 된다.
3.
E2E 테스트는 어떤가?
•
E2E 테스트를 작성한다는 것은 모든 프로세스 외부 의존성을 실제 인스턴스로 유지하여 테스트하겠다는 것이다.
•
통합 테스트는 관리 의존성을 포함시키고 비관리 의존성을 목으로 대체하는 것만으로도 보호 수준이 E2E 테스트와 비슷해지므로 생략할 수 있다.
•
하지만 배포 후 상태 점검을 위해 한 개 또는 두 개 정도의 중요한 E2E 테스트를 작성할 수 있다.
4.
통합 테스트: 첫 번째 버전
public void Changing_email_from_corporate_to_non_corporate() {
// Arrange
var db = new Database(ConnectionString); // Database repository
User user = CreateUser("user@mycorp.com", UserType.Employee, db); // Creates the user and company in the database
CreateCompany("mycorp.com", 1, db);
var messageBusMock = new Mock<IMessageBus>(); // Sets up a mock for the message bus
var sut = new UserController(db, messageBusMock.Object);
// Act
string result = sut.ChangeEmail(user.UserId, "new@gmail.com");
// Assert
Assert.Equal("OK", result);
// Asserts the user’s state
object[] userData = db.GetUserById(user.UserId);
User userFromDb = UserFactory.Create(userData);
Assert.Equal("new@gmail.com", userFromDb.Email);
Assert.Equal(UserType.Customer, userFromDb.Type);
// Asserts the company’s state
object[] companyData = db.GetCompany();
Company companyFromDb = CompanyFactory
.Create(companyData);
Assert.Equal(0, companyFromDb.NumberOfEmployees);
// Checks the interactions with the mock
messageBusMock.Verify(
x => x.SendEmailChangedMessage(
user.UserId, "new@gmail.com"),
Times.Once);
}
C#
복사
•
입력 매개변수로 사용한 데이터와 별개로 데이터베이스의 최종 상태를 확인하는 것이 중요하다.
•
이를 위해 통합 테스트는 검증 영역에서 사용자와 회사 데이터를 각각 조회하고 새로운 인스턴스를 생성한 후에 해당 상태를 검증만 한다.
•
이렇게 하면 테스트가 읽기 쓰기를 모두 수행하므로 회귀 방지를 극대화시킬 수 있다.
◦
읽기는 컨트롤러에서 내부적으로 사용하는 동일한 코드를 써서 구현해야 한다.
•
이 정도 코드로도 통합 테스트는 그 역할을 다 했다고 볼 수 있지만, 헬퍼 메서드 등을 이용해 좀 더 개선할 여지가 남아있긴 하다.
4. 의존성 추상화를 위한 인터페이스 사용
•
단위 테스트 시 가장 많이 오해하는 주제 중 하나가 인터페이스 사용이다.
◦
인터페이스를 둔 이유를 개발자들이 잘못 설명하고 그 결과 남용하게되는 경향이 있다.
•
이 절에서는 잘못된 이유를 알아보고 인터페이스 사용이 어떤 환경에서 바람직한지 알아본다.
1.
인터페이스와 느슨한 결합
•
많은 개발자가 데이터베이스나 메시지 버스와 같은 프로세스 외부 의존성을 위해 인터페이스를 도입한다.
◦
심지어 인터페이스의 구현이 하나만 있는 경우도 그렇게 한다.
•
이 관습은 오늘날 널리 펴져 있어서 아무도 이를 지적하지 않는다.
•
인터페이스를 사용하는 주 목적으로는 프로세스 외부 의존성을 추상화하여 느슨한 결합을 하게 해주고 기존 코드를 변경하지 않고 새로운 기능을 추가해 OCP를 지키기 때문이다.
•
이 두 이유 모두 오해에 해당한다.
◦
단일 구현을 위한 인터페이스는 추상화가 아니며 해당 인터페이스를 구현하는 구체 클래스보다 결합도가 낮지 않다.
▪
따라서 인터페이스가 추상화의 역할을 충실히 수행해내려면 최소한 구현이 두 가지는 있어야 한다.
◦
2번째 이유는 YAGNI를 위반하기 때문이다. 현재 필요하지 않은 기능에 불필요한 시간을 낭비하지 말라는 원칙이다.
2.
프로세스 외부 의존성에 인터페이스를 사용하는 이유는 무엇일까?
•
그렇다면 각 인터페이스에 구현이 하나만 있을때, 프로세스 외부 의존성에 인터페이스를 사용하는 이유는 무엇일까?
•
간단히 말하자면 목을 사용하기 위함이다. 인터페이스가 없다면 테스트 더블을 만들 수 없으므로 테스트 시 상호작용을 확인하기 힘들다.
•
따라서 이러한 의존성을 목으로 처리할 필요가 없는한 프로세스 외부 의존성에 대한 인터페이스를 두지 말라.
•
결국 비관리 의존성만 목으로 처리할 필요가 있으므로 프로세스 외부 의존성 중 비관리 의존성에만 인터페이스를 작성하게 된다.
3.
프로세스 내부 의존성을 위한 인터페이스 사용
•
때로는 프로세스 외부 의존성뿐만 아니라 내부 의존성도 인터페이스 기반인 코드가 존재한다.
•
프로세스 외부 의존성과 마찬가지로 도메인 클래스에 대해서 단일 구현으로 인터페이스를 도입하는 이유는 목으로 처리하기 위해서 뿐이다.
•
프로세스 외부 의존성과 달리 도메인 클래스 간의 상호 작용을 확인해서는 안 된다. 그렇게하면 깨지기 쉬운 테스트로 이어지고 결국 리팩토링 내성이 떨어지게 된다.
5. 통합 테스트 모범 사례
•
통합 테스트를 활용하게 되는데, 도움이 되는 몇 가지 일반적인 지침이 있다.
◦
도메인 모델 경계 명시하기
◦
어플리케이션 내 계층 줄이기
◦
순환 의존성 제거하기
1.
도메인 모델 경계 명시하기
•
항상 도메인 모델을 코드에서 명시적이고 잘 알려진 위치에 두도록 하자. 도메인 모델은 프로젝트가 해결할 문제에 대한 도메인 지식의 모음이다.
•
이 도메인 모델에 대한 명시적인 경계를 잘 지정해두면 코드의 해당 부분을 좀 더 잘 설명하고 이해할 수 있는 강점이 있다.
•
테스트를 위해 코드를 리팩토링하기 때문에 테스트에도 도움이 된다.
2.
계층 수 줄이기
•
대부분의 프로그래머는 간접 계층을 늘려 코드를 추상화하고 일반화하려 한다.
•
애플리케이션에 추상 계층이 너무 많으면 코드를 확인하기 힘들고 간단한 연산이라도 숨은 로직을 이해하기 어려워진다.
•
간접 계층은 코드는 오히려 각 경계를 모호하게 만든다. 가능한 간접 계층을 적게 사용하는 습관을 들이도록 하자.
3.
순환 의존성 제거
public class CheckOutService {
public void CheckOut(int orderId) {
var service = new ReportGenerationService();
service.GenerateReport(orderId, this);
/* other code */
}
}
public class ReportGenerationService {
public void GenerateReport(int orderId, CheckOutService checkOutService) {
/* calls checkOutService when generation is completed */
}
}
C#
복사
•
너무 많은 추상 계층이 문제가 되듯이 순환 의존성은 가독성에 해가 된다.
•
순환 의존성이 있으면 해결책을 찾기 위한 출발점이 명확하지 않고, 하나의 클래스를 이해하기 위해 주변의 클래스를 한 번 순회해야하기 때문이다.
•
또한 순환 의존성은 테스트를 방해하는데, 동작을 하나 하나 분리하여 Mock으로 처리하기 위해 인터페이스에 의존해야하는 경우가 빈번해지기 때문이다.
•
이렇게 인터페이스를 넣으면 단순히 순환 의존성의 문제만 가릴 뿐, 실제 코드의 런타임에는 순환이 존재하는 문제가 있다.
•
결국 순환 의존성을 해결하려면 이를 제거하는 방법이 최선이다.
•
예제를 기준으로 보면 ReportGenerationService을 리팩토링하여 CheckOutService나 ICheckOutService에 의존하지 않도록 하고, 작업 결과를 반환하는 방식으로 바꿔야 한다.
•
좋은 순환 의존성은 존재하지 않는 순환 의존성 뿐이다.
4.
테스트에서 다중 실행 구절 사용
•
테스트에서 두 개 이상의 arrange나 action, assert 구문을 두는 것은 코드 스멜에 해당한다.
•
이는 테스트가 단일된 동작 단위를 검증하지 않으므로 유지 보수성을 저해시키고 있다는 신호다.
•
각 실행을 고유의 테스트로 추출하는 것이 좋다.
•
이 지침의 예외로 원하는 상태로 만들기 어려운 프로세스 외부 의존성으로
•
다만 예외적으로 원하는 상태로 만들기가 까다로운 프로세스 외부 의존성인 경우 여러 동작을 하나루 묶어서 처리해야 한다.
•
이 방법은 프로세스 외부 의존성과의 상호 작용 횟수를 줄이는 효과가 있으므로 어느 정도 타당하다고 볼 수 있다.
7. 결론
•
식별할 수 있는 동작인지, 구현 세부 사항인지 여부에 대한 관점으로 프로세스 외부 의존성과의 통신을 살펴보자.
요약
•
통합 테스트는 단위 테스트가 아닌 테스드다. 통합 테스트는 시스템이 프로세스 외부 의존성과 통합해 동작하는 방식을 검증한다.
◦
통합 테스트는 컨트롤러를 다루고 단위 테스트는 알고리즘과 도메인 모델을 다룬다.
◦
통합 테스트는 회귀 방지와 리팩토링 내성이 우수하고 단위 테스트는 유지 보수성과 피드백 속도가 우수하다.
•
통합 테스트의 기준은 단위 테스트보다 높다.
◦
통합 테스트에서 회귀 방지와 리팩토링 내성 지표에 대한 점수는 단위 테스트보다 높아야 한다.
◦
대부분의 단위 테스트는 빠르면서 비용이 낮아야 하고 시스템이 전체적으로 올바른지 확인하는 통합 테스트는 속도가 느리고 비용이 많이 발생하므로 그 수가 적어야 한다.
▪
단위 테스트를 통해 가능한 많은 비즈니스 시나리오의 예외 상황을 확인해야 한다. 통합 테스트를 사용해서는 하나의 주요 흐름과 단위 테스트로는 확인할 수 없는 예외 상황을 다루도록 하라.
▪
테스트 피라미드의 모양은 프로젝트 복잡도에 따라 달라진다. 간단할 수록 단위 테스트가 적어질 것이다.
•
빠른 실패 원칙은 버그가 빠르게 나타날 수 있도록 하며 통합 테스트에서 할 수 있는 대안이다.
•
프로세스 외부 의존성
◦
관리 의존성은 어플리케이션을 통해서만 접근할 수 있는 프로세스 외부 의존성이다.
▪
관리 의존성과의 상호 작용은 외부에서 관찰할 수 없다. 대표적인 예는 어플리케이션 데이터베이스다.
◦
비관리 의존성은 다른 어플리케이션이 접근 가능한 프로스 외부 의존성이다.
▪
비관리 의존성과의 상호 작용은 외부에서 관찰할 수 있다. 대표적인 예는 메시지 버스나 SMTP 서버 등이 있다.
◦
관리 의존성과의 통신은 구현 세부 사항이고 비관리 의존성과의 통신은 식별할 수 있는 동작이다.
◦
통합 테스트에서 관리 의존성은 실제 인스턴스를 사용하고 비관리 의존성은 목으로 대체하라.
◦
때로는 관리 의존성과 비관리 의존성의 성격을 둘다 나타내는 프로세스 외부 의존성도 존재한다.
▪
식별할 수 있는 동작과 구현 세부 사항을 분리해 각각을 목, 인스턴스로 취급해주는 것이 좋다.
◦
통합 테스트는 관리 의존성을 사용하는 모든 계층을 거쳐야 한다.
•
구현이 하나뿐인 인터페이스는 추상화가 아니다.
◦
이를 사용하기에 타당한 이유는 목을 사용하기 위한것뿐이다. 비관리 의존성에만 이를 적용하고 관리 의존성은 구체 클래스를 두자.
•
도메인 모델을 코드베이스에 찾기 쉬운 곳에 두라. 도메인 클래스와 컨트롤러 사이의 경계가 명확하다면 단위 테스트와 통합 테스트를 좀 더 쉽게 구분할 수 있다.
•
간접 계층이 많아지면 코드가 복잡해진다. 가능한 적게 두라, 대부분의 백엔드 시스템은 도메인 모델, 어플리케이션 서비스 계층, 인프라 계층 이 셋만 존재한다.
•
순환 의존성이 존재하면 뇌가 복잡해진다.
•
테스트에 여러 실행 구절이 있다는 것은 하나의 독립된 동작 단위가 아님을 의미한다.
◦
이것이 타당하려면 올바른 상태가 되기 어려운 프로세스 외부 의존성으로만 동작하는 경우에만 타당하다.
•
항상 모든 의존성을 생성자 또는 메소드 인수를 통해 명시적으로 주입하라