본문 바로가기
아직 카테고리 미정

Mock 객체란 무엇일까? 왜 써야될까?

by simplify-len 2021. 3. 11.

표지

아래 내용은 위 책에서 말하는 4장 TDD with Mock 에서 내용을 발췌했습니다.

 

 TDD를 공부하면서 Mock 이라는 용어는 너무나도 많이 나오고, 실제로 테스트 프레임워크를 사용하면 Mock 객체를 많이 사용되게 된다. 그놈의 Mock!

Mock 객체를 사용해서, 테스트를 용이하게 만들수 있고, 아직 만들어지지 않은 개념을 활용해 내가 만들고자 하는 객체를 구체화시킬 수 있는 도구라는 사실은 알았다. 그러나 문제점은 여기에 있었다. 주로 Mockito 프레임워크를 사용하는데, 각 Mock 객체가 어떤 역할을 하는지 이해하기 어려웠다.

더하여, 어떻게 활용해야 하는지도, 이해하기 어려웠다.

이번 장에서는 다시한번 Mock을 써야하는 이유, 그리고 Mock 객체가 어떤 역할을 하는지, 마지막으로 어떻게 활용하는지 이해할 수 있는 시간이 되었으면 좋겠다.

Mock 객체는 무엇일까?

값싼 비용의 재료를 활용해 제품의 외양을 흉내 낸 모조품이라고 생각한다. 우리가 프로덕트에 삽입되는 코드는 정제하고 또 정제해서 좋은 질을 가진 코드를 만들어 낸다. 하나의 코드를 만들기 위해 수만가지의 생각을 해야하기 때문에 Mock 이 필요해진게 아닐까? Mock이 존재하는 또다른 이유는 객체지향 프로그래밍 방식도 하나의 원인이라고 생각한다.

쾌속질주 책에 나오는 예시를 함께 보자.

 

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class MainTest {

    @Test
    public void savePassword() throws Exception {
        //Given
        UserRegister register = new UserRegister();

        String userId = "sweet88";
        String password = "potato";

        register.savePassword(userId, password);
        Assertions.assertEquals(password, register.getPassword(userId));
    }
}

위와 같은 코드에서 사용자의 패스워드는 반드시 암호화를 해야 한다는 요구사항이 있다고 가정하자.

그리고 암호화할 스펙은 다음과 같다.

 

public interface Cipher {
    public String encrypt(String source);
    public String decrypt(String source);
}

다행히 이 모듈은 독립적으로 구현되기로 하고, 옆팀에서 만든다고 하자.

다시 처음 코드로 돌아와서, 암호화 코드를 삽입하면 아래와 같은 코드가 될 것이다.

 

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class MainTest {

    @Test
    public void savePassword() throws Exception {
        //Given
        UserRegister register = new UserRegister();

        Cipher cipher = null;
        String userId = "sweet88";
        String password = "potato";

        register.savePassword(userId, cipher.encrypt(password));
        String decryptedPassword = cipher.decrypt(register.getPassword(userId));
        Assertions.assertEquals(password, decryptedPassword);
    }
}

여기서 우리가 관심있어라 하는 것은 어떤 암호화를 사용해 암호화를 하는것에 관심있는 것이 아니라, 내가 작성한 savePassword() 의 비지니스 로직이 예상한 대로 잘 동작되는가가 중요한 것이다.

그래서 대안으로 MD5Cipher 처럼 보이는 객체를 만들어서 개발에 사용하는 것 일단 스펙(Cipher 인터페이스)이 있으니, 스펙을 구현하는 MockMD5Cipher 라는 클래스를 만들어 사용한다고 하자.

 

public class MockMD5Cipher implements Cipher {
    @Override
    public String encrypt(String source) {
        return "tomato";
    }

    @Override
    public String decrypt(String source) {
        return "xxxxxxxx";
    }
}

우리가 구현해야 할 savePassword를 테스트하기 위해서는 위 정도의 코드면 완벽하다.

여기서 Mock 이 무엇인지 이해할 수 있다. Mock 객체는 우리가 필요로 하는 객체를 구현하는데 있어서 필요하지만 실제로 준비하기엔 여러가지 어려움이 따르는 대상을 필요한 부분만큼만 채워넣어서 만들어진 객체라 말할 수 있다.

 

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

'의존성'의 원인으로 테스트를 하기 힘들 때

  1. 테스트 작성을 위한 환경 구축이 어려워서
    • 환경 구축을 위한 작업 시간이 많이 필요한 경우 Mock 객체를 사용
    • 경우에 따라서는 특정 모듈을 아직 갖고 있지 않아서 테스트 환경을 구축하지 못할 수도 있다.
    • 타 부서와의 협의나 정책이 필요한 경우에도 Mock이 필요
  2. 테스트가 특정 경우나 순간에 의존적이라서
  3. 테스트 시간이 오래 걸려서

Mock에도 종류가 있는데, 이를 테스트 더블(Test Double) 이라 한다.

테스트 더블이라는 단어는 대역, 스턴트맨을 나타내는 스턴트 더블 이라는 용어에서 차용된 것으로, 여러가지의 종류를 가진다.

image-20210311163435629

포괄적으로 Mock이라 하지만 대다수의 개발자들이 이를 받아들이고 있다.

마찬가지로, 예시로 이해해보자.

 

package mock;

public interface ICoupon {
    String getName();               // 쿠폰 이름
    boolean isValid();              // 쿠폰 유효여부 확인
    int getDiscountPercent();       // 할인률
    boolean isAppliable(Item item); // 해당 아이템에 적용 가능 여부
    void doExpire();                // 사용할수 없는 쿠폰으로 만듦
}

쿠폰 받기 기능 구현용 테스트 시나리오는 다음과 같다.

 

