HOME > android > basic

Robolectric으로 Unit Test 작성하기 (kotlin)

By JS | 11 Jan 2020

Robolectric은 Android 코드를 JVM에서 Unit test하는데 사용하는 테스팅 프레임워크입니다. 에뮬레이터나, 디바이스에서 직접 테스트하지 않기 때문에 속도가 빠릅니다. (하지만, 많이 빠르지 않기 때문에 실제 디바이스에서 테스트하는 나을 수도 있습니다.)

Android Studio의 Unit test도 JVM에서 테스트를 수행하지만, Android SDK는 아무 동작도 하지 않는 stub, android.jar를 사용합니다. 이 stub은 아무 동작도 하지 않고, null이나 0을 리턴합니다. 그렇기 때문에 필요한 것은 Mockito 등으로 Mock을 만들어서 테스트를 해야 합니다.

반면에, Robolectric은 Shadow라는 개념이 있습니다. Shadow는 Mock은 아니지만 Mock과 유사하게, 어떤 의도된 동작을 수행하는 테스트 더블입니다. Robolectric는 Android SDK의 클래스의 Shadow를 구현하였습니다. 이 Shadow들을 사용하여 테스트 코드를 작성하면 디바이스에 동작하는 것처럼 결과를 출력할 수 있습니다. 그렇기 때문에 Mock을 불필요하게 많이 작성하지 않아도 됩니다. 또한, Custom shadow가 필요하다면 직접 만들 수도 있습니다.

Android Studio에서 Robolectric을 사용하여 Unit test를 작성하는 방법에 대해서 알아보겠습니다.

이 글의 코드는 모두 Kotlin으로 작성되었습니다.

프로젝트 Setup

app의 gradle에 다음과 같이 의존성과, testOptions를 추가합니다.

android {
  testOptions {
    unitTests {
      includeAndroidResources = true
    }
  }
}

dependencies {
    testImplementation 'org.robolectric:robolectric:4.3.1'
}

Android Studio 3.3 미만의 버전에는 gradle.properties에 다음을 추가합니다.

android.enableUnitTestBinaryResources=true

프로젝트를 보시면, src/아래는 다음과 같습니다. 여기서 test/가 JVM에서 동작하는 Unit test입니다.

├── androidTest
├── main
└── test
    └── java
        └── com
            └── codechacha
                └── robolectric
                    └── ExampleUnitTest.kt

ExampleUnitTest.kt 파일에서 다음과 같이 annotation을 설정합니다.

@RunWith(RobolectricTestRunner::class)
class ExampleUnitTest {
  ...
}

Android는 Robolectric test 코드가 Unit이 아닌, Instrumentation test에서도 동작할 수 있도록 노력하고 있습니다. 그래서 아래와 같이 AndroidJUnit4::class를 사용해도 동작합니다.

@RunWith(AndroidJUnit4::class)
class ExampleUnitTest {
  ...
}

이제 프로젝트에서 Robolectric으로 Unit test를 작성할 수 있습니다.

JDK 설정

Robolectric는 SDK API 29에 대해서 Java 9를 요구합니다.

[File] => [Project Structures]SDK Location에서 JDK를 Java9로 변경해줘야 합니다. java9

SDK API 29 미만의 경우 Java 9으로 설정하지 않아도 컴파일됩니다.

앱 구현

테스트 코드를 작성하기 전에 먼저 간단한 앱을 구현해야 합니다.

다음과 같이 구현하였습니다.

MainActivity.kt

class MainActivity : AppCompatActivity() {
    companion object {
        const val TAG = "MainActivity"
    }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        Log.d(TAG, "onCreate")

        mainButton.setOnClickListener {
            mainTextView.text = "Hello world!"
        }
    }

    override fun onResume() {
        Log.d(TAG, "onResume")
        super.onResume()
    }

    override fun onPause() {
        Log.d(TAG, "onPause")
        super.onPause()
    }
}

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
    <Button
            android:id="@+id/mainButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Hello World!"/>
    <TextView
            android:id="@+id/mainTextView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Not set"/>
    <TextView
            android:id="@+id/helloTextView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/hello"/>
</LinearLayout>

res/values/strings.xml

<resources>
    <string name="app_name">Robolectric</string>
    <string name="hello">Hello</string>
</resources>

res/values-ko/strings.xml

<resources>
    <string name="hello">안녕하세요</string>
</resources>

테스트 코드 작성 (로그 출력)

이제 위에서 구현한 앱을 테스트하는 코드를 작성할 수 있습니다.

먼저 ExampleUnitTest.kt에 다음과 같이 입력해주세요.

