박종훈 기술블로그

테스트 더블과 모의 객체 (with mockito 예제)

이펙티브 소프트웨어 테스팅 - 마우리시오 아니시 6장 테스트 더블과 모의 객체


참고 작성 글 테스트 대역 (목 과 스텁)

이펙티브 소프트웨어 테스팅은 처음 펼쳐봤는데 단위테스트(블라디미르 코리코프) 책에서 단위테스트를 고전파와 런던파로 나눠서 설명했던 것이 떠올랐다. 설명을 가져와 보면 다음과 같다.

단위 테스트의 고전파와 런던파
고전적 접근법은 ‘디트로이트(Detroit)’라고도 하며, 때로는 단위 테스트에 대한 고전주의적(classicist)접근법이라고도 한다. 아마도 고전파의 입장에서 가장 고전적인 책은 켄트 백(Kent Beck)이 지은 “테스트 주도 개발”일 것이다.
런던 스타일은 때때로 ‘목 추종자(mockist)’로 표현된다. 목 추종자라는 용어가 널리 퍼져 있지만, 런던 스타일을 따르는 사람들은 보통 그렇게 부르는 것을 좋아하지 않으므로 이 책에서는 런던 스타일이라고 소개한다. 이 방식의 가장 유명한 지지자는 스티브 프리먼(Steve Freeman)과 냇 프라이스(Nat Pryce)다. 이 주제에 대한 좋은 자료로 이들이 저술한 “Growing Object-Oriented Software, Guided by Tests”를 추천한다.

이 책의 저자(마우리시오 아니시)는 Mock에 대해서 찬성하는 파로 보인다.
그리고 블라디미르 코리코프 는 고전파에 가까웠다.


이 장은 다음과 같은 내용을 다룬다.

  • 스텁, 페이크, 모의 객체를 사용해서 테스트를 단순화하는 방법
  • 모의 객체가 무엇인지, 모의 객체를 언제 사용해야 하는지, 언제 사용하지 말아야 하는지에 대한 이해
  • 모의할 수 없는 객체를 모의하는 방법

어떤 클래스는 작업을 수행할 때 다른 클래스에 의존한다. 여러 클래스를 함께 수행(또는 테스트)하는 것이 바람직할 수 있다. 이 장에서는 종속성을 너무 신경 쓰지 말고 격리된 방식으로 테스트하는 데 초점을 맞춘다.

왜 이런 테스트가 필요할까? 대답은 간단하다. 테스트 대상 클래스를 구체적인 의존성과 함께 수행하는 일은 너무 느리거나, 너무 힘들거나, 너무 많은 일을 해야 할 수 있기 때문이다.

예를들어 데이터베이스를 의존하는 테스트의 경우에는 데이터베이스를 설정하고 올바른 데이터가 모두 포함되어있는지 확인하는 등 준비를 해야한다. 이로 인해 의존성이 없는 테스트에 비해 훨씬 더 많은 작업을 필요로 한다. (만약 SQL 쿼리가 올바른지 테스트를 하고 싶다면 이것은 통합 테스트에서 해야한다.)

우리는 다른 클래스에 의존하는 클래스를 테스트할 때 의존성을 사용하지 않는 방법을 알아내야 한다. 이때 테스트 더블(test double)이 도움이 된다.

이 블로그에서 여러번 다뤘지만 참고로 double은 대역 이라는 뜻도 있다.

구성요소의 동작을 모방하는 객체를 생성하여 테스트 맥락에 따라 구성요소처럼 행동하도록 한다. 구성요소의 주변 환경을 제어함으로써 복잡한 종속성을 처리하지 않고 구성요소의 동작 방식을 나타낼 수 있다.

다른 객체의 동작을 시뮬레이션하는 객체를 사용하면 다음과 같은 장점이 있다.

6.1 더미, 페이크, 스텁, 모의 객체, 스파이

6.1.1 더미 객체

더미는 테스트 대상 클래스에 전달되었지만 절대 사용되지 않는 객체다. 비즈니스 애플리케이션에 전달해야 할 인수가 여러 개 있지만 테스트는 이들 중 몇 개만 사용할 때 더미 객체를 사용해도 좋다. (어떤 값을 가져도 테스트에 영향이 없을 경우)

6.1.2 페이크 객체

페이크 객체는 시뮬레이션하려는 클래스같이 실제로 동작하는 구현체를 가진다. 하지만 똑같이 동작한다기 보다는 훨씬 단순한 방법으로 동작한다. (책에서는 예시로 인메모리 데이터베이스를 들었는데, 단위 테스트 책에서는 인메모리 데이터베이스를 피하라고 되어있다. - 데이터베이스 테스트)

