Unit Testing 8장 : 통합 테스트를 하는 이유

    들어가기 전

    이 글은 단위테스트 8장을 공부하며 작성한 글입니다. 


    8. 통합 테스트를 하는 이유

    단위 테스트는 비즈니스 로직을 확인하는데는 좋지만, 비즈니스 로직을 외부와 단절된 상태로 확인하는 것만으로는 충분하지 않다. 각 부분이 외부 프로세스 의존성과 어떻게 통합되는지를 확인해야, 시스템이 전체적으로 안정적으로 돌아가는지를 확인할 수 있다. 이런 테스트를 '통합 테스트'라고 한다.


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

    테스트 스위트에서 통합 테스트는 단위 테스트만큼 중요한 역할을 한다. 또한, 적절한 통합 테스트와 단위 테스트의 비율을 맞추면 좋은 테스트 스위트를 구성할 수 있게 된다. 아래에서 통합 테스트에 대해 자세히 살펴본다. 


    8.1.1 통합 테스트의 역할

    단위 테스트는 아래 조건을 모두 만족하는 테스트다. 이 조건 중 하나라도 만족하지 않는다면 모두 '통합 테스트'로 볼 수 있다.

    • 단일 동작 단위를 검증한다.
    • 빠르게 수행된다.
    • 다른 테스트와 별도로 처리한다. 

    실제로 통합 테스트는 어플리케이션이 프로세스 외부 의존성과 통합되어 어떻게 동작하는지를 검증한다. 아래에서 단위 테스트와 통합 테스트가 어떤 사분면에 속하는지를 정확히 볼 수 있다.

    • 통합 테스트 : 컨트롤러 영역
    • 단위 테스트 : 도메인 모델 + 알고리즘 영역

    통합 테스트는 프로세스 외부 의존성과 도메인 모델의 연결을 확인하는 역할을 한다. 즉, 컨트롤러 영역을 테스트 하는 것을 주로 '통합 테스트'라고 한다. 컨트롤러 영역을 모두 Mock으로 대체하면, 테스트 간에 공유하는 의존성이 없어지므로 단위 테스트가 되지만 Mock으로 대체할 수 없는 외부 프로세스도 많이 존재한다. 따라서 일반적으로 '컨트롤러'를 테스트 하는 것은 '통합 테스트'라고 볼 수 있다. 


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

    통합 테스트는 관련된 외부 의존성이 많기 때문에 유지보수 비용이 높은 편이다. 그렇지만 단위 테스트보다 더 많은 코드를 실행하기 때문에 회귀 방지가 단위 테스트보다 우수하다. 또한 클라이언트 관점으로 실행되기 때문에 제품 코드와 결합도가 낮아 리팩토링 내성이 좋은 편이


    다. 다음과 같은 테스트 전략을 사용하는 것을 추천한다. 

    • 단위 테스트 : 가능한 많은 비즈니스 시나리오의 예외 상황을 확인한다.
    • 통합 테스트 : 주요 흐름(시나리오의 성공 흐름)과 단위 테스트가 다루지 못하는 기타 예외 상황을 확인한다. 

    통합 테스트는 간단한 어플리케이션에도 충분히 유의미하다. 코드가 간단하더라도 다른 외부 시스템과의 상호 작용을 확인하는 것은 중요하기 때문이다. 


    8.1.3 통합 테스트와 빠른 실패

    통합 테스트에서 프로세스 외부 의존성과의 상호 작용을 모두 확인하려면 가장 긴 주요 흐름을 선택해야한다. 만약 모든 상호 작용을 거치는 흐름이 없으면, 외부 시스템과의 통신을 모두 확인하는데 필요한만큼 통합 테스트를 추가로 작성해야한다.

    통합 테스트에서 빠른 실패에 해당하는 예외 상황인 경우에는, 해당 예외 상황에 대한 테스트 코드를 작성하지 않아도 된다. 그런 코드는 통합 테스트 이전에 단위 테스트에서 검증하는 것이 타당하기 때문이다. 또한, 외부 프로세스와의 상호 작용을 거치기 전에 이미 해당 도메인 클래스에서 실패하기 때문에 테스트가 무의미하다. 

    // UserController.java
    public void changeEmail(int userId, String newEmail) {
    
    	...
    
        if (!user.canChangeEmail()) {
            return;
        }
    
        ...
        user.changeEmail(newEmail, company);
    }
    
    // User.java
    public void changeEmail(String newEmail, Company company) {
    
            assert canChangeEmail();
    
            ...
    }

    위에서 볼 수 있듯이 UserController, User에서 canChangeEmail()을 호출해서 전제조건(빠른 실패의 예시)를 테스트한다. 이런 경우라면 굳이 시간이 오래 걸리는 통합 테스트에서 실패 상황을 체크하지 않고, 단위 테스트에서 실패 상황을 체크하는 것으로 충분하다. 


    8.1.3 + 빠른실패 원칙

    빠른 실패 원칙은 예기치 않은 오류가 발생하자마자 현재 연산을 중단하는 것을 의미한다. 작은 오류가 시스템 전체에 영향을 미칠 수 있기 때문에 작은 오류가 발견되자마자 빠르게 실패해서 어플리케이션 전체에 큰 영향을 미치는 것을 방지한다. 보통은 예외를 던져서 현재 연산을 중지한다. 예외는 프로그램 흐름을 중단하고 실행 스택에서 가장 높은 레베롤 올라간 후 로그를 남기고 작업을 종료하거나 재시작 할 수 있다.

    전제 조건은 빠른 실패 원칙의 예다. 전제 조건이 실패하면 어플리케이션 상태에 대해 가정이 잘못된 것을 의미하는데 이것은 항상 버그에 해당된다. 

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

     


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

    통합 테스트는 시스템이 프로세스 외부 의존성과 어떻게 통합되는지를 검증한다. 두 가지 방법을 통해 검증할 수 있다.

    • 실제 프로세스 외부 의존성을 이용한다.
    • Mock으로 대체한다. 

    이 절에서는 각각을 언제 사용하는지에 대해 살펴본다. 


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

    모든 프로세스 외부 의존성은 두 가지로 나누어진다.

    • 관리 의존성(세부 구현 사항)  → 실제 인스턴스 사용
      • 어플리케이션을 통해서만 접근할 수 있으며, 해당 의존성과의 상호 작용은 어플리케이션 외부 환경에서 볼 수 없다. 대표적인 예로 DB가 존재한다.
      • 관리 의존성과 통신하는 것은 어플리케이션 뿐이므로 하위 호환성을 지킬 필요가 없다. 클라이언트는 시스템의 최종 상태에만 관심이 있지, 관리 의존성이 어떻게 구성되는지는 중요하지 않다.
      • 통합 테스트에서 관리 의존성은 실제 인스턴스를 사용하면, 외부 클라이언트 관점에서 최종 상태를 확인할 수 있다.
      • 관리 의존성과의 상호 작용을 검증하지 말고, 최종 상태만 점검하라.
    • 비관리 의존성(식별할 수 있는 동작)  → Mock 사용
      • 해당 의존성과의 상호 작용을 외부에서 볼 수 있다. 
      • 어플리케이션은 비관리 의존성과 통신 패턴을 유지해야한다. 비관리 의존성과의 하위 호환성을 지켜야 하기 때문이다.
      • 어플리케이션은 비관리 의존성의 통신 패턴을 컨트롤 할 수 없다.
      • 비관리 의존성과의 상호작용을 Mock을 이용해서 검증한다.

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

    프로세스 외부 의존성들 중 관리 의존성 / 비관리 의존성 특성을 동시에 가지는 녀석들이 존재한다. 한 가지 예시로 DB가 존재한다. 한 DB에는 어플리케이션만 접근 가능한 Table, 외부 클라이언트가 접근 가능한 Table이 동시에 존재할 수 있다. 이 경우 각각을 다음으로 분리한다.

    • 관리 의존성 : 어플리케이션만 볼 수 있는 테이블 → 실제 인스턴스로 처리 + DB 상호 작용 검증 X +  DB의 최종 상태만 점검
    • 비관리 의존성 : 외부 클라이언트도 볼 수 있는 테이블 → Mock으로 처리 + Verification.

     

    8.2.3 통합 테스트에서 실제 DB를 사용할 수 없으면 어떻게 할까?

    관리 의존성임에도 불구하고 실제로 DB를 사용할 수 없는 경우가 존재한다. 만약 이런 경우라면, 통합 테스트를 아예 작성하지 말고 도메인 모델의 단위 테스트에만 집중하도록 한다. 

    만약 관리 의존성임에도 불구하고 DB를 Mock으로 대체한다면, 통합 테스트의 리팩토링 내성이 떨어진다. Mock은 테스트 대상과 다른 의존성을 격리시키며 이에 따라 실행되는 코드가 줄어들어 회귀 방지 역시 줄어든다. 마지막으로 테스트 대상의 유일한 의존성이 DB이면, 통합 테스트는 회귀 방지 관점에서 기존 단위 테스트와 효용 차이가 없다. 이런 이유 때문에 실제로 DB 인스턴스를 사용할 수 없는 경우라면, 굳이 해당 DB에 대한 통합 테스트 코드를 작성할 필요가 없다. 

     


    8.3 통합 테스트 : 예제 

    7장에서 작성했던 코드를 바탕으로 통합 테스트 대상을 고려해보자. 코드의 전체 모습은 다음과 같이 동작한다.

    아래는 현재 작성된 코드다. 

    public void changeEmail(int userId, String newEmail) {
    
        // 외부 의존성
        MyUser findUser = database.findUserById(userId);
        MyUser user = MyUserFactory.createMyUser(findUser.getUserId(), findUser.getEmail(), findUser.getType());
    
        if (!user.canChangeEmail()) {
            return;
        }
    
        Company findCompany = Database.getCompany();
        Company company = CompanyFactory.createCompany(findCompany.getCompanyDomainName(), findCompany.getNumberOfEmployee());
    
        user.changeEmail(newEmail, company);
    
        database.save(company);
        database.save(user);
    
        user.domainEventList.forEach(domainEvent -> messageBus.sendEmailChangedMessage1(domainEvent.getUserId(), domainEvent.getNewEmail()));
    }

    이 코드와 그림을 바탕으로 가장 긴 주요 흐름을 선택하고, 필요한 예외 상황을 선정해서 통합 테스트를 작성한다. 


    8.3.1 어떤 시나리오를 테스트 할까? 

    통합 테스트의 주요 지침은 다음과 같다.

    • 가장 긴 주요 흐름(성공 케이스)를 테스트한다. 가장 긴 주요 흐름은 모든 프로세스 외부 의존성을 거치는 것이다. 
    • 단위 테스트로는 수행할 수 없는 모든 예외 상황을 다룬다. 

    여기서 가장 긴 주요 흐름은 다음과 같다.

    • 데이터베이스에서 사용자와 회사 모두 업데이트 된다. 즉 사용자는 유형을 변경(기업 → 일반)하고, 이메일도 변경하며 회사는 직원 수를 변경한다.
    • 메세지 버스로 메세지를 보낸다. 

    따라서 다음 주요 흐름을 통합 테스트의 대상으로 선택해야한다.


    8.3.2 데이터베이스와 메세지 버스 분류하기

    다음은 통합 테스트의 대상의 외부 의존성을 파악하고, 관리 의존성 / 비관리 의존성으로 분류하는 작업이다. 여기서 비관리 의존성은 Mock 객체로 변경한다. 현재 코드 상태를 바탕으로 검증을 분류하면 다음과 같다

    • DB : 관리 의존성. 실제 DB 인스턴스를 사용하고, 검증은 최종 DB의 상태를 확인한다.
    • 메세지 버스 : 비관리 의존성. Mock 객체를 사용하고, 컨트롤러와 Mock간의 상호 작용(호출)을 검증한다. 

    8.3.3 엔드 투 엔드 테스트는 어떤가?

    엔드 투 엔드 테스트는 통합 테스트에서 좀 더 나아간 버전이다. 외부 클라이언트 관점에서 API를 이용해서 테스트하고, 비관리 의존성까지 실제 인스턴스를 사용해서 테스트한다.

    통합 테스트는 동일한 프로세스 내에서 어플리케이션을 호스팅하고 비관리 의존성을 목으로 대체해서 테스트 한다.

    통합 테스트 범주에 관리 의존성을 포함시키고, 비관리 의존성만 목으로 대체하면 통합 테스트의 보호 수준이 엔드 투 엔드 테스트와 비슷해지므로 엔드 투 엔드 테스트를 생략할 수도 있다. 하지만 배포 후, 프로젝트의 상태 점검을 위해 한 개 또는 두 개 정도의 중요한 엔드 투 엔드 테스트를 작성해 볼 수는 있다. 

    엔드 투 엔드 테스트를 한다면, 이 때 메세지 버스(비관리 의존성)은 직접 확인하고 데이터베이스(관리 의존성) 상태는 어플리케이션을 통해 검증하도록 한다. 왜냐하면 클라이언트를 모방했기 때문에 클라이언트 관점에서는 어플리케이션의 DB를 확인할 수 있는 방법이 없기 때문이다.


    8.3.4 통합 테스트 첫 번째 시도

    테스트는 다음과 같이 작성할 수 있다. 

    @Test
    void changeMailFromCorporateToNonCorporate() {
    
        // given
        Database db = new Database();
        MyUser user = MyUserFactory.createMyUser(1,"user@mycorp.com", MyUser.UserType.EMPLOYEE);
        Company company = CompanyFactory.createCompany("mycorp.com", 1);
        MessageBus messageBus = mock(MessageBus.class);
    
        MyUserController myUserController = new MyUserController();
    
        // when
        myUserController.changeEmail(user.getUserId(), "new@gmail.com");
    
        // then
        MyUser findUser = Database.findUserById(user.getUserId());
        assertThat(findUser.getEmail()).isEqualTo("new@gmail.com");
        assertThat(findUser.getType()).isEqualTo(MyUser.UserType.CUSTOMER);
    
        Company findCompany = Database.getCompany();
        assertThat(findCompany.getNumberOfEmployee()).isEqualTo(0);
    
        verify(messageBus, times(1))
                .sendEmailChangedMessage1(user.getUserId(), "new@gmail.com");
    }
    • 외부 프로세스의 최종 상태는 엔티티로 메모리에 추상화 되어있다. 따라서 이곳에서는 엔티티의 메모리 추상화가 DB에 정상적으로 반영되는지를 확인하면 된다.
    • DB는 관리 의존성이기 때문에 실제 DB 상태를 최종적으로 점검하되, DB와의 상호작용은 직접 검증하지 않는다. 
    • 이 테스트는 DB에 읽기와 쓰기를 모두 하기 때문에 회귀 방지를 최대한 얻을 수 있다.
    • 이 때, 읽기는 반드시 컨트롤러가 외부 프로세스에서 읽어오는 코드를 그대로 사용해서 검증한다. (내부적으로 사용하는 코드)

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

    단위 테스트 영역에서 가장 많이 오해하는 주제 중 하나는 인터페이스 사용이다. 단위 테스트와 관련되어 인터페이스를 잘못 사용하는 경우가 많은데, 이 절에서는 그 부분에 대해서 공부한다.


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

    요즘은 다음과 같이 인터페이스를 사용하는 것이 굉장히 일반적으로 되었다. 그런데 왜 이런 형식으로 인터페이스를 사용하는 것일까?

    public class CustomerJpaRepository implements CustomerRepository{
    	...
    }
    
    public interface CustomerRepository {
    	...
    }

    이렇게 인터페이스를 사용하는 일반적인 이유는 다음과 같다.

    • 프로세스 외부 의존성을 추상화 해 클래스 간의 결합을 낮춘다. (Dependency Injection)
    • 기존 코드를 변경하지 않고 새로운 기능을 추가할 수 있도록 도와준다. (OCP)

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

    또한 YAGNI(You aren't gonna need it의 약자)의 원칙을 위반한다. YAGNI는 현재 필요하지 않은 기능에 시간을 들이지 말라는 것이다. YAGNI는 두 가지 관점에서 이런 일을 하지 않을 것을 알려준다

    • 기회비용 : 현재 비즈니스 담당자들에게 필요하지 않는 기능에 시간을 보낸다면, 지금 당장 필요한 기능을 제치고 시간을 허비하는 것이다. 또한, 기능을 완성했을 때 비즈니스 담당자가 새로 만들 것을 요청한다면 두 배의 시간이 소모된다. 
    • 프로젝트 코드가 커짐 : 요구사항이 있는 것도 아닌데 바로 코드를 작성할 경우, 코드베이스의 유지보수 비용이 증가한다. 

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

    각 인터페이스에 구현체가 하나 밖에 없을 때, 굳이 인터페이스를 사용하는 이유는 비관리 의존성 객체를 위한 Mock 객체를 생성하기 위해서다. 특정 클래스가 인터페이스를 구현한다면, Mock 객체 역시 그 인터페이스를 구현하기만 하면 된다. 이를 통해 손쉽게 Mock 객체를 만들어서 사용할 수 있다. 


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

    프로세스 내부 의존성도 마찬가지 기준이 적용된다. 한 인터페이스의 하나의 구현체만 존재한다면, 이 인터페이스는 YAGNI를 위반한 것이다.

    만약 프로세스 내부 의존성을 위해서 Mock 객체가 필요하고 이를 위해서 인터페이스가 도입되는 경우도 있을 수 있다. 이럴 때는 Mock 객체와 도메인 클래스 간의 상호 작용을 검증해서는 안된다. 


    8.5 통합 테스트 모범 사례

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

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

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

    주로 도메인 모델은 단위 테스트를 실행하며, 컨트롤러는 통합 테스트를 실행한다.  도메인 모델의 경계를 나눈다는 것은 다음과 같이 명시적으로 나누는 것을 의미한다. 

    // 예약 도메인 모델
    public class Reservation {
        private Flight flight;
        private Passenger passenger;
        private int seatNumber;
    
        public Reservation(Flight flight, Passenger passenger, int seatNumber) {
            this.flight = flight;
            this.passenger = passenger;
            this.seatNumber = seatNumber;
        }
    
        public Flight getFlight() {
            return flight;
        }
    
        public Passenger getPassenger() {
            return passenger;
        }
    
        public int getSeatNumber() {
            return seatNumber;
        }
    }
    
    // 승객 도메인 모델
    public class Passenger {
        private String name;
        private String email;
        private String phone;
    
        public Passenger(String name, String email, String phone) {

    이렇게 도메인 모델을 나누고, 도메인 모델 내부에 비즈니스 로직이 응집되어 있다면 테스트를 좀 더 쉽게 할 수 있게된다. 그리고 이렇게 비즈니스 로직이 응집되어 있다면 통합 테스트를 할 때 '구현 세부 사항'이 그다지 중요하지 않게 된다. 왜냐하면 비즈니스 로직을 도메인 모델에서 단위 테스트 할 때 이미 필요한 것들은 다 테스트 되기 때문이다.


    8.5.2 계층 수 줄이기

    대부분의 프로그래머는 간접 계층을 추가해서 코드를 추상화하고 일반화하려고 한다. 그렇지만 추상 계층이 너무 많으면 코드 베이스를 탐색하기 어렵고, 숨은 로직을 찾아내기가 어려워진다. 즉, 간접 계층은 조각난 추상화 코드를 하나의 그림으로 만드는 것을 어렵게하는 사이드 이펙ㅌ크가 존재한다.

    추상화가 지나치게 많은 경우는 간접 계층이 많은 것이다. 간접 계층이 많은 코드 베이스는 컨트롤러와 도메인 모델 사이에 명확한 경계가 없어진다. 즉 대부분이 Mock 처리가 되어서 '각 계층이 격리'될 것이고, 각 계층을 따로 검증하는 경향이 훨씬 강해진다. 이런 경향때문에 통합 테스트는 가치가 떨어지고, 단위 테스트로만 처리하게 된다. 그 결과 리팩토링 내성과 회귀방지에서 안 좋은 결과를 나타낸다.


    8.5.3 순환 의존성 제거하기

    순환 의존성은 서로가 서로를 순환하며 참조하는 것이다. 유지보수와 테스트의 용이성을 위해서 가급적이면 순환 의존성을 제거하는 것이 좋다. 물론 모든 코드에서 순환 의존성을 제거하는 것은 불가능하다. 그렇지만 할 수 있는 부분은 하는 것이 좋다. 

    순환 의존성은 코드를 읽고 이해하려고 할 때 알아야 할 것이 많아서 큰 부담이 된다. 순환 의존성이 있으면 해결책을 찾기 위한 출발 지점이 명확하지 않기 때문이다. 따라서 하나의 클래스를 이해하려면 주변 클래스 그래프 전체를 한 번에 읽고 이해해야한다. 

    또한 순환 의존성은 테스트를 방해한다. 클래스 그래프를 나눠서 동작 단위를 하나 분리하려면 인터페이스에 의존해 목으로 처리해야하는 경우가 많으며, 이는 도메인 모델을 테스트 할 때 해서는 안된다.

    public class CheckoutService {
    
        public void checkOut(int orderId) {
            ReportGenerationService reportGenerationService = new ReportGenerationService();
            reportGenerationService.generateReport(orderId, this);
            /* 기타 코드 */
        }
    
        public void callBack() {
            // ...
        }
    }
    
    public class ReportGenerationService {
        public void generateReport(int orderId, CheckoutService checkoutService) {
    
            // ...
            checkoutService.callBack();
        }
    }

    이런 코드가 존재한다고 해보자. 현재 이 코드는 순환 의존성을 가진다.

    • CheckoutService.checkout() → ReportGenerationService.generateReport() → CheckoutService.callback()

    순환 의존성을 처리하는 가장 좋은 방법은 순환 의존성을 제거하는 방법이다. ReportGenerationService의 generateReport()는 checkoutService 인스턴스를 호출하는 것이 아니라, 작업 결과를 반환하도록 바꾸면 된다. 

     


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

    테스트에서 두 개 이상의 준비 / 실행 / 검증을 두는 것은 코드 악취에 해당한다. 이는 테스트가 여러 동작 단위를 확인하고 있고, 이는 테스트의 유지 보수성을 저해한다는 신호다. 따라서 반드시 통합 테스트라도 단일 동작에 초점을 맞춰서 작성한다. 나쁜 예시로는 사용자 등록 + 사용자 삭제와 같은 두 가지 유스 케이스에 대해서 동시에 확인하는 테스트를 작성할 수 있다. 

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

    일견 자연스러워 보이는 흐름이다. 그렇지만 문제는 테스트가 초점을 잃고 순식간에 너무 커질 수 있다는 것이다. 따라서 각 실행을 고유의 테스트로 추출해 테스트를 나누는 것이 좋다. 각 테스트가 단일 동작에 초점을 맞추게 하면, 테스트를 더욱 이해하기 쉬워진다. 

     

    단 한 가지 예외 상황이 있다. 외부 의존성을 우리가 원하는 상태로 만들기 어려운 경우가 있다. 예를 들어 외부 은행 시스템에서 은행 계좌가 만들어지거나 하는 상황인데, 시간이 오래 걸릴 뿐더러 우리가 원하는 상태로 만들 수 없다. 이런 경우에 한해서만 여러 동작을 하나의 테스트로 묶어서 테스트 한다. 

     

     

     

     

     


    정리

    • 통합 테스트는 가장 긴 주요 흐름만 테스트를 한다.
      • 도메인 코드에서는 외부 프로세스의 상태를 이미 추상화한다. 그리고 단위 테스트에서는 추상화가 잘 되었는지는 추상화 객체의 최종 상태를 테스트 한다. (예를 들면 엔티티의 상태)
      • 통합 테스트에서는 추상화 된 객체가 실제로 외부 프로세스에 잘 반영되는지만 확인하면 된다. 즉, 모든 엔티티의 상태들에 대해서 각각 통합 테스트를 만들 필요는 없다는 이야기다. 
    • 통합 테스트는 단위 테스트가 아닌 테스트에 해당한다. 시스템이 프로세스 외부 의존성과 통합해 동작하는 방식을 검증한다.
      • 통합 테스트는 컨트롤러를 다루고, 단위 테스트는 알고리즘과 도메인 모델을 다룬다.
      • 통합 테스트는 회귀 방지와 리팩토링 내성이 우수하고, 단위 테스트는 유지 보수성과 피드백 속도가 우수하다.
    • 통합 테스트에서 회귀 방지와 리팩토링 내성 지표에 대한 점수는 단위 테스트보다 유지 보수성과 피드백 속도가 떨어진만큼은 높아야 한다.
    • 시스템이 전체적으로 올바른지 확인하는 통합 테스트는 속도가 느리고 비용이 많이 발생하므로 그 수가 적어야 한다.
      • 단위 테스트를 통해서 가능한 한 많은 비즈니스 시나리오의 예외 상황을 확인하라. 
      • 통합 테스트를 사용해서 하나의 주요 흐름(가장 길고 많은 외부 프로세스를 건드리는 성공 케이스)과 단위 테스트로 확인할 수 없는 예외 상황을 다룬다.
    • 빠른 실패 원칙은 버그가 빠르게 나타날 수 있도록 하며, 통합 테스트 대신에 사용할 수 있는 대안이다. 
    • 관리 의존성은 어플리케이션을 통해서만 접근할 수 있는 객체다. 구현 세부 사항에 해당되며, 대표적인 예시는 데이터베이스가 된다. 
    • 비관리 의존성은 다른 어플리케이션이 접근할 수 있는 프로세스 외부 의존성이다. 외부에서 확인 가능하면 SMTP 서버 같은 것이 있다.
    • 관리 의존성과의 통신은 구현 세부 사항 / 비관리 의존성과의 통신은 식별할 수 있는 동직이다.
    • 통합 테스트에서 관리 의존성은 실제 인스턴스를 사용하라. 비관리 의존성은 목으로 대체하라.
    • 때로는 관리 의존성 / 비관리 의존성 특성이 동시에 나타나는 인스턴스가 있다. 비관리 의존성의 식별 가능한 부분을 비관리 의존성으로 간주하고, 테스트에서 해당 부분을 목으로 대체하라.
    • 통합 테스트에서 관리 의존성은 실제 인스턴스를 사용하고, 비관리 의존성은 목으로 대체하라.
    • 관리 의존성은 최종 상태를 검증(DB의 상태 등)하고, 비관리 의존성은 상호 작용을 Mock으로 검증한다.
    • 구현이 하나뿐인 인터페이스는 추상화가 아니다. 이러한 인터페이스에 대한 향후 구현을 예상하면 YAGNI 원칙을 위배한다.
    • 구현이 하나뿐인 인터페이스를 사용하기에 타당한 이유는 목을 사용하기 위한 것뿐이다. 비관리 의존성에만 사용하고, 관리 의존성에는 구체 클래스를 사용하라.
    • 프로세스 내부 의존성에 대해 구현이 하나뿐인 인터페이스는 좋지 않다. 이러한 인터페이스는 Mock을 사용해 도메인 클래스 간의 상호 작용을 확인하는데 쓰이는데, 이것은 구현 세부 사항과 테스트가 결합하는 것을 의미한다. 
    • 도메인 모델을 코드베이스에 명시적이고 잘 알려진 위치에 둔다. 도메인 클래스 / 컨트롤러 사이의 경계가 명확하면 단위 테스트와 통합 테스트를 좀 더 쉽게 구분할 수 있게 된다. 
    • 간접 계층이 너무 많으면 코드를 추론하기 어려워진다. 간접 계층을 가능한 한 적게 사용하라.
    • 순환 의존성이 있으면 코드를 이해하려고 할 때 알아야 하는 부담이 커진다. 
    • 테스트에 여러 실행 구절이 있는 것은 원하는 상태로 만들기 어려운 외부 프로세스인 경우에만 사용한다. 그 외의 경우에는 각 실행 구절을 나누고, 각각을 검증하도록 한다. 여러 실행 구절이 있는 것은 End To End Test에 가깝다.

    댓글

    Designed by JB FACTORY