HOME > android > jetpack

Android Espresso를 사용하여 UI를 테스트하는 방법 (1)

By JS | 14 Nov 2018

Espresso는 안드로이드 UI를 테스트하는데 도움을 주는 라이브러리인데 AndroidX와 함께 refactoring되었습니다. 에스프레소를 이용하여 어떻게 UI를 테스트하는지 간단히 알아보겠습니다. 샘플 코드는 Kotlin으로 작성되었으며 AndroidX를 사용하였습니다.

AndroidX는 Android Support Library의 refactoring 버전입니다.

프로젝트 생성

Android studio에서 Basic Activity로 안드로이드 프로젝트를 생성합니다.

Android espresso

KotlinUse AndroidX artifacts를 선택해주세요.(Android Studio 버전이 3.4 미만이라면 이 기능이 없습니다. 마이그레이션 기능을 이용하거나 직접 의존성을 변경해줘야 합니다)

AndroidX로 프로젝트를 생성하지 않았다면, 메뉴에서 [Refactor] -> [Migrate to AndroidX...] 를 누르시면 AndroidX를 사용하는 프로젝트로 마이그레이션이 됩니다.

간단한 앱 만들기

동작하는 앱을 만들어야 테스트 코드를 짜겠죠? 여기서는 간단히 국가 이름(korea)을 입력하면 Locale(ko-KR)을 보여주는 앱을 만들었습니다. 앱은 GitHub에 있습니다. 코드를 다운받아 빌드해보세요. 테스트 코드 작성에 대한 설명이니 앱 구현에 대한 설명은 자세히 하지 않겠습니다.

AndroidX 패키지로 변경되면서, layout xml파일에서 참조하는 View의 이름이 조금씩 변경되었습니다. 프로젝트의 앱 gradle dependency를 보면 androidxmaterial 패키지들로, 테스트는 androidx.test패키지들로 관리되고 있습니다.

// app dependency
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2'
implementation 'com.google.android.material:material:1.1.0-alpha01'

// test dependency
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.1.0'
androidTestImplementation 'androidx.test:rules:1.1.0'
androidTestImplementation 'androidx.test:core:1.0.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0'
androidTestImplementation 'androidx.test.ext:junit:1.0.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.1.0'

앱의 UI는 MainActivity와 SecondActivity가 있습니다. 메인 화면(MainActivity)에서 우측 하단의 FAB 버튼을 누르시면 LocaleActivity가 실행되고 ko_kr이라는 텍스트가 나옵니다.

MainActivity에서 FAB 버튼을 누를 때, EXTRA_COUNTRY에 국가 이름(korea)을 전달합니다.

fab.setOnClickListener { view ->
    val intent = Intent(this, LocaleActivity::class.java)
    intent.putExtra(LocaleActivity.EXTRA_COUNTRY, getString(R.string.country_korea))
    startActivityForResult(intent, REQUEST_CODE_LOCALE)
}

LocaleActivity에서 intent를 통해 국가 이름(korea)을 받으면 Locale을 찾아 화면에 보여줍니다. 그리고 확인 버튼을 누르면 setResult()로 MainActivity에 결과를 전달합니다.

val country = intent.getStringExtra(EXTRA_COUNTRY)
if (country == null) {
    tvLocale.text = getString(R.string.no_country_extra)
} else {
    val localeStr: String = when (country.toLowerCase()) {
        "korea" -> getString(R.string.locale_korea)
        "japan" -> getString(R.string.locale_japan)
        "china" -> getString(R.string.locale_china)
        else -> getString(R.string.unknown_country, country)
    }
    tvLocale.text = localeStr
}

btnOk.setOnClickListener {view ->
    val result = Intent()
    result.putExtra(EXTRA_LOCALE, tvLocale.text)
    setResult(Activity.RESULT_OK, result)
    finish()
}

AndroidX espresso

MainActivity는 LocaleActivity로부터 Result를 받고 화면에 결과를 보여줍니다.

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    when (requestCode) {
        REQUEST_CODE_LOCALE -> {
            val locale = data?.getStringExtra(LocaleActivity.EXTRA_LOCALE)
            tvResultLocale.text = locale
        }
    }
}

AndroidX espresso

SecondActivity도 MainActivity와 거의 동일합니다. 차이점은 EditText에 국가이름을 직접 입력하고 그 이름에 대한 Locale을 찾는 Activity입니다.

테스트 코드 작성

아래 화면처럼 테스트 코드는 프로젝트의 /src/androidTest/ 폴더에 존재합니다. 여기에 LocalActivityTest.kt, MainActivityTest.kt, SecondActivityTest.kt를 생성합니다.

