8장, 통합 테스트를 하는 이유

2022.04.20

단위 테스트에만 전적으로 으존하면 시스템이 전체적으로 잘 작동하는지 확신할 수 없다. 단위 테스트가 비지니스 로직을 확인하는 데 좋지만, 비지니스 로직을 외부와 돤절된 상태로 확인하는 것만으로는 충분하지 않다. 각 부분이 데이터베이스나 메시지 버스 등 외부 시스템과 어떻게 통합되는지 확인해야 한다.

8.1 통합 테스트는 무엇인가?

통합 테스트는 테스트 스위트에서 중요한 역할을 하며, 단위 테스트 개수와 통합 테스트 개수의 균형을 맞추는 것도 중요하다.

8.1.1 통합 테스트의 역할

단위 테스트가 아닌 모든 테스트가 통합 테스트에 해당한다.
실제 통합 테스트는 대부분 시스템이 프로세스 외부 의존성과 통합해 어떻게 작동하는지를 검증한다.
단위 테스트는 도메인 모델을 다루는 반면, 통합 테스트는 프로세스 외부 의존성과 도메인 모델을 연결하는 코드를 확인한다.


간단한 코드는 노력을 들일만한 가치가 없고, 지나치게 복잡한 코드는 알고리즘과 컨트롤러로 리팩터링해야 한다.
모든 테스트는 도메인 모델과 컨트롤러 사분면에만 초점을 맞춰야 한다.

8.1.2 다시 보는 테스트 피라미드

단위 테스트와 통합 테스트의 비율은 프로젝트의 특성에 따라 다를 수 있지만, 일반적인 경험에 비춰본 규칙은 다음과 같다.
단위 테스트로 가능한 한 많이 비지니스 시나리오의 예외 상황을 확인하고, 통합 테스트는 주요 흐름(happy path)과 단위 테스트가 다루지 못하는 기타 예외 상황(edge case)을 다룬다.

주요 흐름은 시나리오의 성공적인 실행이다. 예외 상황은 비지니스 시나리오 수행 중 오류가 발생하는 경우다.


테스트 피라미드는 프로젝트의 복잡도에 따라 모양이 많이 다를 수 있다. 단순 애플리케이션은 도메인 모델과 알고리즘 사분면에 거의 코드가 없다. 결국 테스트 구성이 피라미드 대신 직사각형이 되며, 단위 테스트와 통합 테스트의 수가 같다. 아주 단순한 경우라면, 어떠한 단위 테스트도 없을 것이다.
통합 테스트는 단순한 애플리케이션에서도 가치가 있다. 코드가 얼마나 간단한지보다 다른 서브 시스템과 통합해 어떻게 작동하는지 확인하는 것이 더 중요하다.

8.1.3 통합 테스트와 빠른 실패

통합 테스트에서 프로세스 외부 의존성과의 상호 작용을 모두 확인하려면 가장 긴 주요 흐름을 선택하라. 이렇게 모든 상호 작용을 거치는 흐름이 없으면, 외부 시스템과의 통신을 모두 확인하는 데 필요한 만큼 통합 테스트를 추가로 작성하라.
컨트롤러에서 CanChangeEmail()을 호출하는 것과 달리, User에 사전 조건이 있는지를 테스트해야 한다. 이는 단위 테스트로 하는 것이 더 낫고, 통합테스트는 필요하지 않다.
버그를 빨리 나타나게하는 것을 빠른 실패 원칙(Fast Fail principle)이라고 하며, 통합테스트에서 할 수 있는 대안이다.

빠른 실패 원칙

빠른 실패 원칙은 예기치 않은 오류가 발생하자마자 현재 연산을 중단하는 것을 의미한다.
이 원칙은 다음을 통해 애플리케이션의 안정성을 높인다.

  • 피드백 루프 단축: 버그를 빨리 발견할 수록 더 쉽게 해결할 수 있다. 운영 환경으로 넘어온 버그는 개발 중에 발견된 버그보다 수정 비용이 훨씬 더 크다.
  • 지속성 상태 보호: 버그는 애플리케이션 상태를 손상시킨다. 손상된 상태가 데이터베이스로 침투하면, 고치기가 훨씬 어려워진다. 빨리 실패하면 손상이 확산되는 것을 막을 수 있다.

    보통 예외를 던져서 현재 연산을 중지한다. 예외는 그 의미가 빠른 실패 원칙에 완벽히 부합되기 때문이다. 예외는 프로그램 흐름을 중단하고 실행 스택에서 가장 높은 레벨로 올라간 후 로그를 남기고 작업을 종료하거나 재시작할 수 있다.

8.2 어떤 프로세스 외부 의존성을 직접 테스트해야 하는가?