6.1.3 스텁

스텁은 테스트 과정에서 수행된 호출에 대해 하드 코딩된 응답을 제공한다. 페이크 객체와는 달리 스텁은 실제로 동작하는 구현체가 없다.

6.1.4 모의 객체

모의 객체는 메서드의 응답을 설정할 수 있다는 점에서 스텁 같은 역할을 한다. 하지만 모의 객체는 그 이상이다. 모의 객체는 모든 상호작용을 저장해서 나중에 단언(assert)문에서 활용할 수 있도록 해준다. (상호 작용 횟수를 저장하고 있음)

6.1.5 스파이

스파이는 의존성을 감시한다. 스파이는 실제 객체를 감싸서 그 행동을 관찰한다. 엄밀히 말하면 객체를 시뮬레이션하는게 아니라 감시하고 있는 근본 객체와의 모든 상호작용을 기록한다. 스파이는 특정 맥락에서 사용된다. 모의 객체를 사용하는 것보다 실제로 구현하는 게 훨씬 더 쉽고, 테스트 대상 메서드가 의존 대상ㅇ과 어떻게 상호작용하는지 단언하고 하는 경우에 사용된다. 스파이는 현업에서는 보기 힘들다.

6.2 모의 객체 프레임워크에 대한 소개

여기서는 모키토(mokito)를 사용한다.

모키토는 매우 간단하며 다음 세 가지 메서드만 알아도 충분하다.

mock(<class>): 주어진 클래스로 모의 객체 또는 스텁을 생성한다. 클래스는 <ClassName>.class로 구체화한다.
when(<mock>.<method>).thenReturn(<value>): (스텁화된) 메서드의 동작을 정의하는 연속된 메서드 호출이다. <value>를 반환한다.
verify(<mock>).<method>: 모의 객체와의 상호작용이 예상된 방식으로 일어난다고 단언한다.

예시를 통해 알아보자

6.2.1 의존성 스텁화

아래의 요구사항이 있다고 하자.

프로그램은 100보다 작은 값을 가지는 송장을 모두 반환한다. 송장은 데이터베이스에서 찾을 수 있다. IssuedInvoices 클래스는 모든 송장을 검색하는 메서드를 이미 포함하고 있다.

아래 코드는 이 요구사항을 구현한 예다.

public class InvoiceFilter {
    public List<Invoice> lowValueInvoices() {
        DatabaseConntection dbConnection = new DatabaseConnection();
        IssuedInvoices issuedInvoices = new IIssuedInvoices(dbConnection);

        try {
            List<Invoice> all = issuedInvoices.all();
            return all.stream()
                    .filter(invoice -> invoice.getValue() < 100)
                    .collect(toList())
        } finally {
            dbConnection.close();
        }
    }
}

issuedInvoices 클래스를 스텁으로 만들지 않고 InvoiceFilter 클래스를 테스트하려면 실제 데이터베이스를 설정해야 한다. 이 방법은 작업량이 많다.

일단 위 코드에 대한 테스트 코드를 작성해보면 아래와 같이 작성 작성해볼 수 있다.

public class InvoiceFilterTest {
  private IssuedInvoices invoices;
  private DatabaseConnection dbConnection;

  @BeforeEach
  public void open() {
    dbConnection = new DatabaseConnection();
    issuedInvoices = new IIssuedInvoices(dbConnection);

    dbConnection.resetDatabase();
  }

  @AfterEach
  public void close() {
    if (dbConnection != null)
      dbConnection.close();
  }

  @Test
  void filterInvoices() {
    Invoice invoice1 = new Invoice("invoice1", 20);
    Invoice invoice2 = new Invoice("invoice2", 99);
    Invoice invoice3 = new Invoice("invoice3", 100);
    invoices.save(invoice1);
    invoices.save(invoice2);
    invoices.save(invoice3);

    InvoiceFilter filter = new InvoiceFilter();

    assertThat(filter.lowvalueInvoices())
        .containsExactlyInAnyOrder(invoice1, invoice2);
  }
}

containsExactlyInAnyOrder 는 AssertJ의 기능이다.

이번에는 IssuedInvoices 클래스를 스텁으로 만들어서 데이터베이스와 관련된 귀찮을 일을 피해보자.

IssuedInvoices 생성자를 통해 전달받도록 수정하자. 이렇게 리팩터링 하면 DatabaseConnection을 주입할 필요가 없어진다.

public class InvoiceFilter {
    private final IssuedInvoices issuedInvoices;

    public InvoiceFilter(IssuedInvoices issuedInvoices) {
        this.issuedInvoices = issuedInvoices;
    }

