Home 4장 한계 돌파를 위한 노력, Mock을 이용한 TDD
Post
Cancel

4장 한계 돌파를 위한 노력, Mock을 이용한 TDD

4.1 Mock 객체

Mock 객체란 무엇인가?

Mock 이란 조각하기 쉬운 재료를 이용하여 추후 만들어질 제춤의 외양을 흉내 낸 모조품을 말한다. 즉 실제 모듈과 비슷하게 보이도록 만든 가짜 객체를 Mock 객체라고 한다.

예) 사용자 암호를 변경하는 기능을 구현하기 위해 테스트 케이스를 작성하는데 추가 요구사항으로 사용자의 패스워드를 반드시 암호화한 다음에 저장해야 한다. 암호화 모듈의 스펙은 interface로 정해져 있고, 이 모듈은 다른 팀에서 구현하기로 결정한다고 했을 때 다른팀에서 암호화 모듈을 완성할 때까지 테스트를 진행하기가 어렵다. 이럴때 스펙이 정해져 있으니 암호화 모듈처럼 보이는 객체를 만들어서 테스트에 사용할 수 있다.

정리하자면 우리가 구현하는데 필요하지만 실제로 준비하기엔 여러가지 어려움이 따르는 대상을 필요한 부분만큼만 채워서 만들어진 객체 를 말한다.

언제 Mock 객체를 만들 것인가?

테스트 케이스 작성이 어려운 상황과 Mock 객체가 필요한 상황은 종종 일치하곤 한다. 대부분의 경우 모듈이 가진 의존성이 근본적인 원인이 된다. 모듈이 필요로 하는 읜존성은 테스트 작성을 어렵게 만든다. 그리고 그 의존성을 단절시키기 위해 Mock 객체가 사용된다.

  1. 테스트 작성을 위한 환경 구축의 어려운 경우 : 환경 구축을 위한 작업시간이 많이 필요한 경우, 특정 모듈을 아직 갖고 있지 않아서 ,타 부서와의 협의나 정책이 필요한 경우에 Mock이 필요하다.
  2. 테스트가 특정 경우나 순간에 의존적인 경우 : 특히 예외처리를 테스트할 때 많이 접한다. Mock을 이용하면 특정 상태를 가상으로 만들어놓을 수 있다.
  3. 테스트 시간이 오래 걸리는 경우

4.2 Mock에 대한 기본적인 분류 개념, 테스트 더블

제라드 메스자로스가 만든 단어로 테스트 더블은 오리지널 객체를 사용해서 테스트를 진행하기 어려울 경우 이를 대신해서 테스트를 진행할 수 있도록 만들어주는 객체를 지칭한다. Mock 객체는 테스트 더블의 하위로 분류되어 있다.

예) 고객이 쿠폰을 발급받아 저장하고, 그 내역을 확인할 수 있는 기능 구현

1
2
3
4
5
6
7
public interface ICoupon {
  public String getName();                //쿠폰 이름
  public boolean isValid();               //쿠폰 유효 여부 확인
  public int getDiscountPercent();        //할인율
  public boolean isAppliable(Item item);  //해당 아이템에 적용 가능 여부
  public void doExpire();                 //사용할 수 없는 쿠폰으로 만듬
}
1
2
3
4
5
6
7
8
9
10
11
12
public class UserTest{
  @Test
  public void testAddCoupon() throws Exception {
    User user = new User("area88");
    assertEquals("쿠폰 수령 전", 0, user.getTotalCouponCount());

    ICoupon coupon = ?????

    user.addCoupon(coupon);
    assertEquals("쿠폰 수령 후", 1, user.getTotalCouponCount());
  }
}

위 예제에서 ICoupon은 테스트 대상은 아니고 테스트에 사용되는 일종의 테스트 픽스처이다. 하지만 이 구현 객체가 없으면 정상적인 테스트 케이스를 작성할 수 없다. 이때 테스트 더블을 활용하여 이 상황을 극복할 수 있다.

더미 객체

