Post

(Test) 대역(Test Doubles) - Stubs, Mocks

대역의 필요성

테스트를 작성하다 보면 다양한 사유로 대역이 필요해지게 됩니다.

  1. 테스트 수행이 외부 의존성에 영향을 주면 안되는 경우
    • e.g., withdraw 요청이 포함된 기능을 테스트 할 때, 실제로 withdraw 요청이 API 서버에 전달되어서는 안됨.
  2. 테스트 내에서 같은 요청을 보냈을 때, 외부 의존성의 응답이 항상 동일할 것이라고 신뢰 할 수 없는 경우 (대부분)
    • e.g., 테스트용 카드를 받았으나, 만료되는 경우
    • e.g., 외부 의존성에 장애가 발생하는 경우
  3. 외부 의존성으로 부터 원하는 응답을 일으키기 어려운 경우

대역의 간단한 예제

테스트 하고자 하는 것이 아래와 같을 때

  • AutoDebitRegister.register(user, cardNum) - cardNum 유효성에 따라 VALID, INVALID를 반환하는지.
  • AutoDebitRegister.register(user, cardNum) - user가 신규인지 기존회원인지 여부에 따라 새로 등록하거나, update하는지.

대역을 사용하지 않는 예 - 외부 서버와 DB 상태가 테스트에 영향을 미침.

대역을 사용하는 예-외부 서버 의존성 제거

대역을 사용하는 예-DB 의존성 제거

대역(Test Double)의 종류와 용어 정리

Test Double?

Meszaros uses the term Test Double as the generic term for any kind of pretend object used in place of a real object for testing purposes. The name comes from the notion of a Stunt Double in movies. (One of his aims was to avoid using any name that was already widely used.)

  • 즉, 테스트 목적으로 real object를 흉내내는 모든 대역 객체를 통틀어 Test Double이라고 부릅니다.
  • 한국말로 번역한다면 ‘대역’ 정도가 적당해보입니다.
  • 보통 여러가지 대역을 모두 mock 객체라고 부르는 경우가 많은데, mock은 Test Double에 속하는 하나의 카테고리 이므로 대역을 통틀어서 부르고 싶다면 용어를 분리하여 ‘Double’ 또는 ‘대역’을 사용하는게 좋아보입니다.

대역의 종류

  • Dummy objects are passed around but never actually used. Usually they are just used to fill parameter lists.
    • 넘기거나 전달되지만 실제로 사용되지는(호출되지는) 않는 객체. 주로 파라미터 채우는데 사용됨.
  • Fake objects actually have working implementations, but usually take some shortcut which makes them not suitable for production (anInMemoryTestDatabase is a good example).
    • 실제 프로덕션에 사용하는 구현체를 테스트에 사용하기에는 적합하지 않을 때(구동 속도 등), 이에 대한 기능 축소판의 구현체를 대신 사용하는 것.
    • e.g., DB 대신 H2, HashMap container같은 in memory database를 사용하는 경우
  • Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what’s programmed in for the test.
    • 테스트 동안 설정된 응답을 반환하는 객체로, 일반적으로 테스트를 위해 설정된 것 이외에는 응답을 반환하지 않음.
    • 그래서 정해진 응답을 반환하도록 만드는 행위를stubbing 이라고 한다.
    • e.g., MailService 대신 MailServiceStub을 만들고, 실제 메시지는 보내지 않고, 보냈어야 할 메시지를 저장하도록 구성하는 경우
      • 테스트가 끝난 후, 실제로 보내기로 한 메시지가 제대로 보내졌겠는가? 를 체크하게 되므로 - 상태 검증
  • Spies are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.
    • 어떻게 호출되었는지에 대한 정보를 기록하는 기능을 추가한 stub
    • e.g., 메시지가 몇개나 발송 되었는지를 기록하는 email service
    • 하지만 Mockito 같은 라이브러리에서 spy는 [1.실제 구현체 가 어떻게 호출되었는지를 감시하기 위해서 2. 대체로실제 구현체 기능을 사용하되 일부만 stubbing하기 위해서] real object의 wrapper 형식으로 사용함. 아예 근본이 모조품인 mock, stub과는 차이가 있다.
  • Mocks are pre-programmed with expectations which form a specification of the calls they are expected to receive. They can throw an exception if they receive a call they don’t expect and are checked during verification to ensure they got all the calls they were expecting.
    • 어떤 method call을, 어떤 파라미터로, 몇 번 받게 될지를 미리 예상하여 세팅 해둘 수 있는 객체. call을 받았을 때, 응답을 어떻게 반환할지도 설정(stubbing)해둘 수 있다.
    • verification 단계에서 예상한 형태로 call 수신했는지 체크하는 기능 제공한다. - 행위 검증
    • e.g., a 파라미터로 3번 method call 일어날 줄 알았는데, b 파라미터로 2번만 일어났다면 Exception