유저 ID를 기준으로 신규유저 객체를 생성
- 현재 쿠폰 확인
- 신규 쿠폰 받기
- 유저의 보유 쿠폰 개수 확인(우선은 보유 쿠폰 수가 증가됐는지 판단하는 수준으로 작성)

이제 위 인터페이스를 사용해 사용자에게 쿠폰을 제공하는 테스트 코드를 작성한다고 가정하자.

 

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

public class UserTest {

    @Test
    public void addCoupon() throws Exception {
        //Given
        User user = new User("area88");
        assertEquals(0, user.getTotalCouponCount());

        ICoupon coupon = null;
        user.addCoupon(coupon);
        assertEquals(1, user.getTotalCouponCount());
    }
}

간결한 결합을 만들기 위해 설계하다보면 꼭 나오는 것이 바로 인터페이스이다. 마찬가지로 위 코드에도 인터페이스가 등장하게 되는데, 테스트 케이스를 만드는 데 있어 장애물이다. 현재 쿠폰은 테스트 대상은 아니고, 테스트에 사용되는 일종의 테스트 픽스처다. 우리가 집중하려는 건 User 클래스의 메소드 구현이다. 어떻게 해야될까? 우리가 개발해야될 부분이 아니라면 이는 어떻게 처리해야 될까?

이제부터 테스트 더블 은 이상황을 어떤 식으로 해결하는지 하나씩 살펴보자.

더미 객체(Dummy Object)

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

 

package mock;

public class ICouponImpl 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() {

    }
}

테스트 스텁(Test Stub)

테스트 스텁(stub)은 더미 객체가 마치 실제로 동작하는 것처럼 보이게 만들어놓은 객체다. 더미 객체로 만들어진 경우에는 메소드가 호출되면 동작을 하긴한다. 만약 리턴 타입이 있으면 타입 기본값으로 리턴되고, void 메소드일 경우 아무 일도 안 일어난다. 반면에 테스트 스텁은 객체의 특정 상태를 가정해서 만들어놓은 단순 구현체다. 특정한 값을 리턴해주거나 특정 메세지를 출력하는 등의 작업을 한다. 앞에서 Mock 객체를 설명할 때 만들었던 MockMD5Ciper 클래스가 바로 스텁에 해당된다.

테스트 스텁과, 더미의 차이점을 정리하면

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

마치, 실제 로직이 구현된 것처럼 보이는데, 그렇게 만들어진 객체를 페이크 객체

페이크 객체

더미와 스텁의 경계가 모호할 수 있던 것처럼, 스텁과 페이크의 경계도 사실 딱 구분 짓기는 어렵다. 다만 스텁 은 하나의 인스턴스를 대표하는 데 주로 사용하고, 페이크 는 여러 개의 인스턴스를 대표할 수 있는 경우이거나, 좀더 복잡한 구현이 들어가 있는 객체를 지칭한다. 예를 들어 DB를 통해 쿠폰 적용 가능 카테고리나 아이템을 확인한다고 하면, 페이크 객체에서는 테스트에 사용할 아이템과 카테고리에 대해서만 실제로 DB에 접속해서 비교할 때와 모습처럼 보이게 만들 수 있다.

페이크 객체는 복잡한 로직이나, 객체 내부에서 필요로 하는 다른 외부 객체들의 동작을, 단순화하여 구현한 객체이다. 결과적으로 테스트 케이스 작성을 진행하기 위해 필요한 다른 객체(혹은 클래스)들과의 의존성을 제거하기 위해 사용.

테스트 스파이(Test spy)

보통 일반적으로 몰래 무엇가를 조사해서 정보를 넘기는 일을 하는 사람을 스파이라 한다.

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

특정 메서드의 정상호출 여부 확인을 목적으로 구현되며, 더미부터 시작해서 페이크 객체에 이르기까지 테스트 더블로 구현된 객체 전 범위에 걸쳐 해당 기능을 추가할 수 있다. 보통 스파이들이 다른 일도 하면서 스파이 일을 겸업하듯이 테스트 스파이 객체도 다른 동작을 하면서 스파이 기능까지 한다.

Mock 객체

Mock 객체를 이해하기 위해서는 상태 기반 테스트와 행위 기반 테스트에 대한 이해가 필요하다.

image-20210311172918134

 

 

image-20210311173112281

  • 일반적인 테스트 더블은 상태(state)를 기반으로 테스트 케이스를 작성한다.
  • Mock 객체는 행위(behavior)를 기반으로 테스트 케이스를 작성한다.

Mock 객체는 행위를 검증하기 위해 사용되는 객체를 지칭하며, 수동으로 만들 수도 있고, Mock 프레임워크를 이용할 수도 있다.

Mock 객체 라는 단어는 행위기반테스트를 위해 사용되는 객체보다 더 넓은 일반적인 '가상 임시 구현체'의 의미로 사용되는 경우가 더 많다.

image-20210311173623997


Mock 프레임워크 중 Mockito에 대해서만 학습해보자.

https://site.mockito.org/

Mockito는 주로 Stub작성과 Verify 가 중심을 이루며 다음과 같은 순서로 진행된다.

  • CreateMock - 인터페이스에 해당하는 Mock 객체를 만든다.
  • Stub - 테스트에 필요한 Mock 객체의 동작을 지정
  • Exercise - 테스트 메소드 내에서 Mock 객체 사용
  • Verify - 메소드가 예상대로 호출됐는지 검증
@Test
void name() {
  List<Integer> mock = mock(List.class);
  mock.add(1);
  mock.add(2);

  verify(mock, times(1)).add(1);
  verify(mock, never()).add(3);
}

Mockito 에 대한 좀더 자세한 예제는 다음 포스팅에서 다루도록 하겠습니다!

댓글