AndroidX espresso

LocaleActivity에 대한 UI 테스트 코드를 먼저 작성해보겠습니다. 아래 코드처럼 입력하면 기본적인 테스트는 만들어졌습니다. @RunWith(AndroidJUnit4::class)는 Junit4를 사용하기 위해 꼭 써줘야 하고, @LargeTest는 테스트의 범위를 말합니다. (Developers에 간단한 설명이 있습니다.)

@Rule은 ActivityTestRule을 정의할 때 붙여줍니다. Activity의 UI 테스트를 하는데 도움을 주는 객체입니다. @JvmField은 kotlin var객체를 만들 때 getter/setter를 자동으로 생성하지 않고 사용하겠다는 의미입니다. @Test은 테스트 코드 앞에 써줍니다. AndroidJUnitRunner가 이 annotation이 붙은 method가 test method라고 인식하고 이것들만 실행해줍니다.

package com.codechacha.espresso

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
@LargeTest
class LocaleActivityTest {
    @Rule
    @JvmField
    var activityRule = ActivityTestRule(LocaleActivity::class.java)

    @Test
    fun noCountryExtra() {

    }
}

중요한 것은 App gradle의 testInstrumentationRunner를 꼭 androidx.test.runner.AndroidJUnitRunner로 설정해야합니다. AndroidX 패키지로 변경되면서 class name이 변경되었기 때문에 이 부분을 변경해주어야 합니다.

