ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Classicist TDD vs Mockist TDD
    책/단위 테스트 2022. 7. 25. 22:53

    Classicist TDD vs Mockist TDD

    테스트를 구성하다 보면 실제 객체를 사용해서 테스트를 구성해야 할 지 아니면 Mock 을 사용해서 구성해야 할 지 고민될 때가 많습니다. 이번 포스팅에서는 실제 객체 사용을 권장하는 Classcist 와 Mock 사용을 권장하는 Mockist를 비교하면서 Test 에 대해 알아보도록 하겠습니다.

    시작전

    해당 주제를 설명하기 앞서 Test Double 이란 용어를 먼저 설명하겠습니다.

    Test Double이란 영화에서 스턴트 더블 개념에서 비롯되어서 테스트를 진행하는 것입니다. 실제 객체를 활용하기에 어렵거나 비용이 많이 들 떄 사용하는 가짜 객체를 의미합니다.

    테스트 더블에는 각각 Dummy, Fake, Mock, Spy, Stub 이 있습니다.

    Dummy

    • 가장 기본적인 테스트 더블입니다.
    • 인스턴스화 된 객체가 필요하지만 기능은 필요하지 않은 경우에 사용합니다.
    • Dummy 객체의 메서드가 호출되었을 때 정상 동작은 보장하지 않습니다.
    • 객체는 전달되지만 사용되지 않는 객체입니다.

    정리하면 인스턴스화된 객체가 필요해서 구현한 가짜 객체일 뿐이고, 생성된 Dummy 객체는 정상적인 동작을 보장하지 않습니다.

     

     

     

    간단한 예시를 통해 알아보도록 하겠습니다.

    public interface PrintWarning {
      void print();
    }
    public class PrintWarningDummy implements PrintWarning  {
      @Override
      public void print() {
        // 아무런 동작을 하지 않습니다.
      }
    }

    실제 객체는 PrintWarning 인터페이스의 구현체를 필요로 하지만, 특정 테스트에서는 해당 구현체의 동작이 전혀 필요하지 않을 수 있습니다.

    실제 객체가 로그용 경고만 출력한다면 테스트 환경에서는 전혀 필요가 없기 때문입니다.

    이런경우에는 print() 가 아무런 동작을 하지 않아도 테스트에는 영향을 미치지 않습니다.

    이처럼 동작하지 않아도 테스트에는 영향을 미치지 않는 객체를 Dummy 객체라고 합니다.

    Fake

    • 복잡한 로직이나 객체 내부에서 필요로 하는 다른 외부 객체들의 동작을 단순화 하여 구현한 객체입니다.
    • 동작의 구현을 갖고 있지만, 실제 프로덕션에는 적합하지 않는 객체를 의미합니다.

    정리하면 동작은 하지만 실제 사용하는 객체처럼 정교하게 동작하지는 않는 객체 를 의미합니다.

    간단한 예시를 통해 알아보겠습니다.

     

    간단한 예시를 통해 알아보겠습니다.

    @Entity
    public class User {
      @Id
      private Long id;
      private String name;
      
      protected User() {}
      
      public User(Long id, String name) {
        this.id = id;
        this.name = name;
      }
      
      public Long getId() {
        return this.id;
      }
      
      public String getName(){
        return this.name;
      }
    }
    public interface UserRepository {
      void save(User user);
      User findById(Long id);
    }
    public class FakeUserRepository implements UserRepository {
      private Collection<User> users = new ArrayList<>();
      
      @Override
      public void save(User user) {
        if (findById(user.getId() == null)) {
          user.add(user);
        }
      }
      
      @Override
      public User findById(Long id) {
        for (User user : users) {
          if (user.getId() == id) {
            return user;
          }
          return null;
        }
      }
    }

    테스트해야 하는 객체가 DB와 연관돼 있다고 가정해 보겠습니다. 그럴 경우 실제 DB를 연결해서 테스트해야 하지만, 실제 DB 대신 가짜 DB 역할을 하는 FakeUserRepository 를 만들어 테스트 객체에 주입하는 방법도 있습니다. 이렇게 하면 테스트 객체는 DB 에 의존 하지 않으면서도 동일하게 동작을 하는 가짜 DB 를 가지게 됩니다.

    이처럼 실제 객체와 동일한 역할을 하지만 실제 사용하는 객체처럼 정교하게는 돌아가지 않는 객체를 Fake 라고 합니다.

    Stub

    • Dummy 객체가 실제로 동작하는 것처럼 보이게 만들어 놓은 객체입니다.
    • 인터페이스 또는 기본 클래스가 최소한으로 구현된 상태입니다.
    • 테스트에서 호출된 요청에 대해 미리 준비해둔 결과를 제공합니다.

    정리하면 테스트를 위해 프로그래밍된 내용에 대해서만 준비된 결과를 제공하는 객체입니다.

    간단한 예시를 통해 살펴보겠습니다.

    위에서 사용했던 UserRepository 인터페이스를 사용하겠습니다.

    public class StubUserRepository implements UserRepository {
      // ...
      @Override
      public User findById(Long id) {
        return new User(id, "Test User");
      }
    }

    위의 코드 처럼 StubUserRepositoryfindById() 메서드를 사용하면 언제나 동일 id 값에 Test User라는 이름을 가진 User 인스턴스를 반환받습니다.

    테스트 환경에서 User 인스턴스의 name 을 Test User 만 받기를 원하는 경우 이처럼 동작하는 객체를 만들어 사용할 수 있습니다.

    물론 테스트가 수정될 경우(findById() 메서드가 반환해야 할 값이 변경되야 할 경우)Stub 객체도 함께 수정해야 하는 단점이 있다.

    Mockito 프레임워크도 Stub과 같은 역할을 해줍니다.

    @Test
    public mockito_stub_example() {
      given(stubUserRepository.findById(anyInt())).willReturn(new User(1L, "Hello"))
        // ...
    }

    이처럼 테스트를 위해 의도한 결과만 반환되도록 하기 위한 객체가 Stub 입니다.

    Spy

    • Stub 의 역할을 가지면서 호출된 내용에 대해 약간의 정보를 기록합니다.
    • 테스트 더블로 구현된 객체에 자기 자신이 호출 되었을 때 확인이 필요한 부분을 기록하도록 구현합니다.
    • 실제 객체처럼 동작시킬 수도 있고, 필요한 부분에 대해서는 Stub 으로 만들어서 동작을 지정할 수도 있습니다.

    정리하면 실제 객체로도 사용할 수 있고 Stub 객체로도 활용할 수 있으며 필요한 경우 특정 메서드가 제대로 호출되었는지 여부를 확인 할 수 있습니다.

    간단한 예시를 통해 살펴보겠습니다.

    public class MailingService {
        private int sendMailCount = 0;
        private Collection<Mail> mails = new ArrayList<>();
    ​
        public void sendMail(Mail mail) {
            System.out.println("Real sendMail Called");
            sendMailCount++;
            mails.add(mail);
        }
    ​
        public int getSendMailCount() {
            System.out.println("Real getSendMailCount Called");
            return sendMailCount;
        }
    }

    MailingServicesendMail 을 호출 할 때마다 보낸 메일을 저장하고 몇 번 보냈는지를 체크합니다.

    그리고 나중에 메일을 보낸 횟수를 물어볼 때 sendMailCount 변수에 저장된 값을 반환합니다. 실제로 메서드가 호출되었는지 확인하기 위해 println 문을 활용하여 출력결과를 확인해보겠습니다.

        @Spy
        MailingService mailingService;
        @Test
        void spy_example () throws Exception {
            // given
            Mail param = new Mail();
            doReturn(5).when(mailingService).getSendMailCount();
            
            // when
            mailingService.sendMail(param);
            System.out.println(mailingService.getSendMailCount());
            
            // then 메서드 호출 되었는지 결과 확인
            verify(mailingService).sendMail(param);
            verify(mailingService).getSendMailCount();
         }

    테스트 코드는 다음과 같습니다. getSendMailCount() 는 호출되면 5를 출력하기로 stubbing 하였고 sendMail 메서드는 아무것도 stubbing해주지 않았기 때문에 본래 메서드가 호출됩니다. 또한 메서드가 잘 호출 되었는지 verify 를 활용하여 확인할 수 있습니다.

    출력값
    Real sendMail Called
    5

     

    Mock

    • 호출 될 것으로 예상되는 메서드에 대해서 반환하는 값의 사양과 기댓값을 미리 프로그래밍 하는 객체를 의미합니다.
    • 테스트 대상 클래스

    이 역시도 Mockito 프레임워크를 사용하여 알아보도록 하겠습니다.

    @ExtendWith(MockitoExtension.class)
    public class UserServiceTest {
        @Mock
        private UserRepository userRepository;
        
        @Test
        void test() {
            // given
            when(userRepository.findById(anyLong())).thenReturn(new User(1, "Test User"));
          
          
            // when  
            User actual = userService.findById(1);
          
            // then
            assertThat(actual.getId()).isEqualTo(1);
            assertThat(actual.getName()).isEqualTo("Test User");
        }
    }

    SUT

    협력객체

    • SUT 가 의존하는 클래스

    이제 테스트 더블에 대해 알아보았으니 실제 객체를 이용한 테스트와 Mock 을 활용한 테스트 를 비교해보겠습니다.

    주문과 창고에 대한 도메인이 있다고 가정해보겠습니다.

    주문이 들어오면 창고에서 상품의 재고를 확인하는 과정을 거쳐야 합니다. 만약 재고가 충분하면 창고에 있던 상품의 재고가 줄어들고 주문이 유효해지는 상황입니다.

    이와 같은 요구사항에서 실제 객체를 이용해서 테스트를 한다면 다음과 같이 테스트를 할 수 있습니다.

    // given
    Order order = new Order(식빵, 딸기잼, 우유);
    WareHouse wareHouse = new WareHouse(식빵, 딸기잼, 우유, 사과);
    ​
    // when
    order.check(wareHouse);
    ​
    // then
    assertThat(order.isPossible).isTrue();
    assertThat(wareHouse.size()).isEqulTo(1);

    Mockito 라이브러리를 활용해면 다음과 같은 테스트를 할 수 있습니다.

    // given
    Order order = new Order(식빵, 딸기잼, 우유);
    WareHouse mockWareHouse = mock(WareHouse.class);
    ​
    given(mockWareHouse.hasInventory(식빵, 딸기잼, 우유)).willReturn(true);
    ​
    // when
    order.check(wareHouse);
    ​
    // then
    verify(mockWareHouse).hasInventory(식빵, 딸기잼, 우유);
    verify(mockWareHouse).remove(식빵, 딸기잼, 우유);

    given 절을 보면 창고의 행동에 대한 기댓값을 미리 설정하고 있습니다.

    테스트를 진행하는 메서드는 같지만 이전에 보았던 테스트와는 검증하는 방법이 조금 다릅니다. 해당 테스트는 창의 메서드가 호출되었는지를 확인하는 모습을 볼 수 있게 됩니다.

    단위란?

    방금 위에서 짰던 실제 객체를 사용하는 테스트에 대해서 단위 테스트를 했다고 말할 수 있을까요? 이 말에 대답하기 위해선 단위란 무엇인지 생각해 볼 필요가 있습니다. 마틴 파울러는 단위에 대한 여러 의미가 있지만 공통적인 3가지 특징을 제시합니다.

    1. 소프트웨어의 작은 부분에 집중하는 Low-Level 테스트를 다룬다는 것
    2. 일반적인 테스트 도구를 사용한다는 점
    3. 실행 속도가 빠르다.

    이 3가지 특성을 고려해서 보면 실제 객체를 사용하는 테스트도 단위테스트를 했다고 볼 수 있을 것 같습니다. 단위에 대해서는 여러의미가 있을 수 있습니다. 한 메서드를 단위로 볼 수도 있고 한 클래스를 단위라고 볼 수도 있습니다. 절차지향에선 한 기능을 단위로 보기도 합니다. 기능 단위라고 했을 때 객체지향적인 시각에서 보면 그 기능을 수행하기 위한 밀접히 관련 있는 클래스의 집합을 단위라고 볼 수 있습니다.

    하지만 더블을 이용한 테스트와 구분되어지는 것 또한 사실입니다.

    더블을 사용하여 실제 의존 클래스로부터 격리된 테스트는 Solitary Unit Test

    더블을 사용하지 않는 테스트는 Sociable Unit Test 라고 부르기도 합니다.

    지금까지 Sociable 한 단위테스트를 단위테스트라고 봐야할지 , 미니 통합테스트라고 봐야할 지 혼란스러워 했습니다. 단위테스트를 작성할 때 Solitary 하게 짜야 진짜 독립적인 테스트를 할 수 있을 거라고 생각하는 사람이 있을 수도 있고 Sociable 한 단위테스트로도 충분하다는 사람이 있을 수도 있습니다.

    이때 Solitary 를 지향하는 사람들을 Mockist, Sociable 한 테스트도 괜찮다고 생각하는 사람들은 Classicist 라고 부릅니다.

    상태검증과 행위검증

    // givenOrder order = new Order(식빵, 딸기잼, 우유);WareHouse wareHouse = new WareHouse(식빵, 딸기잼, 우유, 사과);// whenorder.check(wareHouse);// thenassertThat(order.isPossible).isTrue();assertThat(wareHouse.size()).isEqulTo(1);

    상태검증이란 Classicist 가 주로 사용하는 검증방법입니다. 이 테스트 코드를 보면 주문 가능한 상태인지 혹은 창고의 재고가 줄어들었는지를 확인하고 있습니다.

    검증을 위해서 테스트 하는 메서드나 상황이 수행된 다음 객체 내부의 상태를 확인하고 있습니다. 상태 검증을 사용하게 되면 테스트를 위해서 상태를 드러내야 하는 메서드가 생길 수도 있지만 행위가 끝난 후에 상태를 직접적으로 검증하기 때문에 테스트에 대한 안정감은 더 높아 질 수도 있습니다.

    // givenOrder order = new Order(식빵, 딸기잼, 우유);WareHouse mockWareHouse = mock(WareHouse.class);given(mockWareHouse.hasInventory(식빵, 딸기잼, 우유)).willReturn(true);// whenorder.check(wareHouse);// thenverify(mockWareHouse).hasInventory(식빵, 딸기잼, 우유);verify(mockWareHouse).remove(식빵, 딸기잼, 우유);

    반면 mock 을 사용하면 verify() 메서드를 통해서 테스트 하고자 하는 상황이 수행된 뒤 협력 객체의 특정 메서드가 호출되어 있는지를 검증하고 있는 모습을 볼 수 있습니다.

    만약 메서드가 호출되지 않았다면 테스트는 실패합니다. 이처럼 객체 내부의 상태를 확인하는 것이 아닌 특정 행동이 이루어졌나를 확인하는 검증을 행위 검증이라고 표현합니다. 행위 검증을 하게 되면 상태를 드러내는 메서드를 만들지 않아도 되지만 SUT 에 대한 구현 방식이 드러나게 됩니다. 또한 행위 검증을 끝내지만 상태를 확인한 것이 아니기 때문에 비교적 테스트에 대한 안정감은 낮아질 수도 있습니다.

    Mockist와 Classicist 의 사전작업

    테스트 사전 작업을 위한 Fixture 와 Mock 에 대해서 알아보겠습니다.

    Classicist 와 Mockist 는 테스트를 위한 협력 객체를 준비하는 방법에 차이가 있습니다.

    먼저 Classicst 가 배달 시작에 대한 테스트를 하는 과정을 살펴보겠습니다.

    //given
    List<Item> orderItems = List.of(new Item("식빵"), new Item("딸기잼"), new Item("우유"));
    ​
    Order order = new Order(orderItems);
    List<Item> inventory = List.of(
      new Item("식빵"), new Item("딸기잼"), new Item("우유"), new Item("사과")
    );
    ​
    WareHouse wareHouse = new WareHouse(inventory);
    order.check(wareHouse);
    ​
    // when
    Delivery delivery = new Delivery(order, 주소);
    delivery.start();
    ​
    // then
    assertThat(delivery.isStarted()).isTrue();

    코드를 보면 given 단계에서 각각의 협력 객체들을 실제로 생성해 주는 모습을 볼 수 있습니다. 실제 테스트가 진행되는 when, then 부분에 비해 사전 준비가 상당히 깁니다.

    지금은 그나마 간단한 예제이지만 만약 조금 더 복잡한 상황이거나 데이터베이스를 사용해야 한다면 더 무거운 작업이 될 것입니다. 하지만 이 Fixture를 사용하는 테스트가 많아진다면 이 객체들을 따로 관리해서 재사용할 수 있다는 장점이 있습니다.

    이 코드는 Mockist 들이 사용하는 Mock 객체를 이용한 테스트 코드입니다.

    // given
    Order mockOrder = mock(order.class);
    given(mockOrder.isPossible()).willReturn(true);
    ​
    // when 
    Delivery delivery = new Deliver(order, 주소);
    delivery.start();
    ​
    // then
    verify(mockOrder).isPossible();

    같은 상황에 대해서 테스트를 하지만 앞서 봤던 Classicist 테스트에 비해 훨씬 짧은 것을 볼 수 있습니다.

    Mock 을 이용하면 SUT와 직접적인 협력을 맺고 있는 객체에 대해서만 사전 준비를 하면 되기 때문입니다. SUT 테스트에서 사용되는 협력 객체의 메서드만 미리 설정해두면 됩니다.

    Mockist 는 Fixture를 만드는 작업이 Mock 객체를 만드는 것에 비해 비용이 크다고 생각하지만 Classcist 는 재사용을 할 수도 있기 때문에 매번 Mock 객체를 만드는 것이 비용이 더 크다고 말하기도 합니다.

    비결정적인 테스트로 Mockist 와 Classicist 비교

    TDD 를 진행하면서 결정적인 메서드를 가지고 있거나 외부 API 가 아닌 경우에는 쉽게 테스트를 작성할 수 있었을 겁니다. 하지만 결제시스템과 같이 외부의 신용카드 정보와 결제 시스템을 활용하는 상황에서는 테스트를 어떻게 진행하는게 좋을까요?

    앞서 봤던 예제에서 결제 시스템이 추가된다고 가정해보겠습니다.

    주문이 유효한 상태가 되려면 창고의 재고에 확인뿐 아니라 유효한 결제가 일어났는지도 검증을 해야합니다. 그런데 이 결제 시스템은 내부에서 진행되는 것이 아니라 카카오 페이 네이버 페이와 같이 외부 API 를 이용한 결제 시스템이라고 가정해보곘습니다.

    실제 객체를 활용해서 테스트를 이와 같이 진행한다면 확률적으로 테스트를 통과하게 되는 좋지 못한 결과를 낼 수도 있습니다.

    // given
    // ...
    CreditCard creditCard = new CreditCard(실제 카드 번호);
    ​
    // when
    Payment payment = new Payment(new KakaoPayApi());
    payment.pay(creditCard, order);
    ​
    // then
    assertThat(payment.isPayed()).isTrue();

    예제코드를 보면 실제 카카오 페이 API 를 사용하고 있습니다. 만약 이 테스트를 통과시키려면 실제 유효한 카드번호를 넣어서 결제를 실제로 진행을 해야합니다.

    그렇다면 매번 테스트를 진행할 때마다 통장에서 돈이 빠져 나가게 될 것입니다.

    Classicst 라고 해서 테스트에서 무조건 실제 객체만을 이용하는 것은 아닙니다. 테스트를 하기 어려운 상황 속에서는 테스트 더블의 종류와 상관없이 활용할 수 있습니다.

    결론적으로 객체간의 협력 관계에서 테스트하기 어려운 부분이 있다면 Classicist 라도 테스트 더블을 사용할 수 있습니다. 반대로 이런 외부 API 를 사용하더라도 충분히 안정적이라고 판단이 된다면 더블을 사용하지 않고 테스트를 진행할 수 있습니다.

    구현 테스트

    Mock 을 사용해 행위 검증을 하게 되면 테스트가 구현에 묶이는 단점이 있습니다. 즉, 테스트가 구현의 영향을 받아 구현이 바뀌면 깨지게 됩니다.

    처음에 보았둔 주문과 창고에 대한 상황을 다시 보겠습니다. 만약 여기서 새로운 요구사항이 추가되면 어떻게 될까요? 원래는 재고가 있는지만 확인을 했지만 상품의 유통기한도 확인을 해야한다고 가정해보겠습니다. 기존에 있던 세가지 흐름에서 유통기한을 확인해야 하는 메서드가 추가된 것을 볼 수 있습니다. 이렇게 구현방법이 바뀌면 과연 이전에 진행했던 테스트는 어떻게 될까요?

     
    // given
    Order order = new Order(식빵, 딸기잼, 우유);
    WareHouse wareHouse = new WareHouse(식빵, 딸기잼, 우유, 사과);
    ​
    // when
    order.check(wareHouse);
    ​
    // then
    assertThat(order.isPossible).isTrue();
    assertThat(wareHouse.size()).isEqulTo(1);

    상태 검증을 하는 클래식 테스트 코드에서는 구현이 변경된다고 해서 테스트 코드가 바뀌지도 않고 깨지지도 않습니다. 테스트 코드를 짜는 사람 입장에서는 check() 메서드 안에서 일어나는 일을 모르기 때문이죠. 따라서 이 테스트는 구현 테스트가 아니라고 볼 수 있습니다.

    하지만 Mock 을 사용한 행위 검증을 하려고 하면 테스트는 깨집니다.

    // given
    Order order = new Order(식빵, 딸기잼, 우유);
    WareHouse mockWareHouse = mock(WareHouse.class);
    ​
    given(mockWareHouse.hasInventory(식빵, 딸기잼, 우유)).willReturn(true);
    ​
    // when
    order.check(wareHouse);
    ​
    // then
    verify(mockWareHouse).hasInventory(식빵, 딸기잼, 우유);
    verify(mockWareHouse).remove(식빵, 딸기잼, 우유);

    상품의 유통기한을 확인하는 메서드에 대한 기댓값이 설정이 안 돼있기 때문입니다. 이처럼 Mock 을 사용하여 행위 검증을 하게 되면 테스트를 작성하는 시점부터 내부 구현을 생각해야 하고 내부 구현이 바뀌면 테스트 세팅과 검증 내용이 달라집니다.

    바뀐 구현을 적용하여 테스트를 통과시키기 위해서는 다음과 같이 변경을 주어야합니다.

    // given
    Order order = new Order(식빵, 딸기잼, 우유);
    WareHouse mockWareHouse = mock(WareHouse.class);
    ​
    given(mockWareHouse.hasInventory(식빵, 딸기잼, 우유)).willReturn(true);
    given(mockWareHouse.isExpired(식빵, 딸기잼, 우유)).willReturn(false); // 추가
    ​
    // when
    order.check(wareHouse);
    ​
    // then
    verify(mockWareHouse).hasInventory(식빵, 딸기잼, 우유);
    verify(mockWareHouse).isExpired(식빵, 딸기잼, 우유); // 추가
    verify(mockWareHouse).remove(식빵, 딸기잼, 우유);
    ​

    상품의 유통기간을 확인해주는 mock 을 추가해주고 이것이 호출되었는지 확인해야 합니다.

    이처럼 실제로 클래스의 내부 구현은 자주 바뀔 수 있습니다. 구현이 바뀔 때마다 테스트가 깨져서 바꿔야 한다면 리팩터링 하기가 두렵고 TDD 에 대해 회의적인 감정을 느낄 수도 있습니다.

    Test Isolation(테스트 격리)

    Mockist 는 Mock 을 사용한 테스트를 할 때 얻을 수 있는 장점으로 단위들간의 테스트 격리를 이룰 수 있다는 점을 강조합니다. 여기서 말하는 격리란 데이터베이스를 공유하는 문제를 해결하기 위해 롤백하거나 컨테이너를 새로 띄우는 등의 격리와는 다른의미입니다. 테스트 격리가 되지 않았을 때 겪을 수 있는 첫번째 문제 사항을 살펴보겠습니다. 버그가 발생했을 때 버그를 가지고 있는 클래스의 테스트 뿐만 아니라 그 클래스를 사용하는 모든 단위에서 테스트가 Red 가 되는 경우 입니다.

    주문, 배달, 결제 테스트가 창고 객체와 직, 간접적으로 연결이 되는 상황에서 창고에서 버그가 발생한다면 협력 객체로 실제 창고를 이용하는 모든 단위의 테스트 또한 버그의 영향을 받게 됩니다.

    하지만 Mock 을 이용한 테스트 들에서는 버그가 발생한 클래스의 테스트만 실패하게 됩니다.

    Mockist 들은 이 상황이 테스트들 간에 Red 가 전파된다고 생각하여 좋지 않다고 말하지만 Classicist 들은 Mockist 들 만큼이나 이를 큰 문제로 여기지 않습니다. 왜냐하면 전체 테스트를 자주 돌리고 기능 구현이나 리팩터링을 할 때마다 테스트를 돌린다면 마지막으로 손 댄 부분에서 버그가 발생했다는 점을 쉽게 찾을 수 있기 때문입니다.

    그렇다고 해서 항상 버그의 뿌리를 쉽게 찾을 수 있는 것도 아닙니다.

    두번째 문제 상황을 생각해보겠습니다.

    Sociable Unit 중 배달에 대한 테스트는 깨지는데 협력 객체 들의 테스트는 모두 통과하는 경우입니다. 분명 버그가 있다는 것을 알지만 협력 객체의 테스트가 모두 통과하기 때문에 디버그 하기 곤란한 상황입니다.

    그렇다면 이 단점은 Classic TDD 를 했기 때문에 발생하는 것일까요?

    다른 시각에서 생각하면 이 문제는 테스트가 꼼꼼히 짜여 있지 않은 경우에 발생하기 쉽습니다. 클래스 집합 안에 모든 클래스의 테스트가 세세하게 짜여 있다면 버그는 쉽게발견될 수 있습니다.

    다음과 같 상황으로 예시를 들어보겠습니다.

    주문이 유효한지 확인하는 메서드를 테스트 한다고 했을 때 유효한 주문인지 체크하는 과정에서 창고의 재고 확인 및 상품 유통기한 확인이 필요해집니다. 창고의 재고 확인 메서드와 유통기한 메서드를 만들어서 구현 후 유효한 주문 테스트를 완료 시켰다고 가정해 보겠습니다.

    이 과정에서 도출된 협력 객체인 창고에 대한 테스트는 과연 진행 되었을까요? 아마 당장 진행중이던 SUT 에만 집중하고 넘어갔을 수도 있습니다 .

    하지만 기본적으로 Mock 을 사용한 TDD 를 하게되면 협력 객체를 구현하기 전부터 SUT 에 대한 테스트를 작성합니다. SUT 에 대한 기능 구현이 모두 완료된 후, Classic TDD 에선 협력 객체에 대한 테스트 세분화를 놓칠 수도 있지만 Mockist 의 경우 다릅니다.

    협력객체가 구현되지 않았기 때문에 협력 객체에 대한 별도의 TDD 가 진행될 것입니다.

    따라서 Classicist TDD 에서 보다 이 협력 객체에 대해 꼼꼼한 테스트가 필요하다는 것이 비교적 쉽게 인식 될 수 있습니다.

    테스트 격리에 대해 정리하면 다음과 같습니다.

    1. Sociable Unit Test 로 인해 격리가 되어 있지 않을 때 발생한 버그가 어떤 클래스에서 발생했는지 찾기 어려울 수도 있다.
    2. 하지만 Socialbe Unit 에 포함되는 모든 클래스에 대한 테스트가 꼼꼼하게 작성되어 있다면 버그는 찾기 쉬울 것이다.
    3. 다만 클래식 TDD 에선 테스트 세분화를 놓치기 쉬운 환경이다
    4. Mockist TDD 에선 테스트 세분화를 하기 쉬운 환경이다.

    Classicist 와 Mockist 가 서로 개발을 이어나가는 방법

    마지막으로는 Classicist 와 Mockist 가 서로 개발을 이어나가는 방법에 대해 살펴보겠습니다.

    Classicist 는 Inside-Out 방법을 사용하고 Mockist 는 Outside-In 을 통해서 개발을 진행해 나갑니다. 각 진영이 어떻게 개발을 진행해 나가는지에 대해서 설명해보곘습니다.

    Classic TDD 에서는 일반적으로 도메인에서 출발하여 레드 그린 리팩터링의 과정을 거치면서 개발을 이어나가게 됩니다. Inside-Out 은 도메인, 클래스 수준에서 시작하여 요구 사항에 맞춰 테스트를 작성하게 됩니다. 해당 테스트를 성공시키기 위해서 내부 구현을 하게 되고 리팩터링을 진행하게 됩니다.

    이 단계에서 협력 객체가 도출되거나 다른 객체와의 협력이 형성되는 경향이 있습니다 .

    기능이 완성되면 마지막으로 사용자와 맞닿아 있는 영역을 구현하게 됩니다.

    이것이 개발이 내부에서 시작하여 바깥으로 향한다는 의미로 Inside-Out 이라고 불리게 됩니다.

    그렇다면 다음과 같이 기능 요구 사항을 통해서 Inside-Out 이 어떻게 진행되는지 살펴보겠습니다.

    1. 배달이 진행 되기 위해서는 배달하는 상품이 필요하므로 상품에 대한 도메인으로 먼저 TDD 를 진행해보겠습니다.
    1. TDD 가 모두 진행되면 상품 객체가 완성이 되었고 상품 도메인을 사용하는 도메인을 만들 차례입니다. 상품에 대한 재고를 관리하는 창고 도메인을 만들어야 한다고 생각할 때 테스트를 작성하기 위해서 이미 구현된 상품 객체를 실제로 이용하여 창고 도메인의 TDD 를 진행하게 됩니다.

    이러한 반복적인 과정을 거치게 된다면 기능 요구사항을 모두 만족하는 도메인 집합이 만들어지게 됩니다. 그리고 사용자와 맞닿아 있는 Controller 를 구현하며 실제로 서비스가 이루어질 수 있게 됩니다.

    Controller TDD 를 진행함에 있어서도 실제로 구현되어 있는 도메인 집합을 사용하게 됩니다.

    Inside-Out 에서는 내부 도메인에서부터 TDD를 진행하기에 어느 객체부터 시작해야하는지 갈피를 못잡는 경우도 있습니다.

    Inside-Out 의 특징은 다음과 같습니다.

    1. 리팩토링 단계에서 디자인이 도출됩니다.
    2. TDD 에서 빠른 피드백이 가능합니다.
    3. 오버 엔지니어링을 피하기 쉽습니다.
    4. 시스템 전반에 대한 완전한 이해없이 시작이 가능합니다.
    5. 초보자가 선택하기 좋습니다.
    6. 객체간의 협력이 어색하거나 public api 가 잘못 설계될 수 있습니다.

    다음은 Mockist 들이 자주 사용하는 Outside-In TDD기법에 대해 설명하겠습니다.

    Outside-In TDD 기법은 UI와 가장 가까운 계층에서부터 시작하여 도메인 계층까지 내려가는 방식입니다. 일반적으로 이를 사용자 시나리오 기반인 인수 테스트로 시작하게 됩니다.

    Outside-In TDD 에서는 일반적으로 협력 객체가 구현 되어 있지 않은 상태에서 시작하기에 구현 보다는 객체들 간의 상호작용에 더 신경을 쓰게 됩니다.

    Inside-Out 에서 살펴봤던 예시와 동일한 예시로 Outside-In 이 어떻게 진행되는지 살펴보겠습니다.

    Outside-In 은 인수테스트 부터 시작합니다. 사용자가 보낼 요청과 받는 응답에 대한 시나리오로 구성되기에 사용자와 가장 맞닿은 영역부터 테스트를 진행하게 됩니다.

    따라서 Controller 에 대한 테스트 부터 시작하게 됩니다. 그런데 아무런 기능 구현이 되지 않은 상태이기에 요구 사항을 만족시킬 수 있는 협력 객체들에 대해서 먼저 생각해보게 됩니다.

    인수 테스트의 시나리오가 배달 요청을 받으면 배달이 시작되었다라는 응답을 보내 주어야 하는 상황입니다. 이에 따라서 이 요구사항을 만족시킬 수 있는 협력 객체 중에 배달 요청을 처리할 수 있는 객체를 먼저 생각해냈습니다.

    배달이라는 객체는 아직 구현 되지 않았기 때문에 Mock 으로 처리합니다.

    그 과정에서 배달이라는 객체는 배달 요청을 받으면 배달을 시작해야 한다는 역할과 책임이 부여되게 됩니다. 이렇게 되면 Controller 에 대한 테스트는 성공하게 됩니다.

    그럼 우린 자연스럽게 아직 구현되지 않았던 배달 객체에 대한 TDD 를 진행하게 될 것입니다.

    배달 객체가 배달을 하는것뿐 아니라 주문 프로세스를 처리하는 것이 어색하다고 느껴 질 수 있습니다. 여기서 주문을 처리할 수 있는 협력 객체가 도출 됩니다. 위에서 마찬가지로 주문은 구현된 상태가 아니기 때문에 배달 테스트를 통과하기 위해서 Mock 으로 생성합니다. 이 과정을 반복하다 보면 바깥에서 내부 구현까지 모두 완료되는 인수 테스트가 통과되게 됩니다.

    Outside-In 에서는 협력 객체를 Mock 으로 사용하기에 Mock 처리한 객체들의 입력과 반환을 자연스럽게 상위 테스트에서 만들면서 협력 객체들의 인터페이스가 만들어집니다. 어떻게 쓰일지 비교적 명확하지 않은 채 구현 되는 Inside-Out 과는 다른 방식입니다. 또한 Mock 으로 구현한 객체들이 다음 구현에 대한 시작점이기에 길을 잃지 않고 나아갈 수 있다는 특징이 있습니다.

    Outside-In TDD 의 특징은 다음과 같습니다.

    1. Test Red 단계에서 디자인이 도출됩니다.
    2. 협력 객체의 public api 가 자연스럽게 도축됩니다.
    3. 객체들 간의 구현보다는 행위에 집중할 수 있습니다.
    4. 객체지향적인 코드 작성이 가능합니다.
    5. 설계에 대한 기초적인 지식이 필요해 숙련도가 필요합니다.
    6. 오버엔지니어링으로 이어질 수도 있습니다.

    표로 정리

     

    분류 Classicist Mockist
    테스트 단위 Sociable Test Solitary Test
    테스트 검증 상태 검증 행위 검증
    협력 객체 준비 Fixture 구성 및 재사용 메서드 마다 Mock 생성 및 기대값 설정
    간단한 협력 객체 실제 객체 사용 더블 적극 사용
    어려운 협력 객체 더블 사용 고려 더블 적극 사용
    테스트 유지보수 Fixture의 변경이 있을 때 많은 테스트가 깨진다. 구현 테스트로 이어지기 때문에 테스트를 수정하는데 많은 시간이 들 수도 있다.
    테스트 격리 X (디버그가 힘듦) O(통합 테스트 필요)
    디자인 스타일 Inside-Out Outside-in

    결론

    그래서 Classicist, Mockist 중 어떤 것을 사용해야 할까요?

    각 진영의 장단점이 많기 때문에 개발자마다 사용하고 있는 방식이 다를 수 있습니다. 그 말을 달리 말하면 한가지만을 고집할 필요는 없습니다.

    각 상황에 따라서 Classicist 의 TDD 가 더 좋을 수도 있고 Mockist의 TDD 가 더 좋은 코드를 만들어 낼 수도 있습니다.

    참고

    ' > 단위 테스트' 카테고리의 다른 글

    3. 단위 테스트 구조  (0) 2022.07.13
    단위 테스트 1장  (0) 2022.06.26

    댓글