[Unit Test] Robolectric 4.4 와 Shadow 그리고 Mocking

[Unit Test] Robolectric 4.4 와 Shadow 그리고 Mocking

enter image description here
안드로이드 어플리케이션의 유닛 테스트를 작성하다 보면,비즈니스 로직을 완전 순수하게 자바로 독립해서 작성하기에는 너무 코드가 많아지고 복잡해지고 솔직히 너무 오버 아키텍처가 되는게 아닐까 고민하게 되는 것 같습니다.

결국 안드로이드 프레임워크 API 를 이용하게 되는데, 이 안드로이드 API 들을 mocking 하기 위해서 그래도 가볍우면서 샘플코드나 자료를 쉽게 찾을 수 있는 mocking framework인 Mockito 를 사용하게 됩니다.

그런데 또 이걸 이용해서 유닛 테스트를 만들며 mock object들을 만들다보면 내가 테스트 코드를 작성하는 건지, 안드로이드 프레임워크 코드를 작성하는 건지 하는 생각이 듭니다.

결국 안드로이드 프레임워크 코드를 내가 작성해야 하는 상황이 싫어, Robolectric을 사용하는 것을 고민하게 됩니다. Robolectric의 원래 목적을 생각해보면 실제 디바이스나 에뮬레이터 상에서 UI를 수행하는 대신, 빌드 타임에 UI테스트를 수행할 수 있게 해 줍니다.

하지만 이 글에서는 여기까지는 고려하지 않고, 직접 Mock 객체를 만들지 않아도 안드로이드 프레임워크 API를 이용할 수 있다는 것에 집중하려합니다. 그리고 Mock 객체를 직접 만들지 않고 Shadow를 사용하는 것은 부족한 점이 있지만, 이 부분을 어떻게 채워가는지 이야기 하고자 합니다.

Mocking 할 필요가 없다.

먼저 JUnit으로 안드로이드 어플리케이션의 유닛 테스트를 작성하는 상황을 보겠습니다. 테스트를 작성할 때, 코드에서 안드로이드 프레임워크의 API를 이용하면 IDE 상으로는 에러를 보여주지 않지만, 실제 테스트를 수행해 보면 에러가 발생합니다.

(테스트 대상 클래스)
public class MyClass {
    public boolean verifyName(String name) {
        return TextUtils.equals(name, "Azza");
    }
}

(테스트 클래스)
@RunWith(JUnit4.class)
public class MyClassTest {
    @Test
    public void testGetName() {
        MyClass myClass = new MyClass();
        assertTrue(myClass.verifyName("Azza"));
    }
}
java.lang.RuntimeException: Method equals in android.text.TextUtils not mocked. See http://g.co/androidstudio/not-mocked for details.

	at android.text.TextUtils.equals(TextUtils.java)
	.
	.
	.

gradle 에서는 이러한 에러를 넘어가기 위한 옵션을 제공하고 있습니다. build.gradle 에 다음과 같이 추가를 해 보죠.

android {
    testOptions {
        unitTests.returnDefaultValues = true
    }
}

이번에는 Assert 에서 실패하게 됩니다. 왜냐하면 기본 값을 리턴하는 Dummy 이기 때문에 항상 False를 리턴하게 됩니다.

TextUtils 는 static method를 가진 유틸 클래스라 mock 객체를 전달할 수 없습니다. 이 상황에서 JUnit 으로 안드로이드 API 를 사용하기 위해서는 test/ 디렉토리 아래에 android.text.TextUtils 클래스의 mock을 작성해야 합니다.(실제로 동작하는 코드를 작성해야 하니 mock이라고 표현할 수 있을지 모르겠습니다만.)

다른 예제로 일반적인 안드로이드 프레임워크 클래스를 살펴 보겠습니다. 왠지 Java 에도 있을 것 같지만 없는 SparseArray 클래스입니다.

(테스트 대상 클래스)
public class MyArray {
    SparseArray<String> myArray;
    public MyArray(SparseArray<String> myArray) {
        this.myArray = myArray;
    }
    public void put(int key, String value) {
        myArray.put(1, "Azza");
    }
    public int isExist(String value) {
        return myArray.indexOfValue(value);
    }
}


(테스트 코드)
@RunWith(JUnit4.class)
public class MyArrayTest {
    @Test
    public void testSum() {
        MyArray myArray = new MyArray(new SparseArray<>());
        myArray.put(1, "Azza");
        assertEquals(1, myArray.isExist("Azza"));
    }
}

역시 위와 비슷한 결과가 나오게 됩니다. 이 상황에서는 Mockito를 이용하여 SparseArray 의 mock 객체를 넘겨주어 indexOfValue() 메소드에 대해 처리해 주어야 할 것 입니다.

이야기가 너무 길어 졌는데, 위와 같은 상황에서 Robolectric을 이용하면 안드로이드 프레임워크 API에 대한 mock을 만들 필요가 없다는 것입니다.

Robolectric은 안드로이드 프레임워크를 다 제공하는가?

아닙니다. Robolectric 홈페이지의 Shadows 항목을 살펴보면 대충 다음과 같은 내용이 있습니다.