통합 테스트는 시스템이 프로세스 외부 의존성과 어떻게 통합하는지를 검증한다. 이를 검증하는데 두 가지의 방식이 있는데, 실제 프로세스 외부 의존성을 사용하거나 해당 의존성을 목으로 대체하는 것이다.

8.2.1 프로세스 외부 의존성의 두 가지 유형

  • 관리 의존성(전체를 제어할 수 있는 프로세스 외부 의존성) : 이러한 의존성은 애플리케이션을 통해서만 접근할 수 있으며, 해당 의존성과의 상호 작용은 외부 환경에서 볼 수 없다. 대표적인 예로 데이터베이스가 있다.
  • 비관리 의존성(전체를 제어할 수 없는 프로세스 외부 의존성) : 해당 의존성과의 상호 작용을 외부에서 볼 수 있다. 예를 들어 SMTP 서버와 메시지 버스 등이 있다.

관리 의존성과의 통신은 구현 세부 사항. 비관리 의존성과의 통신은 시스템의 식별할 수 있는 동작이다.

관리 의존성은 실제 인스턴스를 사용하고, 비관리 의존성은 목으로 대체하라.

비관릐 의존성에 대한 통신 패턴을 유지해야 하는 것은 하휘 호환성을 지켜야했기 때문이다. 이 작업에는 목이 제격이다.

관리 의존성과 통신하는 것은 애플리케이션뿐이므로 하휘 호환성을 유지할 필요가 없다. 중요한 것은 시스템의 최종 상태다. 통합 테스트에서 관리 의존성의 실제 인스턴스를 사용하면 외부 클라이언트 관점에서 최종 상태를 확인할 수 있다.

8.2.2 관리 의존성이면서 비관리 의존성인 프로세스 외부 의존성 다루기

관리 의존성과 비관리 의존성 모두의 속성을 나타내는 프로세스 외부 종속성이 있을 수 있다. 좋은 예로, 다른 애플리케이션이 접근할 수 있는 데이터베이스가 있다.

이러한 테이블을 이용한 통신 패턴이 바뀌지 않도록 하려면 목을 사용하라. 나머지 데이터베이스를 관리 의존성으로 처리하고, 데이터베이스와의 상호 작용을 검증하지 말고 데이터베이스의 최종 상태를 확인하라.


8.2.3 관리 의존성이면서 비관리 의존성인 프로세스 외부 의존성 다루기

관리 범위를 벗어난다는 이유로, 통합 테스트에서 관리 의존성을 실제 버전으로 사용할 수 없는 경우도 있다.
보안 정책 때문이거나 테스트 데이터베이스 인스턴스를 설정하고 유지하는 비용이 만만치 않기 때문이다.

8.4 의존성 추상화를 위한 인터페이스 사용

단위 테스트 영역에서 가장 많이 오해하는 주제 중 하나는 인터페이스 사용이다. 인터페이스를 둔 이유를 개발자들이 자주 잘못 설명하고, 그 결과 남용하는 경향이 있다.

8.4.1 인터페이스와 느슨한 결합

많은 개발자가 데이터베이스나 메시지 버스와 같은 프로세스 외부 의존성을 위해 인터페이스를 도입한다. 심지어 인터페이스에 구현이 하나만 있는 경우에도 그렇다.
위와 같이 인터페이스를 사용하는 일반적인 이유는 인터페이스가

  • 프로세스 외부 의존성을 추상화해 느슨한 결합을 달성하고,
  • 공개 폐쇄 원칙(OCP)을 지키기 때문이다.

단일 구현을 위한 인터페이스는 추상화가 아니며, 해당 인터페이스를 구현하는 구체 클래스보다 결합도가 낮지 않다.
진정한 추상화는 발견하는 것이지, 발명하는 것이 아니다.
의미상 추상화가 이미 존재하지만 코드에서 아직 명확하게 정의되지 않았을 때 그 이후에 발견되는 것이다. 따라서, 인터페이스가 진정으로 추상화되려면 구현이 적어도 두 가지는 있어야 한다.

두 번째 OCP를 지킨다는 생각은 더 근본적인 원칙인 YAGNI를 위반하기 때문에 잘못된 생각이다. 여기에는 크게 두 가지 이유가 있다.

  • 기회 비용: 현재 비지니스 담당자들에게 필요하지 않은 기능에 시간을 보낸다면, 지금 당장 필요한 기능을 제치고 시간을 허비하는 것이다. 처음부터 실제 필요에 따라 기능을 구현하는 것이 유리하다.
  • 프로젝트 코드가 적을수록 좋다. 요구 사항이 바로 있는 경우가 아닌데도 많일을 위해 코드를 작성하면 코드베이스의 소유 비용이 불필요하게 증가한다.