    public List<Invoice> lowValueInvoices() {
        List<Invoice> all = issuedInvoices.all();
        return all.stream()
                .filter(invoice -> invoice.getValue() < 100)
                .collect(toList())
    }
}

위 코드에 대한 테스트 코드를 작성하면 아래와 같이 개선된다.

public class InvoiceFilterTest {

  @Test
  void filterInvoices() {
    IssuedInvoices issuedInvoice = mock(IssuedInvoices.class)

    Invoice invoice1 = new Invoice("invoice1", 20);
    Invoice invoice2 = new Invoice("invoice2", 99);
    Invoice invoice3 = new Invoice("invoice3", 100);
    List<Invoice> listOfInvoices = Arrays.asList(invoice1, invoice2, invoice3)

    when(issuedInvoices.all()).thenReturn(listOfInvoices)

    InvoiceFilter filter = new InvoiceFilter(issuedInvoices);

    assertThat(filter.lowvalueInvoices())
        .containsExactlyInAnyOrder(invoice1, invoice2);
  }
}

모키토의 모의 메서드를 이용해서 IssuedInvoices 클래스에 대한 스텁 인스턴스를 생성한 후 all()이 호출되면 미리 정의된 송장 목록을 반환하도록 하였다.

스텁은 테스트를 쉽게 작성하도록 해줄 뿐만 아니라, 테스트 클래스를 더 응집력 있게 해주고 다른 요소의 변경으로 인한 변경을 줄여준다. 이는 테스트가 실패할 활률을 줄여준다. 테스트가 실패했을 때 개발자의 디버깅 시간도 아껴준다.

6.2.2 모의 객체와 기댓값

다음과 같은 새로운 요구사항이 생겼다고 가정하자

작은 값을 가진 송장을 모두 SAP 시스템 (비즈니스 운영 관리 시스템)으로 전송해야 한다. SAP는 송장을 받기 위해 sendInvoice 웹 서비스를 제공한다.

요구사항에 대해 아래와 같이 구현하였다.

public interface SAP {
    void send(Invoice invoice);
}


public class SAPInvoiceSender {
    private final InvoiceFilter filter;
    private final SAP sap;

    public SAPInvoiceSender(InvoiceFilter filter, SAP sap) {
        this.filter = filter;
        this.sap = sap;
    }

    public void sendLowValuedInvoices() {
        List<Invoice> lowValuedInvoices = filter.lowValueInvoices();
        for(Invoice invoice : lowValuedInvoices) {
            sap.send(invoice);
        }
    }
}

이 클래스에 대한 테스트를 해보자. 여기서 테스트 해야 할 것은 작은 값의 송장이 모두 SAP에 전송되는지 확인하는 것이다. 그렇게 하려면 SAP에 있는 send 메서드 호출이 발생했는지 확인하면 된다. InvoiceFilter에 대한 테스트는 이미 위에서 진행했기 때문에 여기서는 하지 않아도 된다. 따라서 해당 부분은 Stub으로 처리한다.

다음과 같이 테스트 코드를 작성할 수 있을 것이다.

public class SAPInvoiceSenderTest {
    private InvoiceFilter filter = mock(InvoiceFilter.class);
    private Sap sap = mock(SAP.class);

    private SAPInvoiceSender sender = new SAPInvoiceSender(filter, sap);

    @Test
    void sendToSap() {
        Invoice invoice1 = new Invoice("invoice1", 20);
        Invoice invoice2 = new Invoice("invoice2", 99);

        List<Invoice> invoices = Arrays.asList(invoice1, invoice2);

        when(filter.lowValueInvoices()).thenReturn(invoices);

        sender.sendLowValuedInvoices();

        verify(sap).send(invoice1);
        verify(sap).send(invoice2);
    }
}

여기서 스텁(stubbing)과 모의(mocking)의 차이를 볼 수 있는데 스텁은 어떤 메서드 호출에 대해 하드 코딩한 값을 반환한다. 모의는 훨씬 더 구체적인 기댓값을 정의 할 수 있게 해준다.

아래와 같이도 사용할 수 있다.

verify(sap, times(2)).send(any(Invoice.class));
verify(sap, times(1)).send(invoice1);

작은 값의 송장이 없을 경우에 대한 테스트 코드를 짠다면 다음과 같이 짤 수 있을 것이다.

@Test
void noLowValueInvoices() {
    List<Invoice> invoices = emptyList();
    when(filter.lowValueInvoices()).thenReturn(invoices);

    sender.sendLowValuedInvoices();

    verify(sap, never()).send(any(Invoice.class));
}

6.2.3 인수 포획

