8. 왜 통합 테스트를 해야 하는가?
원제 : Why integration testing?
단위 테스트에만 전적으로 의존하면 시스템이 전반적으로 잘 동작하는 지 확인하는 데 한계가 있다.
이는 단위 테스트 특성상 비즈니스 로직 확인에는 유용하지만, 이 비즈니스 로직이 외부와 단절되어있는 상태에서도 정상적으로 동작하는지 확인할 수 없기 때문이다.
따라서 본 포스팅에서는 시스템의 각 부분이 외부 시스템과 어떻게 통합되는지 확인하는 방법에 대해서 알아보고,
통합 테스트의 역할과 단위 테스트나 빠른 실패(Fall Fast) 원칙과 같은 기법에 어떻게 의존하는지에 대해서도 알아본다.
8.1. 통합테스트란 무엇인가?
원제 : What is an integration test?
통합 테스트는 테스트 스위트에서 중요한 역할을 하며, 단위 테스트의 개수와 통합 테스트의 개수의 균형을 맞추는 것이 필요하다.
먼저 통합 테스트와 단위 테스트의 차이점을 다시 정리해보자.
8.1.1. 통합 테스트의 역할
원제 : The role of integration tests
앞선 포스팅에서 알아본 단위 테스트가 충족해야할 세 가지 요구사항을 다시 살펴보자.
- 작은 코드 조각(이를 “단위”라고도 함)을 검증하고,
- 빠르게 수행하며,
- 격리된 방식으로 처리하는 자동화된 테스트
위 세 가지 요구사항 중 하나라도 충족하지 못하는 경우, 통합 테스트 범주에 속한다고 정의했었다.
다시말해, 단위 테스트가 아닌 모든 테스트를 통합 테스트로 간주하면 된다.
실제로 대부분의 통합 테스트는 시스템이 프로세스 외부 의존성과 통합해 어떻게 작동하는지를 검증한다.
즉, 7장에서 다룬 도표 중 컨트롤러에 해당하는 D유형에 해당한다고 볼 수 있다.
참고 단위 테스트는 당연히 A 유형에 해당한다.
8.1.2. 테스트 피라미드 다시 보기
원제 : The Test Pyramid revisited
통합 테스트 특성상 프로세스 외부 의존성에 직접 작동하면 느려질 수 밖에 없고, 이러한 형태의 테스트는 유지비용도 많이 지불해야한다.
유지비은 다음과 같은 두 가지 이유로 증가하게 된다.
- 프로세스 외부 의존성의 운영이 필요하다.
- 관련된 협력자가 많아서 테스트가 비대해진다.
반면 통합 테스트는 필연적으로 많은 코드를 거치며 수행되므로, 단위 테스트에 비해 회귀 방지가 우수하다.
단위 테스트와 통합 테스트의 비율에 대해 정답은 없지만 아래와 같은 절차로 진행하면 된다.
- 가능한 한 많은 비즈니스 로직을 단위 테스트로 검증한다.
- 단위 테스트가 검증하지 못하는 기타 예외 상황(Edge case) 은 통합 테스트로 검증한다.
- 그 외에 비즈니스에 대한 주요 흐름(Happy path) 도 통합 테스트로 검증한다.
참고
예외 상황이란 비즈니스 시나리오 수행 중 오류가 발생하는 경우를 말하며,
주요 흐름이란 흔히 해피 케이스라고 부르는 시나리오의 성공적인 실행을 말한다.
이하 해피 케이스, 엣지 케이스로 명명한다.
이제 4장의 테스트 피라미드를 다시 보자.
물론 프로젝트의 복잡도에 따라 피라미드의 모양이 다를 순 있겠지만 위의 절차를 이용해 피라미드에 가까운 모양을 만들어야 한다.
8.1.3. 통합 테스트 vs 빠른 실패
원제 : Integration testing vs. failing fast
이번엔 통합 테스트를 통해 비즈니스 시나리오당 하나의 해피 케이스와 엣지 케이스를 처리하는 방법에 대해 알아보자.
통합 테스트에서 프로세스 외부 의존성과의 상호 작용을 모두 확인하려면 가장 긴 해피 케이스에 대해 테스트를 수행하는 것이 좋다.
만약 최장 해피 케이스에 해당하지 않는 외부 의존성이 있다면 별도의 통합 테스트를 추가로 작성해야한다.
상술했듯 테스트라는 전체 집합에서 단위 테스트의 여집합은 통합 테스트라고 볼 수 있다.
그렇다고 해서 통합 테스트가 모든 엣지 케이스를 처리할 수 있고, 처리해야하는 것은 아니다.
예를 들어 어떠한 엣지 케이스에서 전체 애플리케이션이 즉시 실패하는 경우라면 테스트할 필요가 없다고 볼 수 있다.
7장에서 다룬 고객 관리 시스템을 다시 한 번 살펴보자.
살펴볼 예제는 User
클래스의 ChangeEmail()
메서드이다.
1 | // User.cs |
컨트롤러는 CanChangeEmail()
메서드를 호출하고 해당 메서드가 오류를 반환하면 연산을 중단한다.
1 | // UserController.cs |
위의 코드에서 HERE
로 표시된 부분이 엣지 케이스이다.
만약 여기서 컨트롤러가 CanChangeEmail()
메서드를 참조하지 않고, 이메일을 변경하려고 하는 경우
애플리케이션이 실패하고, 실행만 하면 바로 버그를 특정할 수 있으므로 이를 쉽게 찾아내 수정할 수 있다.
즉 테스트를 통해 검증할 필요가 없는 케이스이다.
빠른 실패 원칙(The Fail Fast principle)
빠른 실패 원칙은 예기지 않은 오류가 발생하자마자 현재 동작을 중단하는 것을 의미한다.
이 원칙은 아래와 같은 효과로 애플리케이션의 안정성을 높여준다.
- 피드백 루프 단축(Shortening the feedback loop)
버그를 빨리 발견할수록 더 쉽게 해결할 수 있다.
이미 운영 환경으로 넘어간 뒤 발견된 버그는 수정에 더 큰 비용을 치뤄야 한다.- 지속성 상태 보호(Protecting the persistence state)
버그는 애플리케이션의 상태를 손상 시킨다.
손상된 상태가 외부 저장소 등으로 침투하면 수정이 훨씬 어려워지므로, 빨리 실패하여 손상의 확산을 막는 것이 좋다.
8.2. 직접 테스트해야하는 프로세스 의존성은 무엇인가?
원제 : Which out-of-process dependencies to test directly
상술했듯 통합 테스는 시스템이 프로세스 외부 의존성과 어떻게 통합하는지를 검증한다.
이를 검증하는 방법은 크게 두 가지인데,
하나는 실제 프로세스 외부 의존성을 사용하거나, 해당 의존성을 Mock으로 대치하는 것이다.
두 가지 방식을 언제 적용해야하는지 알아보자.
8.2.1. 프로세스 외부 의존성을 테스트하는 두 가지 유형
원제 : The two types of out-of-process dependencies
모든 프로세스 외부 의존성은 두 가지 범주로 나눌 수 있다.
관리 의존성(Managed dependencies)
- 전체를 제어할 수 있는 프로세스 외부 의존성
- 관리 의존성은 애플리케이션을 통해서만 접근할 수 있으며, 해당 의존성과의 상호 작용은 외부 환경에서 볼 수 없다.
- 대표적인 예시 : 데이터베이스
비관리 의존성(Unmanaged dependencies)
- 전체를 제어할 수 없는 프로세스 외부 의존성
- 비관리 의존성과의 상호 작용은 외부 환경에서 볼 수 있다.
- 대표적인 예시 : SMTP 서버, 메시지 버스
5장에서 다룬 내용을 복기해보자.
관리 의존성과의 통신은 구현 세부 사항이며, 비관리 의존성과의 통신은 식별가능한 동작임을 추론할 수 있다.
정리하자면 관리 의존성은 구현 세부 사항이므로 실제 인스턴스를 이용하여 테스트하고,
비관리 의존성은 식별가능한 동작이므로 Mock으로 대치하여 테스트하면 된다.
아래 그림을 참고하자.
8.2.2. 관리 의존성이면서 비관리 의존성인 프로세스 외부 의존성
원제 : Working with both managed and unmanaged dependencies
모든 프로세스 외부 의존성은 관리 의존성과 비관리 의존성의 두 가지 범주로 나눌 수 있다고 했다.
하지만 관리 의존성과 비관릐 의존성의 특징을 모두 가지는 프로세스 외부 의존성이 존재할 수 있다.
가장 좋은 예시가 다른 애플리케이션이 접근할 수 있는 데이터베이스를 들 수 있다.
이 경우엔 다른 애플리케이션이 볼 수 있는 테이블을 비관리 의존성으로 취급해야한다.
공유된 테이블은 결국 메시지 버스와 같다는 것을 생각해보자.
8.2.3. 통합 테스트시 실제 데이터베이스를 사용할 수 없는 경우
원제 : What if you can’t use a real database in integration tests?
어떤 보안 정책이나, 유지 비용 등으로 테스트용 데이터베이스를 쓸 수 없다면 어떻게 해야할까?
관리 의존성임에도 데이터베이스를 Mock으로 처리하면 될까?
이는 회귀 방지를 낮추게 되므로 좋은 선택지가 아니다.
이럴땐 아예 통합 테스트를 작성하지않고 도메인 모델의 단위 테스트에 집중하는 것이 좋다.
8.3. 통합 테스트 : 예제
원제 : Integration testing: An example
다시 7장의 고객 관리 시스템을 살펴보자.
이 시스템은 사용자의 이메일 변경 기능만 구현되어 있으며, 데이터베이스에서 사용자와 회사를 검색하고 의사 결정은 도메인 모델에 위임한 다음,
그 결과를 데이터베이스에 저장한 뒤, 필요한 경우 메시지 버스에 메시지를 실어 보낸다.
그림으로 나타내면 아래와 같다.
컨트롤러 코드는 아래와 같다.
1 | public class UserController { |
8.3.1. 어떤 시나리오를 테스트해야하는가?
원제 : What scenarios to test?
이제 상술한 지침에 따라 가장 긴 해피 케이스와 엣지 케이스들을 식별해보자.
먼저 가장 긴 해피 케이스는 모든 프로세스 외부 의존성을 거치는 것이 기준이다.
고객 관리 프로젝트에를 기준으로 살펴보면 기업 이메일에서 일반 이메일로 변경하는 것이 가장 긴 해피 케이스이다.
- 데이터베이스에서 사용자와 회사 모두 업데이트 된다.
- 사용자는 유형을 기업에서 일반으로 변경한 뒤, 이메일도 변경한다.
- 회사는 직원 수를 변경한다.
- 메시지 버스로 메시지를 보낸다.
엣지 케이스는 하나로, 이메일을 변경할 수 없는 시나리오이다.
하지만 이 시나리오는 테스트할 가치가 없다.
컨트롤러에 변경 불가능에 대한 확인이 없으면 애플리케이션이 빠른 실패를 보장해주기 때문이다.
따라서 아래와 같은 통합 테스트 하나만이 남게 된다.
1 | public void Changing_email_from_corporate_to_non_corporate() |
8.3.3. 종단간 테스트는 어떻게 테스트 하는가?
원제 : What about end-to-end testing?
만약 작업하고 있는 프로젝트가 샘플 프로젝트라면 종단간 테스트(end-to-end) 가 없을 수 있다.
API의 경우 모두 배포 후 작동하는 버전의 API를 테스트하게 될 것이고, 외부 의존성에 대해 Mock으로 대체하지않는 뜻이다.
아래 그림을 참고하자.
종단간 테스트는 위 그림처럼 외부 클라이언트를 모방하므로, 테스트 범위에 포함된 모든 프로세스 외부 의존성을 참조하는 배포된 버전의 애플리케이션을 테스트하게 되는 것이다.
중요한 것은, 종단간 테스트는 관리 의존성에 대해 직접적으로 확인하지않고 애플리케이션을 통해 확인해야 한다.
반면 통합 테스트는 동일한 프로세스 내에서 애플리케이션을 호스팅하고 비관리 의존성을 Mock으로 대체한다.
이번에도 그림을 참고하자.
통합테스트는 비관리 의존성을 Mock으로 대체하기 때문에 관리 의존성만 참조하는 것을 볼 수 있다.
8.3.4. 통합 테스트 : 첫 번째 시도
원제 : Integration testing: The first try
본 포스팅에서 다룰 통합 테스트는 상술 했듯 단 하나이다.
1 | public void Changing_email_from_corporate_to_non_corporate() |
이 통합 테스트를 구현한 테스트 코드는 아래와 같다.
이 코드가 첫 번째 버전임을 알고 살펴보도록 하자.
1 | [ ] |
입력 매개변수로 주어지는 데이터와는 별개로 데이터베이스 상태를 확인하는 것이 중요하다.
이를 위해 통합 테스트는 Assert
영역에서 사용자와 회사의 상태를 각각 조회하고 새로운 객체인 userFromDb
와 companyFromDb
를 생성한 뒤 해당 상태를 검증한다.
이렇게 하면 테스트가 데이터베이스에 대한 읽기와 쓰기를 모두 수행하도록 하여 회귀 방지를 최대로 끌어올릴 수 있다.
이 정도 코드로도 통합 테스트는 그 역할을 다 했다고 볼 수 있지만, 헬퍼 메서드 등을 이용해 좀 더 개선할 여지가 남아있긴 하다.
또한 messageBusMock
은 회귀 방지가 높다고 볼 수 없다.
이에 대한 개선은 다음 포스팅에서 다시 다루어보도록 하자.
8.4. 의존성 추상화를 위한 인터페이스 사용
원제 : Using interfaces to abstract dependencies
단위 테스트에서 가장 많이 오해되는 것 중 하나는 인터페이스의 사용이다.
인터페이스를 선언한 이유를 개발자들이 잘못 설명하거나, 그 결과를 남용하는 경우가 많기 때문이다.
이번엔 무엇이 잘못된 설명인지 살펴보고, 인터페이스 사용을 위한 바람직한 환경에 대해서도 살펴보록 하자.
8.4.1. 인터페이스와 느슨한 결합
원제 : Interfaces and loose coupling
많은 개발자들이 데이터베이스나 메시지 버스와 같은 프로세스 외부 의존성을 위해 인터페이스를 도입한다.
심지어 인터페이스에 오직 하나의 구현만 존재하는 경우에도 그렇다.
이러한 습관은 너무 퍼져서 오히려 이것이 정답인 것처럼 받아들여지기도 한다.
예를 들어 아래와 같은 클래스와 인터페이스 세트를 자주 볼 수 있다.
1 | public interface IMessageBus |
이렇게 인터페이스를 남용하는 일반적인 이유는 다음과 같다.
1. 인터페이스가 프로세스 외부 의존성을 추상화해 느슨한 결합을 달성한다.
단일 구현을 위한 인터페이스는 추상화에 해당하지 않으므로 이는 명백한 오해이다.
추상화가 무엇인지 표현할 수 있는 의미가 있긴 하지만, 이는 코드에서 정의하는 것이 아닌 발견되는 것에 가까우며,
진정한 추상화는 구현이 두 가지 이상 있어야한다.
2. 인터페이스는 기존 코드를 변경하지 않고 새로운 기능을 추가해 공개 폐쇄 원칙을 준수한다.
이는 공개 폐쇄 원칙보다 더 기본적인 3대 원칙 중 하나인 YAGNI를 위반한다.
YANGI는 현재 필요하지않은 기능에 시간을 들여서는 안되는 원칙을 말하며, 이는 기회 비용을 낭비하거나 프로젝트 코드의 경량화를 방해한다.
참고 공개 폐쇄 원칙(OCP: Open-Closed principle)도 SOLID 중 하나이다.
SOLID - 객체 지향 설계의 다섯가지 기본 원칙
참고 소프트웨어 개발 3개 원칙은 아래와 같다.
KISS(Keep It Simple Stupid!) : 되도록 단순하게 만들어 불필요하게 장황하거나 복잡한 코드를 지양하라
YANGI(You Ain’t Gonna Need It) : 꼭 필요한 작업만 하고, 필요없는 것들을 하지마라
DRY(Do not Repeat Yourself) : 동일한 코드를 반복해서 작성하지마라
8.4.2. 인터페이스를 프로세스 외부 의존성에 사용하는 이유는 무엇일까?
원제 : Why use interfaces for out-of-process dependencies?
그렇다면 인터페이스에 구현이 하나만 존재하는 경우 프로세스 외부 의존성에 써서는 안 될까?
이는 조금 다른 전제이다.
여기서 인터페이스를 쓰는 이유는 어떤 추상화나 설계 원칙을 지키기 위해서가 아니라 오로지 Mock을 사용하기 위함이다.
인터페이스를 쓰지 않고는 테스트 대역을 만들 수 없기 때문이다.
따라서 프로세스 외부 의존성을 Mock으로 처리할 필요가 있을 때에만 인터페이스를 사용하면 된다.
좀 더 명세해보자면 비관리 의존성이 Mock으로 대치할 대상이르모 이부분만 인터페이스로 대치하고, 관리 의존성은 컨트롤러에 명시적으로 주입하는 방식이다.
구현이 두 개 이상인 진정한 추상화는 Mock과 상관없이 인터페이스로 표현하는 데 문제가 없다는 것도 상기하자.
아래 예제를 살펴보자.
1 | public class UserController { |
UserController
는 생성자를 통해 데이터베이스와 메시지 버스를 주입받지만, 메시지 버스만 인터페이스로 되어있음을 확인할 수 있다.
8.4.3. 프로세스 내부 의존성에서 인터페이스 사용하기
원제 : Using interfaces for in-process dependencies
때로는 프로세스 내부 의존성도 인터페이스 기반인 코드를 볼 수 있다.
아래 예제를 보자.
1 | public interface IUser { |
IUser
인터페이스에 구현이 하나만 있는 경우 이는 좋지 않은 신호로 해석하면 된다.
프로세스 외부 의존성과 마찬가지로, 도메인 클래스에 대한 단일 구현으로 인터페이스를 도입하는 목적은 오로지 Mock으로의 대치임을 명시하자.
만약 내부 의존성을 Mock으로 바꾸어 도메인 클래스 간의 상호 작용을 확인하는 경우엔 오히려 깨지기 쉬운 테스트가 되어 리팩토링 내성을 낮추게 된다.
8.5. 통합 테스트 모범 사례
원제 : Integration testing best practices
통합 테스트를 최대한 활용하는 데 도움이 되는 몇 가지 일반적인 지침이 있다.
- 도메인 모델의 경계 명시(Making domain model boundaries explicit)
- 애플리케이션 내 계층 줄이기(Reducing the number of layers in the application)
- 순환 의존성 제거(Eliminating circular dependencies)
테스트에 유익한 모범 사례일 수록 코드베이스의 상태를 개선할 수 있다.
8.5.1. 도메인 모델의 경계 명시
원제 : Making domain model boundaries explicit
도메인 모델은 프로젝트가 해결하고자하는 문제에 대한 도메인 지식의 모음이다.
따라서 도메인 모델은 코드베이스 내에서 명시적이고 잘 알려진 위치에 두어야 한다.
이 도메인 모델에 대한 명시적 경계를 지정하면 코드의 해당 부분을 좀 더 잘 설명하고 이해할 수 있는 강점이 있다.
테스트를 위해 코드베이스를 리팩토링을 수행하는 만큼, 이러한 경계 표시는 테스트에도 당연히 도움이 된다.
도메인 클래스와 컨트롤러 사이에 명확한 경계는 단위 테스트와 통합 테스트를 쉽게 구별할 수 있는 강점도 가지고 있다.
8.5.2. 애플리케이션 내 계층 줄이기
원제 : Reducing the number of layers
코드를 추상화하고 일반화하기위해 간접적인 계층을 추가하는 것은 흔한 일이며
엔터프라이즈급 애플리케이션일수록 그 복잡도로 인해 이러한 계층 구조를 쉽게 찾아볼 수 있다.
극단적인 경우, 추상 계층이 너무 많아서 코드 베이스를 탐색하기 어려워지는 경우가 발생할 수도 있다.
너무 많은 추상 계층은 단위 테스트와 통합 테스트에도 도움이 되지않는다.
간접 계층이 많을 수록 컨트롤러와 도메인 모델 사이의 경계가 모호해지기 때문이다.
이 경우 각 계층을 따로 검증하는 케이스가 많은데, 이로 인해 통합 테스트의 가치는 하락고 하위 계층을 Mock으로 처리하게 되어 낮은 리팩토링 내성과 회귀 방지로 이어진다.
결론적으로 가능한 한 간접 계층을 적게 사용하는 습관을 들이도록 하자.
8.5.3. 순환 의존성 제거
원제 : Eliminating circular dependencies
대표적인 순환 의존성으로 콜백(callback)을 들 수 있다.
아래 코드를 보자.
1 | public class CheckOutService { |
너무 많은 추상 계층이 문제가 되듯이, 순환 의존성을 코드를 읽고 이해하는데 부담을 준다.
순환 의존성이 있으면 해결책을 찾기 위한 출발점이 명확하지 않고, 하나의 클래스를 이해하기 위해 주변의 클래스를 한 번 순회해야하기 때문이다.
또한 순환 의존성은 테스트를 방해하는데, 동작을 하나 하나 분리하여 Mock으로 처리하기 위해 인터페이스에 의존해야하는 경우가 빈번해지기 때문이다.
graph LR CheckOutService --> ICheckOutService; CheckOutService --> ReportGenerationService; ReportGenerationService --> ICheckOutService;
이렇게 인터페이스를 넣으면 단순히 순환 의존성의 문제만 가릴 뿐, 실제 코드의 런타임에는 순환이 존재하는 문제가 있다.
결국 순환 의존성을 해결하려면 이를 제거하는 방법이 최선이다.
예제를 기준으로 보면 ReportGenerationService
을 리팩토링하여 CheckOutService
나 ICheckOutService
에 의존하지 않도록 하고, 작업 결과를 반환하는 방식으로 바꿔야 한다.
1 | public class CheckOutService { |
코드베이스에서 순환 의존성을 모두 제거하는 것은 거의 불가능하며, 설령 가능하다고 하더라도 서로 의존적인 클래스의 그래프를 가능한 한 작게 만들어 손상을 최소화해야 한다.
8.5.4. 테스트내 다중 실행 구절 사용하기
원제 : Using multiple act sections in a test
AAA 패턴에 대해 알아볼 때 언급되었듯이, 테스트 내에서 두개 이상의 준비/실행/검증 구절을 두는 것은 권장되지않는다.
이는 테스트가 여러 가지 동작 단위를 확인한다는 신호로 유지보수성을 저해한다는 신호이기 때문이다.
예를 들어 사용자의 등록과 삭제를 하나의 통합 테스트에서 확인하려고 하는 경우 아래와 같은 구조를 가질 수 있다.
- 준비 : 사용자 등록에 필요한 데이터 준비
- 실행 :
UserController.RegisterUser()
호출 - 검증 : 등록 동작의 성공 여부를 확인하기 위해 데이터베이스 조회
- 실행 :
UserController.DeleteUser()
호출 - 검증 : 삭제 동작의 성공 여부를 확인하기 위해 데이터 베이스 조회
이러한 방식은 사용자의 상태가 자연스럽게 흐르기 때문에 어느정도 설득력이 있어보인다.
문제는 이러한 테스트 구조는 어떤 것을 검증하려는 것인지 모호해지면서 규모가 순식간에 커질 수 있다는 점이다.
따라서 각 실행을 고유의 테스트로 추출해 각각 테스트하는 것이 좋다.
다만 예외적으로 원하는 상태로 만들기가 까다로운 프로세스 외부 의존성인 경우 여러 동작을 하나루 묶어서 처리해야 한다.
이 방법은 프로세스 외부 의존성과의 상호 작용 횟수를 줄이는 효과가 있으므로 어느 정도 타당하다고 볼 수 있다.