class ExampleUnitTest {
    @Before
    fun setup() {
        ShadowLog.stream = System.out;
    }
}

위의 코드는 Log.d()와 같이, 디바이스에서 로그를 출력하는 코드를 JVM에서도 동작하게 만드는 것입니다. 이렇게 설정하지 않으면 Log.d()는 로그를 출력하지 않습니다.

TextView 테스트

다음 코드는 Activity의 TextView의 초기값을 확인하는 코드입니다.

@Test
fun textView_text_is_right() {
    val activity = Robolectric.setupActivity(
          MainActivity::class.java)  // 1
    assertEquals("Not set", activity.mainTextView.text)   // 2
}
  1. Robolectric.setupActivity()는 Activity를 초기화합니다. 내부적으로 onCreate(), onResume() 등이 호출됩니다. 실제 Activity 코드가 동작하지 않고, Robolectric에서 만든 Shadow Activity 코드가 동작합니다.
  2. Activity의 TextView를 가져오고 text의 초기값을 확인합니다.

Shadow는 실제 코드와 유사하게 동작하는 것 같습니다. 하지만, 내부적으로 Shadow가 어떻게 구현되었는지 모르기 때문에 에뮬레이터나 디바이스에서도 테스트 결과가 동일할까? 라는 생각이 계속 드네요.

Button 테스트

앱의 MainActivity.kt에서 다음과 같이 버튼이 눌리면 text를 변경하도록 구현했었습니다.

mainButton.setOnClickListener {
    mainTextView.text = "Hello world!"
}

다음 코드는 Button이 눌렸을 때 text가 변경되었는지 확인하는 테스트입니다.

@Test
fun textView_when_click_button() {
    val activity = Robolectric.setupActivity(MainActivity::class.java)
    assertEquals("Not set", activity.mainTextView.text)

    activity.mainButton.performClick()  // 1
    assertEquals("Hello world!", activity.mainTextView.text)  // 2
}
  1. Button 클릭
  2. text가 변경되었는지 확인

ActivityController

위에서 Activity를 만들 때 Robolectric.setupActivity()를 사용했었습니다. 이 코드는 Activity를 만들어주지만, Lifecycle을 마음대로 변경할 수 없습니다.

다음 코드는 ActivityController를 이용하여 Lifecycle을 직접 변경하는 코드입니다.

@Test
fun textView_text_is_right2() {
    val controller: ActivityController<MainActivity> =
            Robolectric.buildActivity(MainActivity::class.java)   // 1
    val activity = controller   // 2
            .create()
            .start()
            .resume()
            .visible()
            .get()

    assertEquals("Not set", activity.mainTextView.text)   // 3
    activity.mainButton.performClick()
    assertEquals("Hello world!", activity.mainTextView.text)

    controller    // 4
        .pause()
        .stop()
        .destroy()
}
  1. ActivityController 생성
  2. ActivityController의 Lifecycle을 변경하고, get()으로 Activity 리턴
  3. text 확인
  4. Activity의 Lifecycle을 destroy로 변경

Lifecycle이 변경될 때마다, Activity의 onResume(), onPause() 등에 넣은 로그가 출력됩니다.

Activity 재실행 테스트

Activity가 다시 실행되는 케이스를 테스트하고 싶을 수 있습니다.

다음과 같이 Activity Lifecycle을 변경하고 테스트 코드를 작성하면 됩니다.

@Test
fun recreatesActivity() {
    val bundle = Bundle()

    var controller: ActivityController<MainActivity> =
            Robolectric.buildActivity(MainActivity::class.java)
    controller
        .create()
        .start()
        .resume()
        .visible()
        .get()

    // Destroy the original activity
    controller
        .saveInstanceState(bundle)
        .pause()
        .stop()
        .destroy()

    // Bring up a new activity
    controller = Robolectric.buildActivity(MainActivity::class.java)
        .create(bundle)
        .start()
        .restoreInstanceState(bundle)
        .resume()
        .visible()

    val activity = controller.get()

    // ... add assertions ...
}

다국어 테스트

다양한 Locale에 대해서 테스트할 수 있습니다.

다음 코드처럼 @Config annotation에 qualifiers로 locale을 설정할 수 있습니다.

@Test
@Config(qualifiers = "en")
fun localizedEnglishHello() {
    val activity = Robolectric.setupActivity(MainActivity::class.java)
    assertEquals(activity.helloTextView.text.toString(), "Hello")
}

@Test
@Config(qualifiers = "ko")
fun localizedKoreanHello() {
    val activity = Robolectric.setupActivity(MainActivity::class.java)
    assertEquals(activity.helloTextView.text.toString(), "안녕하세요")
}

참고