8.4.2 프로세스 외부 의존성에 인터페이스를 사용하는 이유는 무엇인가?

각 인터페이스에 구현이 하나만 있다고 가정할 때 프로세스 외부 의존성에 인터페이스를 사용하는 이유는 목을 사용하기 위함이다.
인터페이스가 없으면 테스트 대역을 만들 수 없으므로 테스트 대상 시스템과 프로세스 외부 의존성 간의 상호 작용을 확인할 수 없다.

8.4.3 프로세스 내부 의존성을 위한 인터페이스 사용

프로세스 외부 의존성과 마찬가지로 도메인 클래스에 대해 단일 구현으로 인터페이스를 도입하는 이유는 목으로 처리하기 위한 것뿐이다. 그러나 프로세스 외부 의존성과 달리 도메인 클래스 간의 상호 작용을 확인 해서는 안 된다. 그렇게하면 깨지기 쉬운 테스트로 이어지고, 결국 리팩터링 내성이 떨어지게 된다.

8.5 통합 테스트 모범 사례

통합 테스트를 최대한 활용하는 데 도움이 되는 몇 가지 일반적인 지침이 있다.

  • 도메인 모델 경계 명시하기
  • 애플리케이션 내 계층 줄이기
  • 순환 의존성 제거하기

8.5.1 도메인 모델 경계 명시하기

항상 도메인 모델을 코드베이스에서 명시적이고 잘 알려진 위치에 두도록 하라. 도메인 모델은 프로젝트가 해결하고자 하는 문제에 대한 도메인 지식의 모음이다. 도메인 모델에 명시적 경계를 지정하면 코드의 해당 부분을 더 잘 보여주고 더 잘 설명할 수 있다.

단위 테스트는 도메인 모델과 알고리즘을 대상으로 하고, 통합 테스트는 컨트롤러를 대상으로 한다. 도메인 클래스와 컨트롤러 사이의 명확한 경계로 단위 테스트와 통합 테스트의 차이점을 쉽게 구별 할 수 있다.

8.5.2 계층 수 줄이기

대부분 프로그래머는 간접 계층을 추가해서 코드를 추상화하고 일반화하려고 한다. 일반적인 엔터프라이즈급 애플리케이션에서 여러 계층을 쉽게 찾아볼 수 있다.

애플리케이션에 추상 계층이 너무 많으면 코드베이스를 탐색하기 어렵고 아주 간단한 연산이라 해도 숨은 로직을 이해하기가 너무 어려워진다. 단순히 직면한 문제의 구체적인 해결 방법을 알고 싶을 뿐이지, 외부와 단절된 채로 해결책을 일반화하려는 것은 아니다.

컴퓨터 과학의 모든 문제는 또 다른 간접 계층으로 해결할 수 있다. 간접 계층이 너무 많아서 문제가 생기지 않는다면 말이다.

  • 데이빗 휠러(David J. Wheeler)

간접 계층은 코드를 추론하는 데 부정적인 영향을 미친다. 모든 기능이 각각의 계층으로 전개되면 모든 조각을 하나의 그림으로 만드는 데 상당한 노력이 필요하다.

추상화가 지나치게 많으면 단위 테스트와 통합 테스트에도 도움이 되지 않는다. 간접 계층이 많은 코드베이스는 컨트롤러와 도메인 모델 사이에 명확한 경계가 없는 편이다. 그리고 각 계층을 따로 검증하는 경향이 강해 통합 테스트의 가치가 떨어진다.

가능한 한 간접 계층을 적게 사용하라. 대부분의 백엔드 시스템에서는 도메인 모델, 애플리케이션 서비스 계층, 인프라 계층 이 세 가지만 활용하면 된다.


8.5.3 순환 의존성 제거하기

코드베이스의 유지 보수성을 대폭 개선하고 테스트를 더 쉽게 할 수 있는 또 다른 방법으로 순환 의존성을 제거하는 것이 있다.

순환 의존성(circular dependency 또는 cyclic dependency)은 둘 이상의 클래스가 제대로 작동하고자 직간접적으로 서로 의존하는 것을 말한다.

추상 계층이 너무 많은 것과 마찬가지로, 순환 의존성은 코드를 읽고 이해하려고 할 때 알아야 할 것이 많아서 큰 부담이 된다. 순환 의존성이 있으면 해결책을 찾기 위한 출발점이 명확하지 않기 때문이다. 하나의 클래스를 이해하려면 주변 클래스 그래프 전체를 한 번에 읽고 이해해야 하며, 심지어 소규모의 독립된 클래스 조차도 파악하기가 어려워질 수 있다.