SAP에 송장을 전송하는 기능에 대한 요구사항에 자그마한 변경이 생겼다고 하자.

SAP은 이제 Invoice 엔티티를 직접 받는 대신 다른 형식으로 전송된 데이터를 받는다. SAP는 고객명, 송장 가격, 생성 ID가 필요하다.

ID는 다음과 같은 형식을 따른다: <날짜><고객코드>

  • 날짜는 항상 ‘MMddyyyy’ 형식이어야 한다: <월><일><4자리 년도>
  • 고객 코드는 고객 이름의 첫 두 글자다. 고객 이름이 두 글자보다 짧으면 ‘X’로 한다.

SAP 인터페이스를 바꿔서 SapInvoice 엔티티를 받을 수 있도록 구현한다.

public class SapInvoice {
    private final String customer;
    private final int value;
    private final String id;

    public SapInvoice(String customer, int value, String id) {
        // 생성자
    }

    // 게터
}

public interface SAP {
    void send(SapInvoice invoice);
}

public class SAPInvoiceSender {
    private final InvoiceFilter filter;
    private final SAP sap;

    public SAPInvoiceSender(InvoiceFilter filter, SAP sap) {
        this.filter = filter;
        this.sap = sap;
    }

    public void sendLowValuedInvoices() {
        List<Invoice> lowValuedInvoices = filter.lowValueInvoices();
        for(Invoice invoice : lowValuedInvoices) {
            sap.send(InvoiceToSapInvoiceConverter.convert(invoice));
        }
    }
}

public class InvoiceToSapInvoiceConverter {
    public static SapInvoice convert(Invoice invoice) {
        String customer = invoice.getCustomer();
        int value = invoice.getValue();
        String sapId = generateId(invoice);

        return new SapInvoice(customer, value, sapId);
    }

    private static String generatedId(Invoice invoice) {
        String date = LocalDate.now().format(DateTimeFormatter.ofPattern("MMMddyyyy"));
        String customer = invoice.getCustomer();

        return date + (customer.length() >= 2 ? customer.substring(0, 2) : "X")
    }
}

위 코드에 대해서 여러 Invoice 인스턴스를 만들어서 convert 메소드를 호출하고 반환된 SapInvoice가 올바른지 단언해보자.

ParameterizedTest와 CsvSource 어노테이션을 이용하면 쉽게 여러 인스턴스를 만들 수 있고 모키토의 인수 포획기(argument captor)를 사용하면 모의 객체에 전달된 특정 객체를 얻을 수 있도록 해준다.

@ParameterizedTest
@CsvSource({
    "Username,Us",
    "U,X"
})
void sendToSapWithTheGeneratedId(String customer, String customerCode) {
    Invoice invoice = new Invoice(customer, 20);

    List<Invoice> invoices = Arrays.asList(invoice);
    when(filter.lowValueInvoices()).thenReturn(invoices);

    sender.sendLowValuedInvoices();

    ArgumentCaptor<SapINvoice> captor = ArgumentCaptor.forClass(SapInvoice.class);

    verify(sap).send(captor.capture());

    SapInvoice generatedSapInvoice = captor.getValue();

    String date = LocalDate.now().format(DateTimeFormatter.ofPattern("MMddyyyy"));
    assertThat(generatedSapInvoice).isEqualTo(new SapInvoice(customer, 20, date + customerCode));
}

인수 포획을 통해 ID가 기대한 것과 일치하는지 확인한다.

6.2.4 예외 시뮬레이션

아래와 같이 sendLowValuedInvoices 에서 전송중 에러가 나는 것에 대한 예외 처리를 추가하였다고 가정하자.

public void sendLowValuedInvoices() {
    List<Invoice> failedInvoices = new ArrayList<>();
    List<Invoice> lowValuedInvoices = filter.lowValueInvoices();
    for(Invoice invoice : lowValuedInvoices) {
       try {
           sap.send(InvoiceToSapInvoiceConverter.convert(invoice));
       } catch(SAPException e) {
           failedInvoices.add(invoice);
       }
   }

   return failedInvoices;
}

이것을 테스트 하기 위해 모의 객체가 특정 입력에 대해서 예외를 던지도록 처리할 수 있다. 모키토의 doThrow().when() 기능을 사용하면 된다. 코드는 아래와 같다.