android {
    compileSdkVersion 28
    defaultConfig {
        applicationId "com.codechacha.espresso"
        ...
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

첫번째 테스트

저희가 만든 테스트 코드 noCountryExtra()에는 아무 내용이 없습니다. 코드를 채워 첫번째 테스트를 완성해보겠습니다. 이전에 ActivityRule를 생성할 때 LocaleActivity를 인자로 입력하였는데요. LocaleActivity에 대한 UI 테스트를 하는데 필요한 Rule을 생성한 것입니다.

먼저 아래 코드와 같이 입력해주세요. 테스트 코드의 의도는 LocaleActivity로 국가이름이 전달되지 않았을 때 예외처리가 잘 되었는지 확인하는 것입니다. 여기서 사용된 코드에 대해서 간략히 말씀드리면...

activityRule.launchActivity(Intent())는 LocaleActivity를 실행하면서 내용이 없는 Intent를 전달하는 코드입니다.

onView(....).check(...)는 리소스 id가 R.id.tvLocale라는 view를 찾고 그 View의 text가 R.string.no_country_extra와 일치하는지 확인하는 코드입니다.

@Rule
@JvmField
var activityRule = ActivityTestRule(LocaleActivity::class.java)

@Test
fun noCountryExtra() {
    activityRule.launchActivity(Intent())
    onView(withId(R.id.tvLocale))
            .check(matches(withText(R.string.no_country_extra)))
}

앱 구현 코드를 보면, Intent에 EXTRA_COUNTRY가 없으면 R.string.no_country_extra를 보여주도록 예외처리 되어있습니다. 우리는 이 케이스가 정상적으로 동작하는지 확인을 하고 싶었습니다.

val country = intent.getStringExtra(EXTRA_COUNTRY)
if (country == null) {
    tvLocale.text = getString(R.string.no_country_extra)
}

테스트 코드를 짰으니 테스트를 Run해봅시다. Android Studio에서 function 옆에 초록색 화살표를 누르면 그 function만 테스트를 합니다. Class에 있는 모든 테스트를 하고 싶으면 Class 옆에 있는 초록색 화살표를 누르면 됩니다.

테스트 실행이 완료되면 아래처럼 결과가 나옵니다. 만약 Fail이 되었다면 테스트 코드가 잘못되거나 앱 구현이 잘못되었다는 것을 의미합니다.

AndroidX espresso

위에서 사용한 코드 withId()는 Activity에서 리소스 id에 해상하는 view가 있는지 확인하는 View Matcher입니다. Espresso가 저 Matcher와 일치하는 view가 없으면 테스트를 fail시킵니다.

withText()도 View Matcher이며 찾은 View의 text가 파라미터로 넘어온 Matcher와 동일한지 체크하는데 사용합니다. text가 일치하지 않으면 테스트가 실패하게 됩니다. (View Matcher에 대한 것은 developers를 참고해주세요)

두번째 테스트

첫번째 테스트는 데이터가 없는 Intent에 대한 예외처리를 확인하는 내용이었습니다. 두번째 테스트는 EXTRA_COUNTRY를 입력하고 의도한 결과가 출력되는지 확인해보겠습니다.

사실 지금까지 설명하지 않았지만, 테스트 코드는 우리가 구현한 앱과 다른 앱입니다. 예를들어 구현한 앱의 PackageName이 com.codechacha.espresso라면, 테스트 앱의 PackageName은 com.codechacha.espresso.test가 됩니다. 이런 세세한 부분들은 Android Studio에서 처리하기 때문에 인지하기 어려웠습니다.

어떤 앱에서 다른 앱의 Context를 가져올 수 없는데, 테스트 앱은 테스트 하려는 앱의 Context를 가져올 수 있습니다. 아래 코드처럼 InstrumentationRegistry를 통해 targetContext를 가져올 수 있습니다. 이 Context를 통해 국가이름과 예상되는 Locale string을 가져왔습니다.

그리고 Intent에 EXTRA_COUNTRY로 "korea"를 입력하였고 첫번째 테스트처럼 LocaleActivity를 실행했습니다. 그리고 Matcher에 예상되는 Locale인 "ko-kr"을 입력하였습니다.

@Test
fun countryKorea() {
    val context = InstrumentationRegistry.getInstrumentation().targetContext
    val countryKorea= context.resources.getString(R.string.country_korea)
    val expectedResult = context.resources.getString(R.string.locale_korea)

    val intent = Intent()
    intent.putExtra(LocaleActivity.EXTRA_COUNTRY, countryKorea)
    activityRule.launchActivity(intent)

    onView(withId(R.id.tvLocale))
            .check(matches(withText(expectedResult)))
}

테스트를 Run해보세요. 큰 문제 없다면 Pass되었을 것입니다. 정리하면, 이번 테스트는 korea가 입력되었을 떄 'ko-kr'이 리턴되는지 확인하는 테스트였습니다.

세번째 테스트

만약 Intent에 어떤 text(국가명)를 입력하더라도 "ko-kr"이 리턴되는 버그가 있을 수도 있습니다. 혹시 모르니 국가명을 "china"로 변경하여 테스트해보았습니다. 예상되는 Locale은 "zh_cn"입니다.

@Test
fun countryChina() {
    val context = InstrumentationRegistry.getInstrumentation().targetContext
    val countryChina = context.resources.getString(R.string.country_china)
    val expectedResult = context.resources.getString(R.string.locale_china)

    val intent = Intent()
    intent.putExtra(LocaleActivity.EXTRA_COUNTRY, countryChina)
    activityRule.launchActivity(intent)

    onView(withId(R.id.tvLocale))
            .check(matches(withText(expectedResult)))
}

Run해보세요. 저는 Pass되었습니다. 앱에서 구현한 로직에 큰 문제는 없어보이네요.

네번째 테스트

사실 또 말씀드리지 않았지만, 저희가 구현한 앱은 korea, japan, china에 대해서만 Locale을 찾아줍니다. 누군가 france에 대해 Locale을 찾으려 할 수 있습니다. 그래서 지원하지 않는 국가에 대해서는 Unknown country %1$s라는 문자열을 보여주도록 구현하였습니다.

국가명에 "france"를, 예상되는 결과에 "Unknown country france"를 설정하였습니다. Run해보면 Pass되네요. 예외처리가 잘 된 것을 확인할 수 있었습니다.

@Test
fun unknownCountry() {
    val context = InstrumentationRegistry.getInstrumentation().targetContext
    val countryFrance = "france"
    val expectedResult = context.resources.getString(R.string.unknown_country, countryFrance)

    val intent = Intent()
    intent.putExtra(LocaleActivity.EXTRA_COUNTRY, countryFrance)
    activityRule.launchActivity(intent)

    onView(withId(R.id.tvLocale))
            .check(matches(withText(expectedResult)))
}

정리

입력에 따라 결과가 달라지는 LocaleActivity에 대한 테스트 코드를 작성해보았습니다. 기억해야 할 내용을 간단히 정리하였습니다.

  • 테스트 앱은 구현한 앱과 다른 PackageName을 갖습니다. 그래서 테스트하려는 앱의 리소스를 접근하려면 targetContext를 통해 접근해야 합니다.
  • Activity를 테스트하려면 ActivityTestRule을 정의해야 합니다.
  • 실행 중인 View를 테스트하려면 onView()와 Matcher를 이용해야 합니다.

MainActivity에 대한 테스트 코드는 다음 글Android Espresso를 사용하여 UI를 테스트하는 방법 (2)을 참고해주세요.

참고