순환 의존성을 처리하는 좋은 방법은 순환 의존성을 제거하는 것이다. CheckoutServiceICheckoutService 인터페이스에 의존하지 않도록 하고, ReportGenerationServiceCheckoutService를 호출하는 대신 작업 결과를 빈 값으로 리턴하게 하라.

코드베이스에서 순환 의존성을 모두 제거하는 것은 거의 불가능하다. 설령 그렇더라도 서로 의존적인 클래스의 그래프를 가능한 한 작게 만들면 손상을 최소화할 수 있다.

8.5.4 테스트에서 다중 실행 구절 사용

테스트에서 두 개 이상의 준비나 실행 또는 검증 구절을 두는 것은 코드 악취(code smell)에 해당한다.

  • 준비 : 사용자 등록에 필요한 데이터 준비
  • 실행 : UserController.ResgiterUser() 호출
  • 검증 : 등록이 성공적으로 완료됐는지 확인하기 위해 데이터베이스 조회
  • 실행 : UserController.DeleteUser() 호출
  • 검증 : 사용자가 삭제됐는지 확인하기 위해 데이터베이스 조회

위와 같은 방식은 사용자의 상태가 자연스럽게 흐르기 때문에 설득력이 있고, 첫 번째 실행은 두 번째 실행의 준비 단계 역할을 할 수 있다. 문제는 이러한 테스트가 초점을 잃고 순식간에 너무 커질수 있다는 것이다.
각 실행을 고유의 테스트로 추출해 테스트를 나누는 것이 좋다.
각 테스트가 단일 동작 단위에 초점을 맞추게 하면, 테스트를 더 쉽게 이해하고 필요할 때 수정할 수 있다.

8.7 결론

식별할 수 있는 동작인지, 아니면 구현 세부 사항인지 여부에 대한 관점으로 프로세스 외부 의존성과의 통신을 살펴 보자.
로그 저장소도 그런 면에서 전혀 다르지 않다. 개발자가 아닌 사람이 로그를 볼 수 있으면 로깅 기능을 목으로 처리하고, 그렇지 않으면 테스트하지 말라.

요약

  • 통합 테스트는 단위 테스트가 아닌 테스트에 해당한다.
    • 통합 테스트는 컨트롤러를 다루고, 단위 테스트는 알고리즘과 도메인 모델을 다룬다.
    • 통합 테스트는 회귀 방지와 리팩터링 내성이 우수하고, 단위 테스트는 유지 보수성과 피드백 속도가 우수하다.
  • 통합 테스트의 기준은 단위 테스트보다 높다. 회귀 방지와 리팩터링 내성 지표에 대한 점수는 단위 테스트보다 높아야 한다.
    • 대부분의 테스트는 빠르면서 비용이 낮아야 하고, 시스템이 전체적으로 올바른지 확인하는 통합 테스트는 속도가 느리고, 비용이 많이 발생하므로 그 수가 적어야 한다.
    • 테스트 피라미드의 모양은 프로젝트 복잡도에 따라 달라진다.
  • 빠른 실패 원칙은 버그가 빠르게 나타날 수 있도록 하며 통합 테스트에서 할 수 있는 대안이다.
  • 관리 의존성과의 통신은 구현 세부 사항이고, 비관리 의존성과의 통신은 식별할 수 있는 동작이다.
    • 관리 의존성은 애플리케이션을 통해서만 접근 가능한 외부 의존성, 비관리 의존성은 다른 애플리케이션이 접근할 수 있는 외부 의존성이다.
    • 관리 의존성은 실제 인스턴스를 사용, 비관리 의존성은 목으로 대체하라.
  • 구현이 하나뿐인 인터페이스를 사용하기에 타당한 이유는 목을 사용하기 위한 것뿐이다.
    • 그렇지 않은 경우는 YAGNI 원칙을 위배하며 추상화도 아니다.
  • 도메인 모델을 코드베이스에 명시적이고 잘 알려진 위치에 둬라. 도메인 클래스와 컨트롤러 사이의 경계가 명확하면 단위 테스트와 통합 테스트를 좀 더 쉽게 구분할 수 있다.
  • 간접 계층이 너무 많으면 코드를 추론하기가 어려워진다. 간접 계층을 가능한 한 적게 하라. 대부분의 백엔드 시스템은 도메인 모델, 애플리케이션 서비스 계층, 인프라 계층, 이 세 가지 계층만 있다.
  • 순환 의존성이 있으면 코드를 이해하려고 할 때 알아야 하는 부담이 커진다.
  • 테스트에 여러 실행 구절이 있는 것은 올바른 상태가 되기 어려운 프로세스 외부 의존성으로 작동하는 경우에만 타당하다.
    • 단위 테스트는 여러가지 실행을 해서는 안 된다.
    • 다단계 테스트는 대부분 엔드 투 엔드 테스트 범주에 속한다.