더미 객체는 단순한 껍데기에 해당한다. 오로지 인스턴스화될 수 있는 수준으로만 ICoupon 인터페이스를 구현한 객체다.

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
public class DummyCoupon implements ICoupon{
    @Override
    public String getName() {
        return null;
    }

    @Override
    public boolean isValid() {
        return false;
    }

    @Override
    public int getDiscountPercent() {
        return 0;
    }

    @Override
    public boolean isAppliable(Item item) {
        return false;
    }

    @Override
    public void doExpire() {

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
public class UserTest{
  @Test
  public void testAddCoupon() throws Exception {
  User user = new User("area88");
  assertEquals("쿠폰 수령 전", 0, user.getTotalCouponCount());

      ICoupon coupon = new DummyCoupon();

      user.addCoupon(coupon);
      assertEquals("쿠폰 수령 후", 1, user.getTotalCouponCount());
  }
}

더미 객체는 단지 인스턴스화된 객체가 필요할 뿐 해당 객체의 기능까지는 필요하지 않은 경우에 사용한다.(정상 동작은 보장되지 않음)

테스트 스텁

테스트 스텁은 더미 객체가 마치 실제로 동작하는 것처럼 보이게 만들어놓은 객체다. 더미 객체와 다르게 테스트 스텁은 객체의 특정 상태를 가정해서 만들어놓은 단순 구현체다. 특정 값을 리턴해주거나 특정 메시지를 출력하는 등의 작업을 한다.

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
public class StubCoupon implements ICoupon{
    @Override
    public String getName() {
        return "VIP 고객 한가위 감사쿠폰";
    }

    @Override
    public boolean isValid() {
        return true;
    }

    @Override
    public int getDiscountPercent() {
        return 7;
    }

    @Override
    public boolean isAppliable(Item item) {
        return true;
    }

    @Override
    public void doExpire() {

    }
}

단지 인스턴스화될 수 있는 객체 수준이면 더미, 인스턴스화된 객체가 특정 상태나 모습을 대표하면 스텁이다.

1
2
3
4
5
6
7
8
9
10
@Test
public void testGetLastOccupiedCoupone() throws Exception {
  User user = new User("area88");
  ICoupon coupon = new StubCoupone();
  user.addCoupon(coupon);
  ICoupone lastCoupone = user.getLastOccupiedCoupone();

  assertEquals("쿠폰 할인율", 7, lastCoupone.getDiscountPercent());
  assertEquals("쿠폰 이름", "VIP 고객 한가위 감사 쿠폰", userCoupone.getName());
}

스텁은 특정 객체가 상태를 대신해주고 있긴 하지만 하드코딩된 형태이기 때문에 로직이 들어가는 부분은 테스트할 수 없다. 특정 쿠폰이 구매 제품에 적용되는지 여부에 따라 결제액이 바뀌는걸 테스트하는 경우 할인이 적용되는 아이템과 할인이 적용되지 않은 아이템에 대한 테스트가 필요하다. 이런 경우 아이템에 따라 쿠폰이 적용 가능한지 안한지로 판단하여 하드코딩 하여 작성할 수 있다.

이렇게 테스트 더블로 만들어진 객체가 발전하면 단순 스텁을 벗어나서 실제 로직이 구현된 것처럼 보이는데 그런 객체를 페이크 객체라고 한다.

페이크 객체

페이크 객체는 스텁과 경계를 구분짓기 어려우나 스텁은 하나의 인스턴스를 대표하는데 주로 쓰이고, 페이크는 여러 개의 인스턴스를 대표할 수 있는 경우이거나 좀 더 복잡한 구현이 들어가 있는 객체를 지칭한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class FakeCoupone implement ICoupone {
  List<String> categoryList = new ArrayList();

  public FakeCoupone() {
    categoryList.add("부엌칼");
    categoryList.add("아동 장난감");
    categoryList.add("조리기구");
  }

  @Override
  public boolean isAppliable(Item item) {
    if(this.categoryList.contains(item.getCategory()) {
      return true;
    }
    retuen false;
}

페이크 객체는 복잡한 로직이나 객체 내부에서 필요로 하는 다른 외부 객체들의 동작을, 비교적 단순화하여 구현한 객체다. (복잡도 = 더미 < 테스트 스텁 < 페이크 객체)

테스트 스파이

테스트에 사용되는 객체에 대해서도 특정 객체가 사용됐는지 그리고 객체의 예상된 메소드가 정상적으로 호출됐는지를 확인해야 하는 상황이 발생한다. 보통은 호출 여부를 몰래 감시해서 기록했다가, 나중에 요청이 들어오면 해당 기록 정보를 전달해준다. 그런 목적으로 만들어진 테스트 더블을 테스트 스파이라고 부른다.

1
2
3
4
5
6
7
8
9
@Test
public void testGetOrderPrice_discounableItem() throws Exception {
  PriceCalculator calculator = new PriceCalculator();
  Item item = new Item("LightSavor", "부엌칼", 100000);
  ICoupon coupon = new FakeCoupon();

  //getOrderPrice를 구하기 위해서는 isAppliable 메소드가 1번 호출되어야 한다는 것이 가정되어 있다.
  assertEquals("쿠폰으로 인해 할인된 가격", 93000, calculator.getOrderPrice(item, coupon));
}
1
2
3
4
5
6
  @Override
  public boolean isAppliable(Item item) {
    isAppliableCallCount++; //호출되면 증가
    if(this.categoryList.contains(item.getCategory()) {
      return true;
    }
1
2
3
4
//테스트 스파이
public int getIsAppliableCallCount() {
  return this.isAppliableCallCount;
}

테스트 스파이는 아주 특수한 경우를 제외하고 잘 쓰이지 않는다. 대부분의 Mock 프레임워크들은 기본적으로 테스트 스파이 기능을 제공해준다.

[상태 기반 테스트, 행위 기반 테스트] 상태 기반 테스트는 테스트 대상 클래스의 메소드를 호출하고, 그 결과 값과 예상 값을 비교하는 식이다. 특정 메소드를 거친 후, 객체의 상태에 대해 예상값과 비교하는 방식이 상태 기반 테스트이다.

행위 기반 테스트는 올바른 로직 수행에 대한 판단의 근거로 특정한 동작의 수행 여부를 이용한다.

Mock 객체

일반적으로 테스트 더블은 상태 기반으로 테스트 케이스를 작성하고, Mock 객체는 행위를 기반으로 테스트 케이스를 작성한다. 행위기반 테스트는 복잡한 시나리오가 사용되는 경우가 많고, 모양이나 작성 등 여러가지 측면에서 어색한 경우가 많기 때문에 만일 상태 기반으로 테스트를 할 수 있는 상황이라면 굳이 행위 기반 테스트 케이스는 만들지 않는 것이 좋다.

Mock 객체라는 용어가 주는 혼란스러움 정리

Mock 객체는 넓은 의미로 일반적인 가성 임시 구현체의 의미로 사용되는 경우가 많다. 테스트 더블과 거의 동등한 의미로 사용되는 경우가 많았기 때문에 따라서 Mock 객체라는 단어를 테스트 더블과 동일한 의미로 사용할 것이다.

**테스트 케이스 작성시 매번 클래스를 구현하는 것은 번거로울 수 있다 그럴땐 명시적인 클래스 보다는 ‘익명 클래스’로 만들어서 사용하

4.3 Mock 프레임워크

Mock 프레임워크는 동적으로 Mock 객체를 만들어주는 프레임워크를 지칭한다. Mock 프레임워크 종류로는 EasyMock, jMock, Mockito가 있다. 이중 Mockito는 간편함과 기존 Mock 프레임워크들이 지향했던 행위 기반 테스트 위주에서 상태 위주 테스트로의 회귀를 전면으로 내세우고 있다.

EasyMock

스프링 프레임워크에서 테스트로 사용하는 EasyMock 이다. 기본적으로 Recode & Replay라는 메타포를 사용하며 네 단계로 동작한다.

  • createMock : 인터페이스에 해당하는 Mock 객체를 만든다.
  • Record : Mock 객체 메소드의 예상되는 동작을 녹화한다.
  • Replay : 예정된 상태로 재생한다.
  • Verify : 예상했던 행위가 발생했는지 검증한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
//1. createMock
List MockList = createMock(List.class)

//2. Record
//녹화
expect(Mock 객체의 메소드).andReturn(예상 결과값);
//재생
replay(Mock 객체);

expect(mockList.add("item")).andReturn(true);
expect(mockList.size()).andReturn(0).items(2);
mockList.clear();
replay(mockList);

녹화 및 재생은 Mock 객체가 만들어진 이후부터 재생이 호출되기 전까지 동작이 기록된다. 행위 기록이기 때문에 횟수를 지정할 수도 있다.

1
2
3
4
5
6
7
8
9
10
//3. 검증 verify(Mock_객체);
@Test
public void listMockTest() throws Exception {
  mockList = createMock(List.class);
  expect(mockList.add("item")).andReturn(true);
  expect(mockList.size()).andReturn(0).items(2);
  mockList.clear();
  replay(mockList);
  verify(mockList);
}

jMock

jMock은 다른 프레임워크와 구별되는 몇 가지 독특한 차이점을 목표로 개발되었는데 특히 테스트의 표현 확대와 가독성 증진에 많은 노력이 들어가 있다.

[특징]

  1. 연쇄 호출 : 동일 객체에 여러 개의 메시지를 보낼 때 발생하는 번잡스러움을 해결하기 위해 만들어진 스타일이다. 사전에 특정 워크플로를 만들어놓고, 해당 순서에 해당하는 객체를 리턴하는 방식으로 변경한다.
1
2
3
4
5
6
7
8
9
10
11
//전
bookedTicket.setDeparture(A);
bookedTicket.setArrival(B);
bookedTicket.setDepartureDate(date);
bookedTicket.setAdditionalFare(fare);

//후
bookedTicket.departure(A)
            .arrival(B)
            .departureDate(date)
            .additionalFare(fare);
  1. 전용 Matcher 사용 : 기본적으로 Hamcrest Matcher 라이브러리를 사용하고 있다.
    • CreateMock : 인터페이스에 해당하는 Mock 객체를 만든다.
    • Expect : Mock 객체 메소드의 예상되는 동작을 미리 지정 한다.
    • Exercise : 테스트 메소드 내에서 Mock 객체를 사용한다.
    • Verify : 예상했던 행위가 발생했는지 검증한다.
  2. 예상 동작 지정 및 테스트 수행
  3. 예상값 지정하기
  4. 검증 : 프레임워크가 알아서 처리해주기 때문에 명시적으로 검증을 표시할 필요가 없다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//1. 테스트 픽스처 클래스 정의와 Mockery 생성
@RunWith(JMock.class)
public class PublisherTest {
    Mockery context = new JUnit4Mockery();
}

//2. Mock 객체 만들기
final Subscriber subscriber = context.mock(Subscriber.class);

//3. 예상 동작 지정 및 테스트 수행
//	{{}} 가 연속적으로 사용된 것은 Anonymous 클래스로 만들어지고 있는 Expectation 클래스의 필드 부분에 멤버 블록을 만든 코드다. -> 클래스가 생성과 동시에 호출됨
context.checking(new Expectations() {{
    //예상 동작들을 지정한다.
}}
);

//4. 예상값 지정하기
호출횟수지정메소드(Mock_객체).Mock객체메소드(argument_지정);
inSequence(sequence-name);
when(state-machine.is(state-name));
will(action);
then(state-machine.is(new-state-name));

Mockito

Mockito는 역사는 오래되지 않았지만 상태 기반 테스트를 지향한다는 점이 인기 요인이 되었다.

[특징]

  1. 테스트 그 자체에 집중한다.
  2. 테스트 스텁을 만드는 것과 검증을 분리시켰다.
  3. Mock 만드는 방법을 단일화했다.
  4. 테스트 스텁을 만들기 쉽다.
  5. API가 간단하다.
  6. 프레임워크가 지원해주지 않으면 안되는 코드를 최대한 배제했다.
  7. 실패 시에 발생하는 에러추적이 깔끔하다.

**Mockito는 EasyMock과 jMock의 단점을 보완하기 위해 나온 Mock 프레임워크라고 생각하면 된다.

  • CreateMock : 인터페이스에 해당하는 Mock 객체를 만든다.
  • Stub : 테스트에 필요한 Mock 객체의 동작을 지정한다.
  • Exercise : 테스트 메소드 내에서 Mock 객체를 사용한다.
  • Verify : 메소드가 예상대로 호출됐는지 검증한다.
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
//1. Mock 객체 만들기
Mockito.mock(타깃 인터페이스);
List mockedList = mock(List.class);

//2. 예상 값 지정 : Mockito는 스텁->수행->검증으로 단순화되어 있다. 미리 예상 행동을 고민하지 않고 테스트를 수행 후 결과를 보자는 개념이다.

//3. 테스트에 사용할 스텁 만들기
when(Mock_객체의_메소드).thenReturn(리턴값);
when(Mock_객체의_메소드).thenThrow(예외);

mockedList.add("item")
mockedList.clear();

when(mockedList.get(0)).thenReturn("item");
when(mockedList.size(0)).thenReturn(1);
when(mockedList.get(1)).thenThrow(new RuntimeException());

//4. 검증
verify(Mock_객체).Mock_객체의_메소드;
verify(Mock_객체, 호출횟수지정_메소드).Mock_객체의_메소드;

verift(mockedList).add("item");
verift(mockedList, times(1)).add("item");       //times(n) : n번 호출됐는지 확인
verift(mockedList, times(2)).add(box);
verift(mockedList, never()).add(car);           //never : 호출되지 않았어야 함
verift(mockedList, atLeastOnce()).removeAll();  //atLeastOnce : 최소 한 번은 호출됐어야 함
verift(mockedList, atLeast()).size();           //atLeast(n) : 적어도 n번은 호촐됐어야 함
verift(mockedList, atMost(5)).add(box);         //atMost(n) : 최대 n번 이상 호출되면 안됨

[Mockito의 특징적인 기능]

  1. void 메소드를 Stub으로 만들기 - void 메소드에서 예외가 발생하는 경우 Stub으로 구현할 때 doThrow 메소드를 사용한다.
  2. 콜백으로 Stub 만들기 : thenAnswer - 특정 Mock 메소드에 대해 실제 로직을 구현하고자 할 때 콜백 기법을 사용, 권장하지 않는 방식이다.
  3. 실체 객체를 Stub으로 만들기 : SPY - Mockito는 실 객체도 Mock으로 만들 수 있는데 한정적으로 사용하기를 권장한다.
  4. 똑똑한 NULL 처리 : SMART NULLS - mock(List.class, RETURNS_SMART_NULLS);
  5. 행위 주도 개발(BDD) 스타일 지원 - //given, //when, //then 식의 행위 주도 개발 스타일로 테스트 케이스 작성을 지원해준다. BDDMockito를 static import한다.

4.4 Mock 프레임워크 마무리

[Mock 사용시 유의사항]

  1. Mock 프레임워크가 정말 필요한지 잘 따져본다.
  2. 투자 대비 수익이 확실할 때만 사용한다.
  3. 어떤 Mock 프레임워크를 사용하느냐는 핵심적인 문제가 아니다.
  4. Mock은 Mock일 뿐이다. - 실제 객체가 끼어들어 왔을 때도 잘 동작하리라는 보장은 없다.

**테스트 케이스 작성을 위한 궁극의 템플릿으로 //given(선행조건 기술), //when(기능수행), //then(결과 확인)

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

3장 TDD 좀 더 잘하기

5장 데이터베이스 테스트 DbUnit