Unit Testing 9장 : Mock 처리에 대한 모범 사례

    들어가기 전

    이 글은 단위 테스트 9장 Mock 처리에 대한 모범 사례를 공부하며 작성한 글입니다.


    9. 목처리에 대한 모범 사례

    Mock은 테스트 대상 시스템과 의존성 간의 상호 작용을 모방하고 검사하는데 도움이 되는 테스트 대역이다. Mock은 비관리 의존성(외부 어플리케이션에서 식별할 수 있음)에만 적용해야한다. 다른 의존성에도 Mock을 적용하면 리팩토링 내성이 낮은 테스트가 된다. 

    이 장에서는 Mock을 사용할 때 리팩토링 내성과 회귀 방지를 최대화하는 방법을 공부해보고자 한다. 아래에서는 다섯 가지 사례를 공부한다. 

    • 비관리 의존성에만 목 적용하기
    • 시스템 끝에 있는 의존성에 대해 상호 작용 검증하기
    • 통합 테스트에서만 목을 사용하고 단위 테스트에서는 하지 않기
    • 항상 목 호출 수 확인하기
    • 보유 타입만 목으로 처리하기 

    9.1 Mock의 가치를 극대화하기

    • 시스템 끝에서 상호 작용 검증하기
    • Mock 대신 Spy 사용하기

    등을 이용해서 테스트에서 Mock 사용 가치를 최대한으로 끌어낼 수 있다. 먼저 테스트 할 대상이 되는 녀석들을 살펴보면 다음과 같다. 

    MyUserController

    이 녀석은 외부 의존성 프로세스와 비즈니스 로직을 오케스트레이션 해주는 역할만 한다.

    public class MyUserController {
    
    
        private final chapter9.Database database;
        private final EventDispatcher eventDispacher;
    
        public MyUserController(Database database, MessageBus messageBus, DomainLogger domainLogger) {
            this.database = database;
            this.eventDispacher = new EventDispatcher(messageBus, domainLogger);
        }
    
        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);
            eventDispacher.dispatch(user.domainEventList);
        }
    }

     

    MessageBus, EventDispatcher

    • EventDispatcher는 외부 프로세스와 상호작용 하기 위해서 만들어진 Wrapper 클래스다. 
    • EventDispatcher는 MessageBus, DomainLogger를 각각 가지고 있다. 전달되는 이벤트의 종류에 따라 MessageBus를 사용하기도 하고, DomainLogger를 사용해서 이벤트를 처리한다. 
    public class EventDispatcher {
    
        private MessageBus messageBus;
        private DomainLogger domainLogger;
    
    
        public EventDispatcher(MessageBus messageBus, DomainLogger domainLogger) {
            this.messageBus = messageBus;
            this.domainLogger = domainLogger;
        }
    
        public void dispatch(List<MyUser.DomainEvent> domainEventList) {
            domainEventList.forEach(this::dispatch);
        }
    
        private void dispatch(MyUser.DomainEvent domainEvent) {
            messageBus.sendEmailChangedMessage1(
                    domainEvent.getUserId(),
                    domainEvent.getNewEmail());
        }
    
    }
    
    
    public class MessageBus {
    
        public static void sendEmailChangedMessage(int userId, String email) {
        	...
        }
    
        public void sendEmailChangedMessage1(int userId, String email) {
        	...
        }
    }

     

    작성된 테스트 코드

    작성된 테스트 코드는 아래와 같다. 아래 코드에서 사용한 Mock은 회귀 방지 관점 + 리팩토링 내성 관점에서 충분하지 않다. 비관리 의존성을 검증하는 형태이지만, 아래의 클래스들은 시스템의 끝이 아니라 시스템의 끝과 어플리케이션을 연결해주는 중간 어디쯤에 있기 때문이다. 이것은 엄밀히 말하면 '구현 세부 사항'을 검증하는 작업이라고 볼 수 있다. 

    • 비관리 의존성인 messageBus, domainLogger를 Mock으로 생성했다.
    • 비관리 의존성 Mock을 검증하는 코드를 작성했다.
    • 나머지는 상태를 검증하도록 코드가 작성되었다. 
    @Test
    void changingEmailFromCorporateToNonCorporate() {
        // given
        Database database = new Database();
        MyUser user = MyUserFactory.createMyUser(
                "user@mycopr.com", MyUser.UserType.EMPLOYEE, database);
        Company company = CompanyFactory.createCompany(
                "mycorp.com", 1, database);
    
        MessageBus messageBusMock = mock(MessageBus.class);
        DomainLogger loggerMock = mock(DomainLogger.class);
        MyUserController sut = new MyUserController(
                database, messageBusMock, loggerMock);
    
        // when
        sut.changeEmail(user.getUserId(), "new@gmail.com");
    
        // then
        MyUser findUser = database.getUserById(user.getUserId());
        assertThat(findUser.getEmail()).isEqualTo("mycorp.com");
        assertThat(findUser.getType()).isEqualTo(MyUser.UserType.CUSTOMER);
    
        Company findCompany = database.getCompany1();
        assertThat(findCompany.getNumberOfEmployee()).isEqualTo(0);
    
        verify(messageBusMock, times(1))
                .sendEmailChangedMessage1(user.getUserId(), "new@gmail.com");
    }

    9.1.1 시스템 끝에서 상호 작용 검증하기

    Mock을 사용할 때 가장 중요한 것은 '비관리 의존성에 Mock을 적용하고, 가급적 시스템의 가장 끝부분에 Mock을 적용해서 검증한다'는 것이다. 위의 테스트 코드는 그렇지 않기 때문에 테스트 코드 자체의 회귀 방지와 리팩토링 내성이 떨어진다. 아래에서 어떤 문제점이 있는지를 살펴본다. 우선 요약은 다음과 같다. 

    • Mock을 사용할 때 시스템 끝에서 비관리 의존성과의 상호 작용을 검증한다. 
    • 비관리 의존성과의 상호작용 전까지 거치는 많은 단계들이 있는데, 이것들은 결국 구현 세부 사항이다. 

     

    문제점

        verify(messageBusMock, times(1))
                .sendEmailChangedMessage1(user.getUserId(), "new@gmail.com");

    문제점은 이 코드다. messageBusMock은 분명 비관리 의존성이다. 따라서 Mock을 사용해서 검증하는 것이 옳다. 하지만 왜 취약한 테스트 코드가 된다는 것일까? 아래의 코드를 확인해보자.

    public class MessageBus {
    
        private Bus bus;
        
        public void sendEmailChangedMessage1(int userId, String email) {
            String body = String.format("Type: USER EMAIL CHANGED;" +
                    "ID: %d;" +
                    "NewEmail: %s", userId, email);
            bus.send(body);
        }
    }

    MessageBus는 Bus를 한번 감싸는 클래스다. Bus 클래스는 예를 들면 Python의 redis client와 동일한 역할을 한다고 보면 된다. redis-client와 직접적으로 통신하는 라이브러리인데, 어플리케이션에서 사용하기 편하게 MessageBus로 한번 감싼 것이다. MessageBus와 Bus를 굳이 분류하면 다음과 같아진다

    • MessageBus : 구현 세부 사항
    • Bus : 비관리 의존성과 커뮤니케이션 하는 시스템의 끝

     

    해결 방법

    현재 상황을 헥사고날 아키텍쳐로 살펴보면 다음과 같다. 

    위 테스트 코드에서는 MessageBus를 Mock으로 대체하여 verify 했다. 그렇지만 실제로 메세지 버스와 최종 통신을 하는 클래스는 Bus다. 이 내용을 바탕으로 정리하면 다음과 같다. 

    • MessageBus 클래스는 Bus 관점에서 봤을 때는 구현 세부 사항이다.
    • Bus를 Verify하면 더욱 많은 클래스와 코드(MessageBus)를 지나기 때문에 회귀 방지 관점에서 좋다.
    • Bus는 비관리 의존성과 커뮤니케이션 하는 마지막 Layer이기 때문에 이를 검증하는 것이 리팩토링 내성에 더욱 뛰어나다. 
    • 외부에서 식별할 수 있는 동작은 MessageBus가 아니라 Bus다.
      • 외부에서는 MessageBus를 호출하지 않는다. MessageBus는 sendEmail(userId, newEmail)로 외부 어플리케이션에 메세지를 보내려 한다.
      • Bus는 send("Type: USER EMAIL CHANGED"...)를 호출해서 외부 어플리케이션에서 메세지를 보낸다. 외부에서 식별가능한 동작은 "Type: USER EMAIL..."을 보내는 것이다.

    따라서, 이 관점으로 바라본다면 MessageBus 대신 Bus 클래스를 Mocking하고 Verify하는 것이 더욱 좋다. 식별할 수 있는 동작 관점에서 봤을 때도 메세지 버스는 'TYPE: USER EMAIL... " 등의 메세지를 받아본다는 것이다. 이 관점에서도 Bus 클래스를 Mocking하고 Verify하는 것이 좋다. 

    @Test
    void changingEmailFromCorporateToNonCorporateRefactoring() {
        // given
        Database database = new Database();
        MyUser user = MyUserFactory.createMyUser(
                "user@mycopr.com", MyUser.UserType.EMPLOYEE, database);
        Company company = CompanyFactory.createCompany(
                "mycorp.com", 1, database);
    
        Bus busMock = mock(Bus.class);
        MessageBus messageBus = new MessageBus(busMock);
        DomainLogger loggerMock = mock(DomainLogger.class);
        MyUserController sut = new MyUserController(
                database, messageBus, loggerMock);
    
        // when
        sut.changeEmail(user.getUserId(), "new@gmail.com");
    
        // then
        MyUser findUser = database.getUserById(user.getUserId());
        assertThat(findUser.getEmail()).isEqualTo("mycorp.com");
        assertThat(findUser.getType()).isEqualTo(MyUser.UserType.CUSTOMER);
    
        Company findCompany = database.getCompany1();
        assertThat(findCompany.getNumberOfEmployee()).isEqualTo(0);
    
        
        String expected = String.format("Type: USER EMAIL CHANGED;" +
                "ID: %d;" +
                "NewEmail: %s", user.getUserId(), "new@gmail.com");
    
        verify(busMock, times(1))
                .send(expected);
    }

    위의 내용을 고려한다면 테스트 코드는 다음과 같이 수정할 수 있다. 이 방법은 다음과 같은 관점에서 더욱 좋아진다

    • 클라이언트가 식별할 수 있는 동작을 직접 verify한다. 따라서 리팩토링 내성이 좋아진다. 
    • 시스템의 끝을 Mocking 하기 때문에 더 많은 코드가 실행되어 회귀 방지가 좋아진다. 

    9.1.2 목을 스파이로 대체하기

    목과 스파이는 비슷한 역할을 한다. 목은 프레임워크가 만들어주는 녀석이고, 스파이는 개발자가 수동으로 작성하는 녀석이다. 스파이가 가지는 장점은 다음과 같다.

    • 제품 코드보다 테스트 코드를 더욱 신뢰한다. → 리팩토링 내성 증가
    • 검증 코드가 더욱 간결해지고, 응집력 있어진다.

    아래에서 자세한 내용을 살펴보고자 한다.

     

    스파이 코드 작성

    public class SpyBus implements BusInterface {
    
        private List<String> sentMessages = List.of();
    
        @Override
        public void send(String body) {
    		...
        }
    
    
        public SpyBus shouldSendNumberOfMessages(int number) {
            assertThat(sentMessages.size()).isEqualTo(number);
            return this;
        }
    
        public SpyBus withEmailChangedMessage(int userId, String newEmail) {
            String expected = String.format("Type: USER EMAIL CHANGED;" +
                    "ID: %d;" +
                    "NewEmail: %s", userId, newEmail);
            assertThat(sentMessages).contains(expected);
            return this;
        }
    }

    다음과 같이 스파이 코드를 작성한다.

    • send() 메세지는 메세지 버스로 메세지를 보내는 코드다. 실제로 메세지를 보내는 것이 아니라, sendMessage 같은 내부 필드에 메세지를 가지고 있도록 작성한다.
    • 스파이 클래스 내부에는 테스트에서 사용할 검증 코드를 포함한다. 이 때, 메서드 체이닝이 가능하도록 본인을 반환한다. 

     

    테스트 코드

    위와 같이 스파이 클래스를 작성했을 때, 테스트 코드는 어떻게 바뀔까?

    @Test
    void changingEmailFromCorporateToNonCorporateWithSpy() {
        // given
        Database database = new Database();
        MyUser user = MyUserFactory.createMyUser(
                "user@mycopr.com", MyUser.UserType.EMPLOYEE, database);
        Company company = CompanyFactory.createCompany(
                "mycorp.com", 1, database);
    
    	// 스파이 객체 생성
        SpyBus spyBus = new SpyBus();
        MessageBus messageBus = new MessageBus(spyBus);
        DomainLogger loggerMock = mock(DomainLogger.class);
        MyUserController sut = new MyUserController(
                database, messageBus, loggerMock);
    
        // when
        sut.changeEmail(user.getUserId(), "new@gmail.com");
    
        // then
        MyUser findUser = database.getUserById(user.getUserId());
        assertThat(findUser.getEmail()).isEqualTo("mycorp.com");
        assertThat(findUser.getType()).isEqualTo(MyUser.UserType.CUSTOMER);
    
        Company findCompany = database.getCompany1();
        assertThat(findCompany.getNumberOfEmployee()).isEqualTo(0);
    
        // 검증 구절이 간편해짐
        spyBus.shouldSendNumberOfMessages(1)
                .withEmailChangedMessage(user.getUserId(), "new@gmail.com");
    }

    두 가지 장점이 있다.

    • 테스트 코드에서 비관리 의존성과의 상호작용을 검증하는 부분에서 flunet 인터페이스를 사용하면서 가독성 있는 검증이 가능해진다.
    • 제품 코드에서 벗어나 테스트 코드만으로 검증한다. 이를 통해 리팩토링 내성이 강해진다. 

    두번째 줄은 무슨 말을 의미하는 것일까? 아래 코드로 살펴보자.

    // 제품 코드로 검증
    verify(messageBusMock, times(1))
                    .sendEmailChangedMessage1(user.getUserId(), "new@gmail.com");
                    
    // 테스트 코드로 검증
    spyBus.shouldSendNumberOfMessages(1)
                    .withEmailChangedMessage(user.getUserId(), "new@gmail.com");

    모양은 messageBusMock(Wrapper 클래스의 Mock)을 사용했을 때, 스파이를 사용했을 때와 크게 다르지 않다. 하지만 함축하고 있는 의미는 다르다

    • messageBusMock : 제품에서 사용되는 코드를 직접 사용해서 검증한다. 이 말은 제품에서 사용되는 코드가 바뀔 경우, 테스트 코드의 수정이 필요함을 의미한다.
    • spyBus : 인터페이스에 직접 테스트 검증 코드를 플루언트 인터페이스로 작성했다. 제품에서 사용되는 코드가 아닌 테스트 코드이기 때문에 제품 코드와의 결합이 줄어든다. 

    따라서 스파이를 이용하면 리팩토링 관점에서도 내성이 생긴다고 볼 수 있다. 반면에 단점은 더 많은 코드의 구현이 필요하다는 것이다. 


    9.2 목 처리에 대한 모범 사례

    앞서서 다음 두 가지를 살펴봤다.

    • 비관리 의존성에만 목 적용하기
    • 시스템 끝에 있는 의존성에 대해 상호 작용 검증하기

    이 절에서는 나머지 모범 사례에 대해서 공부한다.

    • 통합 테스트에서만 목을 사용하고 단위 테스트에서는 하지 않기. 
    • 항상 목 호출 수 확인하기
    • 보유 타입만 목으로 처리하기 

    9.2.1 목은 통합 테스트만을 위한 것

    목이 통합 테스트만을 위한 것은 비즈니스 로직과 오케스트레이션의 분리에서 기인한다. 도메인 모델(비즈니스 로직)에 대한 테스트는 단위 테스트가 된다. 컨트롤러를 다루는 테스트는 통합 테스트다. 컨트롤러는 비관리 의존성을 처리하는 계층이며, 목은 비관리 의존성에만 적용해야한다. 따라서 통합 테스트에서만 목을 사용하는 것이 적절하다.

    관리 의존성은 모두 구현 세부 사항이다. 따라서 목을 사용할 경우 리팩토링 내성이 없어진다.


    9.2.2 테스트 당 목이 하나일 필요는 없음. 

    테스트는 '동작 단위'를 검증하는 것이다. 이것은 Mock을 사용할 때도 동일하다. 동작 단위를 검증하는데 필요한 Mock의 수는 아무 관계가 없다. Mock의 수는 테스트에 참여하는 비관리 의존성의 숫자에만 의존한다.


    9.2.3 호출 횟수 검증하기

    비관리 의존성을 Mocking 했을 때, 반드시 아래 사항을 체크해야한다.

    • 예상하는 호출이 있는가? 
    • 예상치 못한 호출은 없는가? 
    • 호출되는 횟수는 정확한가?

    어플리케이션은 비관리 의존성과 커뮤니케이션한다. 이 때, 하위 호환성을 지켜야 한다. 하위 호환성은 양방향이어야 한다. 이 말은 다음과 같다.

    • 어플리케이션은 외부 시스템이 예상하는 메세지를 생략하면 안된다.
    • 어플리케이션은 외부 시스템이 예상치 못한 메세지를 생성하면 안된다. 

    따라서 위에서 이야기 한 것처럼 비관리 의존성의 특정 메서드가 정확히 몇 회 호출되었는지, 혹은 호출되지 않았는지를 검증하는 것은 매우 중요하다. 예를 들면 다음과 같이 작성해 볼 수 있다. 

    // 정확히 몇회, 뭐가 호출되었는지 검증
    verify(messageBusMock, times(1))
            .sendEmailChangedMessage1(user.getUserId(), "new@gmail.com");
    
    // 다른 메서드는 호출이 없었는지 검증.
    verifyNoMoreInteractions(messageBusMock);

    9.2.4 보유 타입만 목으로 처리하기

    이 부분은 비관리 의존성과 관련되어 시스템의 가장 끝부분을 Mocking하고 verify하라는 것과는 살짝 충돌되는 부분이 있다. 왜냐하면 앞에서는 MessageBus가 아닌 Bus(파이썬으로 치면 redis-cluster 같은 라이브러리)를 Mocking 해서 검증하라고 했기 때문이다. 그렇지만 여기서는 MessageBus를 Mocking + verify하는 것이 좋다고 한다. Bus는 서드 파티 라이브러리이고, MessageBus는 Bus를 한번 감싼 Adapter의 역할을 한다. 서드 파티 라이브러리가 아니라 Adapter를 Mocking 해야하는 이유는 다음과 같다.

    • 서드파티 코드의 작동 방식을 깊이 이해하기 어렵다.
    • 서드파티 코드의 기술 세부 사항까지는 꼭 필요하지 않다. 따라서 어댑터는 이를 추상화하고, 어플리케이션 관점에서 라이브러리와의 관계를 정의한다. 
    • 라이브러리를 업그레이드 할 때 서드파티 코드가 어떻게 변경될 지 알 수 없다. 따라서 리팩토링 내성이 떨어진다. 업그레이드를 하면 전체 코드베이스에 걸쳐 파급 효과가 일어날 수 있는데, 이것을 어댑터로 제한할 수 있다. 

    Adapter는 다음 역할을 한다.

    • 기본 라이브러리의 복잡성을 추상화한다.
    • 라이브러리에서 필요한 기능만 노출한다.
    • 프로젝트 도메인 언어를 사용해 수행할 수 있다. 

    '보유 타입만 목으로 처리하라' 라는 것은 내부 의존성에 적용되지 않는다. 내부 의존성은 관리 의존성이고, 구현 세부사항이므로 Mock을 사용할 필요가 없다. 또한 관리 의존성이기 때문에 그냥 사용해버리면 된다. 예를 들어 날짜와 시간을 제공하는 서드파티 API를 사용하는 경우, 굳이 서드파티 API를 어댑터를 이용해 추상화 할 필요가 없다. 그냥 있는 것을 사용하면 되기도 하고 관리 의존성이기 때문에 검증할 필요가 없다. 


    요약

    • 시스템 끝에서 비관리 의존성과의 상호 작용을 검증하라. 마지막 부분을 Mock으로 처리해야하는데, 이를 통해 회귀 방지 + 리팩토링 내성이 향상될 수 있다. 
    • 스파이는 개발자가 수동으로 작성한 Mock이다. 시스템 끝에 있는 클래스는 스파이가 Mock보다 낫다. 검증 단계에서 코드를 재사용해 테스트 크기가 줄고 가독성이 개선된다.
    • 검증문을 작성할 때 제품 코드에 의존하지 않는다. 테스트에서 별도의 리터럴과 상수 집합을 사용한다. 테스트는 제품 코드와 독립적으로 검사점을 제공해야 한다. 
    • 목은 비관리 의존성만을 위한 것이고 이러한 의존성을 처리하는 코드는 컨트롤러 뿐이므로 통합 테스트에서 컨트롤러를 테스트 할 때만 목을 적용해야한다. 단위 테스트에서는 목을 사용하지 말라.
    • 테스트에서 사용된 목의 수는 관계가 없다. 목의 수는 비관리 의존성의 수에 따라 달라진다.
    • 목에 예상되는 호출이 있는지와 예상치 못한 호출이 없는지를 확인하라.
    • 보유 타입만 목으로 처리하라. 비관리 의존성에 접근하는 서드파티 라이브러리 위에 어댑터를 작성하라. 기본 타입 대신 해당 어댑터를 목으로 처리하자. 

    댓글

    Designed by JB FACTORY