라이브러리 도움 없이 대역을 사용하기

https://github.com/umbum/tddb/blob/master/chap07/src/test/java/user/UserRegisterNestedTest.java

  • 어떤 값을 넘기든 weak를 반환하도록 stubbing하기 위한 StubWeakPasswordChecker
  • id 중복 케이스를 테스트하기 위한 MemoryUserRepository
  • 이메일 발송 여부를 체크하기 위한 SpyEmailNotifier

Mockito의 도움을 받아 대역 사용하기

다양한 테스트 환경과 프레임워크
  • mockito + JUnit5 + AssertJ (java, kotlin)
  • spock (groovy)
  • kotest + mockk (kotlin)

Mockito를 사용한 mock 처리

https://github.com/umbum/tddb/blob/master/chap07/src/test/java/user/UserRegisterMockTest.java

예제에서는 stub과 spy에 모두 mock() 사용. mock은 stubbing 기능과 spy 기능을 포함한다.

Mockito에서 spy()는 단순 행위 기록 object 그 이상이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
public void whenCreateMock_thenCreated() {
    List mockedList = Mockito.mock(ArrayList.class);
    mockedList.add("one");
    Mockito.verify(mockedList).add("one");  // 단순 행위 기록은 mock으로도 가능
    assertEquals(0, mockedList.size());  // size는 기본값(0) 반환
}
@Test
public void whenCreateSpy_thenCreate() {
    List spyList = Mockito.spy(new ArrayList());
    spyList.add("one");
    Mockito.verify(spyList).add("one");
    assertEquals(1, spyList.size());  // size()가 실제 응답 반환
}

BDD란? (BDDMockito?)

  • Behavior Driven Development
  • 행위 검증과 mockist style TDD의 한 종류
  • ‘행위를 나타내는 네이밍 룰을 사용하기 때문에, 어떤 객체가 어떤 행동을 해야 하는지를 명시하게 되고, 이 것이 TDD 과정에서 객체 디자인이나 설계에 도움을 준다.’고 주장하고 있습니다.

BDDMockito provides BDD aliases for various Mockito methods, so we can write our Arrange step using given (instead of when), likewise, we could write our Assert step using then (instead of verify) - Quick Guide to BDDMockito(Baeldung)

1
2
3
4
5
6
7
8
9
// given
Mockito.when(mockPasswordChecker.checkPasswordWeak("pw")).thenReturn(true);
BDDMockito.given(mockPasswordChecker.checkPasswordWeak("pw")).willReturn(true);
// when
...
// then
...
기존 Mockito는 given 절에 when.then 메서드가 들어가게 되어 어색하다.

state verification VS behavior verification

상태 검증 vs 행위 검증

  • state verification
    • 테스트가 완전히 끝난 후 결과 상태를 검증
    • 결과적으로 반환한 값이 무엇인지, 테스트가 끝난 후 필드(상태)가 예상한 값이 맞는지.
  • behavior verification
    • 테스트 과정이 예상한 행위(호출)들로 진행되었는지를 검증
    • 어떤 method call이, 어떤 파라미터로, 몇 번 호출 되었는지.

차이 비교