@Test
void returnFailedInvoices() {
    Invoice invoiceA = new Invoice("AUser", 20);
    Invoice invoiceB = new Invoice("BUser", 25);
    Invoice invoiceC = new Invoice("CUser", 48);

    List<Invoice> invoices = Arrays.asList(invoiceA, invoiceB, invoiceC);
    when(filter.lowValueInvoices()).thenReturn(invoices);

    String date = LocalDate.now().format(DateTimeFormatter.ofPattern("MMddyyyy"));
    SapInvoice SapInvoiceB = new SapInvoice("BUser", 25, date + "BU");
    doThrow(new SAPException()).when(sap).send(SapInvoiceB);

    List<Invoice> failedInvoices = sender.sendLowValuedInvoices();
    assertThat(failedInvoices).containsExactly(invoice2);

    SapInvoice SapInvoiceA = new SapInvoice("AUser", 20, date + "AU");
    verify(sap).send(SapInvoiceA);

    SapInvoice SapInvoiceB = new SapInvoice("CUser", 48, date + "CU");
    verify(sap).send(SapInvoiceB);
}

invoiceB를 받으면 예외를 던지도록 테스트를 구성하였다.

모의 객체가 예외를 던지도록 구성하면 시스템이 예상하지 못한 시나리오에서 어떻게 동작할지에 대해 테스트할 수 있다.

6.3 현업에서의 모의 객체

언제 모의 객체를 사용해야 하고, 언제 사용하지 말아야 하는가?

6.3.1 모의 객체의 단점

모의 객체를 사용하면 자연스럽게 테스트를 덜 현실적으로 만든다. 실제 구현체에서 있을 수 있는 잘못을 놓칠 수 있다. 모의 객체가 대규모로 잘 동작하게 하려면 계약을 신경 써서 설계해야 한다.

계약 : 클래스가 사전 조건으로 무엇을 요구하는지, 클래스는 사후 조건으로 무엇을 제공하는지, 불변식은 클래스에 대해 항상 무엇을 유지하도록 하는지를 명확하게 설립한다. 계약에 의한 설계 는 버튼란드 마이어가 제안한 모델링 활동이다.

또 다른 단점으로, 모의 객체를 사용한 테스트는 모의 객체를 사용하지 않는 테스트보다 코드와 결합하게 된다. 테스트가 테스트 대상 클래스에 대한 정보를 너무 많이 알고 있다. 이렇게 많은 정보를 알고 있으면 테스트를 변경하기 힘들어진다. (내부 구현이 바뀌면 테스트도 변경되어야 할 가능성이 높아진다.)

6.3.2 모의해야 하는 대상과 하지 말아야 하는 대상

다음과 같은 종류일 때 모의 객체나 스텁을 사용한다.

반면에 다음과 같은 경우에서는 모의 객체나 스텁을 꺼리게 된다.

6.3.3 날짜 및 시간 래퍼

소프트웨어 시스템은 날짜와 시간 정보를 자주 다룬다. 어떻게 자바의 시간 API를 스텁으로 만들 수 있을까?

모키토는 정적 메서드를 모의할 수 있는 기능이 있다. 이 기능을 활용하는 방법도 있다.

다른 해결책은 모든 날짜 및 시간 로직을 어떤 클래스로 캡슐화하는 방법이다. Clock이라는 클래스를 만들어서 이 클래스가 해당 연산응ㄹ 수행하도록 한다. 시스템의 다른 요소는 날짜와 시간을 다룰 때 이 클래스만 사용한다.

public class Clock {
    public LocalDate now() {
        return LocalDate.now();
    }
    // ...
}

관련된 글은 단위테스트 안티 패턴 의 “6 시간 처리하기”에서도 찾아볼 수 있다.

마틴 파울러의 ClockWrapper 에 대한 설명

6.3.4 소유하지 않은 것을 모의하기

모의할 때 모범 사례는, 소유하지 않은 것은 모의하지 말라는 것이다.

아래와 같은 이유가 있어서이다.

굳이 해야한다면 해결책은 해당 클래스를 사용하는 클래스를 테스트를 할 때에는 이를 모의(mocking)하되, 실제 동작은 통합 테스트를 통해 테스트하라

xml writer example

만약 라이브러리가 변경된다면 통합테스트는 깨질 것이다.

6.3.5 모의에 관한 의견 여부

모의를 좋아하는 부류가 있고 그렇지 않은 부료도 있다. 책 “구글 엔지니어는 이렇게 일한다”에는 테스트 더블에 대해 한 장 전체를 할애하고 있다. 이 책의 내용을 정리하면 다음과 같다.

고립성 : Isolation, 독립적으로 실행할 수 있는가
비결정적 : Nondeterministic, 동일한 입력이 주어지더라도 다른 결과를 도출하는 성질

6.5 요약

categories: 스터디-테스트

tags: 테스트 더블 , 모의 객체 , , 목의 , 모킹 , 결합 , 모키토 , mockito , 단언