Robolectric는 실제 안드로이드 프레임워크의 코드를 포함하는 실행 환경을 만들어 동작합니다. 이 것은 당신의 테스트나 코드가 안드로드이 프레임워크의 (API를) 호출할 때, 실제 단말에서 실행되는 것과 많은 부분이 같은 경험을 얻을 수 있다는 것을 의미합니다. 하지만 여기에는 제약사항이 있습니다.

  1. Native code 는 개발 머신에서는 실행할 수 없습니다.
  2. 개발 머신에는 안드로이드 시스템 서비스가 없습니다.
  3. 안드로이드 자체에 테스트를 위한 적절한 API가 없습니다.

Robolectric은 Shadows라고 알려진 클래스들를 가지고 이런 차이를 채웁니다. 각각의 shadow는 안드로이드 OS의 대응하는 클래스들의 행동들을 수정하거나 확장할 수 있습니다. 안드로이드 클래스가 생성될 때, Robolectric은 대응하는 shadow class를 찾고, 만약 찾는다면 shadow object만들고 연결합니다.

안드로이드 개발자라면 1번, 2번은 대충 이해가 갈 것 입니다. 3번이 의미하는 것은 (제가 이해한 바로는) Verification을 위한 API가 충분하지 않다는 것입니다.

1번 항목은 저도 잘 모르는 부분으로 넘어가고, 2번과 3번에 대해서 이야기 하기 위해 제가 최근에 작업 중인 테스트를 위한 BLE Emulator에서 일부 코드를 가져와 봤습니다.

@RunWith(RobolectricTestRunner.class)
@Config(sdk = Build.VERSION_CODES.P)
public class MyBleTest {
    @Before
    public void setUp() throws Exception {
        context = RuntimeEnvironment.systemContext;
        bluetoothManager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
        .
        .
    }

위와 같이 블루투스 시스템 서비스를 가져오려 하면

java.lang.IllegalArgumentException: context not associated with any application (using a mock context?)

이런 에러가 발생합니다. 아래 코드처럼, 우리는 이 간극을 Shadows로 메워야 합니다.

@Config(sdk = Build.VERSION_CODES.P,
        shadows = {ShadowBluetoothManager.class})

하지만 테스트를 돌려보면 잘 되진 않습니다. Robolectric에 포함된 Shadows가 완전하진 않기 때문입니다. 물론 잘 동작하는 Shadows도 있습니다.

하지만 BluetoothManager는 아닙니다. 그래서 우리는 직접 필요한 Shadow를 작성해야 합니다. 아예 새로운 클래스를 만들던, 기존 Shadow를 extends 하던 말이죠.

Robolectric 4.4 기준으로 ShadowBluetoothManager를 보면 그다지 작업이 된 것이 없습니다. 그래서 아예 새로 만들어 보도록 하겠습니다. 당장 필요한 메소드는 생성자 부분입니다.

@Implements(BluetoothManager.class) // Corresponding to BluetoothManager
public class ShadowBluetoothManager {
   private Context context;

   @Implementation // Manadatory annotation to corresponding to real method
   protected void __constructor__(Context context) {
       this.context = context;
   }
}

위와 같이 Shadows를 만든 후 테스트를 수행해 보면, 블루투스 시스템 서비스를 잘 가져오게 됩니다. 이런 식으로 Shadows를 작성을 해 주어야 하지요.

Shadows작성을 정성들여 하셨나요? 그러면 Contribution도 고려해 보세요. Robolectric 홈페이지를 가보면, Shadows 항목은 CONTRIBUTING 항목 아래에 있습니다. 내용의 마지막에도 '공동체를 지원해 주세요’라고 이야기 하고 있고요

자 이제 마지막입니다. 위에서 언급한 제약 사항 3번에 관한 것입니다. 테스트가 성공했는지 판단하기 위해서는 Verification과정이 필요한데, 안드로이드 프레임워크에는 이것이 부족합니다. Shadows에 이 Verification을 위한 메소드를 추가하는 것입니다.

설명을 위해서 여기에서는 간단하게 context가 초기화가 잘 되었는지 확인해 보도록 하겠습니다. BluetoothManager에는 getContext()라는 API가 없지요. 그래서 우리는 아래와 같이 추가를 해 주겠습니다.

(Shadow 클래스)
public class ShadowBluetoothManager {
    .
    .
    public Context getContext() {
        return context;
    }
}

(테스트 클래스)
    ShadowBluetoothManager shadowBluetoothManager = (ShadowBluetoothManager) Shadow.extract(bluetoothManager);
    assertEquals(context, shadowBluetoothManager.getContext());

자 이렇게 Verification을 위한 method를 Shadow 클래스에 추가하고, 이것을 어떻게 호출하는지 살펴보았습니다.

예전에는 Robolectric보다는 Mockito를 더 선호하였으나, 이제는 안드로이드 유닛 테스트 프레임워크로 Robolectric을 충분히 고려할 수 있을 않을까 생각해 봅니다.

크리에이티브 커먼즈 라이선스
이 저작물은 크리에이티브 커먼즈 저작자표시-동일조건변경허락 4.0 국제 라이선스에 따라 이용할 수 있습니다.

Written with StackEdit.

댓글

이 블로그의 인기 게시물

[게임개발 스토리] 장르/타입/조합 정보와 몇 가지 팁

[윈도우] 실행 중인 프로그램의 타이틀을 변경하는 유틸리티

Synergy 한글키 패치 공식 버전 적용 및 최종 정리