예제 1.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class OrderStateTester...
  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(TALISKER, 51);
    MailServiceStub mailer = new MailServiceStub();  // 상태 검증을 위한 stub class 작성
    order.setMailer(mailer);
    order.fill(warehouse);
    assertEquals(1, mailer.numberSent());
  }
class OrderInteractionTester...
  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(TALISKER, 51);
    Warehouse warehouse = mock(Warehouse.class);
    MailService mailer = mock(MailService.class);  // 행위 검증을 위한 mock
    order.setMailer(mailer);
    mailer.expects(once()).method("send");
    warehouse.expects(once()).method("hasInventory")
      .withAnyArguments()
      .will(returnValue(false));
    order.fill(warehouse);
  }

예제 2. Stub을 이용한 state verification 보다 Mock을 이용한 behavior verification이 나은 예

분할 출금 서비스에 대한 테스트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
fun `분할 출금 테스트`(): Unit = runBlocking {
    // given
    val mockWithdrawClient = mock()
    val installmentWithdrawService = InstallmentWithdrawService(mockWithdrawClient)
    val quantity = "2681231".toBigDecimal()
    // when
    installmentWithdrawService.withdrawInInstallment("TEST_ID", quantity)
    // then
    argumentCaptor(String::class, BigDecimal::class).apply {
        /* 3번 출금에 출금 금액 합계가 quantity와 일치하는지를 테스트해야 함 */
        verify(mockWithdrawClient, times(3)).withdraw(first.capture(), second.capture())
        assert(first.allValues.all { it == "TEST_ID" })
        assert(second.allValues.sumOf { it } == quantity)
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private fun getInstallmentWithdrawServiceStub(
    withdrawCountById: HashMap<String, Int>,
    withdrawAmountById: HashMap<String, BigDecimal>,
): InstallmentWithdrawService {
    return InstallmentWithdrawService(
        mock() {
            on { withdraw(any(), any()) } doAnswer {
                val id = it.getArgument<String>(0)
                val quantity = it.getArgument<BigDecimal>(1)
                withdrawCountById[id] = withdrawCountById.getOrDefault(id, 0) + 1
                withdrawAmountById[id] = withdrawAmountById.getOrDefault(id, BigDecimal.ZERO) + quantity
            }
        }
    )
}
@Test
fun `분할 출금 테스트`(): Unit = runBlocking {
    // given
    val withdrawCountById = HashMap<String, Int>()
    val withdrawAmountById = HashMap<String, BigDecimal>()
    val installmentWithdrawService = getInstallmentWithdrawServiceStub(withdrawCountById, withdrawAmountById)
    val quantity = "2681231".toBigDecimal()
    // when
    installmentWithdrawService.withdrawInInstallment("TEST_ID", quantity)
    // then
    assert(withdrawCountById["TEST_ID"] == 3)
    assert(withdrawAmountById["TEST_ID"] == quantity)
}

예제 3. Mock을 이용한 behavior verification 보다 Stub을 이용한 state verification이 나은 예
1
2
3
4
5
6
7
8
9
10
11
12
@DisplayName("같은 ID가 없으면 가입 성공함")
@Test
void noDupId_RegisterSuccess() {
  userRegister.register("id", "pw", "email");
    ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
    BDDMockito.then(mockRepository).should().save(captor.capture());
    
    User savedUser = captor.getValue();
    assertEquals("id", savedUser.getId());
    assertEquals("email", savedUser.getEmail());
}

1
2
3
4
5
6
7
8
9
@DisplayName("같은 ID가 없으면 가입 성공함")
@Test
void noDupId_RegisterSuccess() {
    userRegister.register("id", "pw", "email");
    User savedUser = fakeRepository.findById("id");
    assertEquals("id", savedUser.getId());
    assertEquals("email", savedUser.getEmail());
}

Mockito에는 Stub()이 따로 없지만, Spock에는 Stub()이 따로 있다?

https://github.com/umbum/tddb/commit/493c5f010fe7dd5345a8b873222addffdfc7c730

martinfowler는 Mocks Aren’t Stubs 라고 했지만, 현대 테스트 프레임워크(Mockito 등)에서는 mock object가 stubbing 기능을 포함하고 있기 때문에 stub을 포함하는 개념으로 사용되고 있다.

**Classical and Mockist Testing**

💡 테스트를 작성하고 TDD를 통해 sw를 디자인하는 전반적인 철학 관점에서의 차이

  • classical TDD style
    • real object를 쓸 수 있으면 쓰고, 이게 애매한 경우에는double 사용
    • 어떤 double이든, 상황에 맞게 사용함. (e.g., 상태 검증이면 stub, 행위 검증이면 mock)
  • mockist TDD style
    • 대역이 필요한 모든 부분에mock 사용. (극단적 행위 검증 위주로 작성)

TDD는 단순히 테스트에 국한된 것이 아니라 테스트를 작성하면서 소프트웨어를 함께 작성해나가는 개발 방법론이므로, 이와 같은 Test 작성 style 차이가, 코드를 작성하며 개발해나가는 전반적인 개발 과정의 차이로 이어지게 됩니다.

mockist TDD style : outside-in

  1. 제일 바깥에서부터 user story 하나를 선정한다.
  2. 이에 따라 테스트 대상이 되는 객체를 도출하고,
  3. 필요한 외부 interface를 정의하면서 테스트를 작성하고,
  4. 테스트 대상이 되는 객체와 상호작용이 필요한 collaborator 객체를 모두 mock처리하여 테스트를 작성.
  5. 첫 번째 테스트를 작성이 끝났다면, mock처리 된 collaborator의 예상 동작(행위)이 곧 새로운 테스트로 이어질 수 있음. (collaborator를 테스트 대상 객체로 하는 새로운 테스트.)
  6. 바깥에서부터 안쪽 layer로 진행하면서 위와 같은 과정을 반복하면서 점진적으로 개발해나갈 수 있음.
  • 행위 검증이 필요한 모든 대상을 mock 처리 하기 때문에, 구현 안된 부분이 있더라도 다 mock 처리 해나가면서 진행하면 됩니다.
  • 이런 점 덕분에, 제일 바깥 layer에서부터 안쪽 layer 방향으로 진행하는 outside-in 방식의 개발이 용이합니다.
  • 도메인 모델을 mock 처리 할 수 있으니, UI layer부터 안쪽으로 진행하는게 가능합니다.

classical TDD style : middle-out

많은 책에서 다루는 설명하는 방식입니다.

  1. 기능을 선정하고, 도메인 모델과 그에 대한 테스트를 작성
  2. 테스트 대상이 되는 객체가 필요로하는 collaborator가 생길 때 마다, 테스트를 통과시키기 위해 일단 hard-coding
  3. 그리고 테스트를 통과하면, collaborator를 실제 구현으로 점진적으로 바꾸어 나감.
  • domain model 부터 작성하고, → UI를 작성하게 되므로 domain model-out 방향으로 진행하게 됩니다.
  • 이런 방식이라면 fake가 거의 필요하지 않습니다. (기능을 구현하면서 out 방향으로 나아가기 때문에)
  • 도메인 로직이 UI에 종속되는 것을 방지 할 수 있다는 장점이 있기 때문에, 많은 사람들이 선호하는 방식입니다.

단, 두 방식 모두 하나의 story에 대해서 UI → Domain이든 Domain → UI이든 하는 것이지, UI, UI,… 끝나면 Domain, Domain… 이런 식으로 진행하는게 아니라는 것을 명심해야 합니다. work feature by feature rather than layer by layer.

Test Isolation 측면에서의 비교

classical TDD style
  • 보통은 real object를 이용하게 되므로 여러 테스트에서 사용되는 real object에 버그가 발생하면 그를 사용하는 모든 테스트가 한꺼번에 실패할 수 있음.
    • 이 것이 어디서 문제가 발생했는지 디버깅을 어렵게 만들 수도 있다는 의견도 있지만, stack trace 등을 통해서 충분히 판단 할 수 있을거라고 본다.
    • 중요한 것은, 테스트가 너무 거대하지 않게 적당한 수준으로 세밀하게 나뉘어 작성 되었는지다.
  • 객체들이 실제로 상호작용하기 때문에, 이 상호작용 하는 부분까지 자동으로 검증이 됨.
    • 따라서 unit test라기 보다는 mini-통합 테스트라고도 할 수 있음
mockist TDD style
  • collaborator를 모두 mock 처리 하므로, real object에 버그가 생기더라도 여러 테스트가 한꺼번에 실패하는 일은 발생하지 않는다. 버그가 있는 부분을 테스트하는 코드만 실패.
  • mock 대상이 되는 클래스의 동작이 바뀌었을 때, 유지보수가 더 까다로울 수 있다.
    • 변경 클래스를 mock처리 하고 있는 많은 테스트 케이스에서, 바뀐 동작에 맞는 응답을 반환하도록 stubbing을 수정해야 한다.
    • 테스트가 실패하면 다행이지만, 통과하는 경우…
      1. 나중에 발견되었을 때, 어째서 실제와 다른가? 라는 의문이 생긴다.
      2. 검증되어야 하는 부분이 검증되지 않는 문제를 초래 할 수 있다.
    • 객체들의 상호작용이 테스트에 포함되지 않기 때문에 발생하는 문제.
1
2
3
4
5
6
7
8
A(테스트 대상 클래스) ----> B ----> C
(1) A ----> mock(B)
(2) A ----> B ----> mock(C)
(1)을 선택하게 되면 B에 대한 TC를 따로 작성해주어야 하는데
이게 작성이 좀 까다롭거나, 기존에도 TC가 달려있지 않은 부분이면 그냥 넘어가는 경우가 많음.
반면 (2)는 B의 일부가 Test 범위에 포함되기 때문에 mini-통합 테스트가 된다.
장단이 있음.

**Coupling Tests to Implementations 측면에서의 비교**

classical TDD style
  • 최종 상태에만 관심이 있기 때문에 그다지 구현과는 상관이 없어 결합도가 낮다.
mockist TDD style
  • 메서드의 호출 방식과 구현에 많이 결합되어 있기 때문에, 조금만 바꿔도 테스트가 실패할 가능성이 크다.
  • 이런 커플링이 리팩터링을 귀찮게 만든다.
  • 그래서 너무 타이트한 메서드 호출 조건(순서, args..)을 지정하는 것은 피하는 것이 좋다.

**So should I be a classicist or a mockist?**

  • 우선, 마틴파울러는 이렇게 얘기하고 있는데…
    • ‘Personally I’ve always been a old fashionedclassic TDDer and thus far I don’t see any reason to change.’
    • mockist는 ‘mock 처리 해야 하는 예상 행동을 먼저 생각하고 → 테스트 대상 객체가 어떻게 구현되어야 하는지?’ 방식으로 개발하는데, 이 것이 상당히 부자연스럽게 느껴진다.
      • 부연 설명 하자면, mockist의 개발 과정은 이렇다.
      • collaborator를 일단 mock 처리 하고,이 collaborator의 예상 행동을 먼저 생각 하여 stubbing한다.
      • 나중에 이 예상 행동을 바탕으로 collaborator의 실제 구현을 작성한다.
      • 즉, 실제 대상의 실체화 보다, mock 처리와 행위를 예상한 stubbing 작성이 먼저다!
    • mockist TDD 방식 사실 많이 안해봤다. 그래서 더 얘기하기가 좀 그렇다.
  • 개인적으로는, 꼭 한쪽 진영을 골라야 하는 문제는 아닌 것으로 보입니다.
    • outside-in이든, domain model-out이든, real object를 쓰고 상태 검증을 하든, mock 처리 하고 행위 검증을 하든, 모두 그때의 상황에 맞게 취향껏 사용하면 충분합니다.
    • 중요한 것은 이러한 방법론과 차이점, 장단점을 이해하고 적재적소에 사용할 수 있는 것입니다

참고

This post is licensed under CC BY 4.0 by the author.