Espresso

Testowanie

  |   15 min czytania
(Espresso v3.1, UI Automator v2.2)

Testy UI

Testy intregracyjne są przeprowadzane w celu wykrycia błędów zachodzących w interakcji pomiędzy integrowanymi interfejsami systemu i polegają na testowaniu reakcji na wywołane zdarzenia oraz wymianie danych między testowanymi elementami. Wykonywane są na wielu poziomach testowania i dotyczą całego obszaru integracji (funkcjonalności, moduły, systemy). Realizacją testów integracyjnych w Android są testy UI (testy interfejsu użytkownika) polegające na przeprowadzeniu zestawu ciągu operacji w docelowej aplikacji z punktu widzenia użytkownika. Ręczne testy przeprowadzane przez testera choć ważne są jednak obciążone sporym nakładem czasowym i podatne na błędy, dlatego warto dążyć do automatyzacji testów UI. Instrumentalne automatyczne testy interfejsu użytkownika mogą dotyczyć jednej aplikacji lub interakcji między aplikacjami czy też aplikacją i systemem. Przykładem biblioteki przeznaczonej do testów UI obejmujących jedną aplikację jest Espresso, a dla interakcji między aplikacjami i systemem np. UI Automator. Jako alternatywę można rozważyć biblioteki Robotium lub Appium. Interfejs użytkownika może być również testowany pod względem wydajności dzięki takim narzędziom jak dumbsys i systrace.

Charakterystyka

Testy jednej aplikacji weryfikują zachowanie docelowej aplikacji w stosunku do przeprowadzonych ustalonych działań lub wprowadzenia danych w ekranie aplikacji (Aktywności) przez użytkownika. Espresso w sposób automatyczny umożliwia przeprowadzanie symulacji akcji użytkownika w aplikacji oraz sprawdzenie oczekiwanego rezultatu po stronie UI co może przełożyć się na zwiększenie jakości User Experience. Udostępnione podstawowe API jest niewielkie, proste, intuicyjne i oparte jest o interakcję oraz asercje stanów widoków. Przeznaczone jest do operowania na konkretnych widokach oraz wybranych elementach kolekcji widoków. Espresso działa szybko i zapewnia właściwe zarządzanie wątkiem głównym dzięki czemu uruchamia polecenia testowe we właściwym czasie co zwalnia programistę z obowiązku tworzenia tymczasowych obejść. Aby uniknąć nieoczekiwanych rezultatów i błędów w przeprowadzanych testach rekomendowane jest wyłączenie (na testowanym urządzeniu fizycznym lub wirtualnym) animacji systemowych dostępnych w opcjach programisty (animacje okien, animacje przejścia, czas animacji).

Przykład
Na podstawie Aktywności MainActivity zostaną przedstawione możliwości implementacji automatycznych testów UI w Espresso.

class MainActivity : AppCompatActivity() {

    companion object {
        const val NAME = "NAME"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        buttonAction.setOnClickListener {
            val text = editTextName.text.toString()
            if(text.equals("hide"))
                textViewName.visibility = INVISIBLE
            else {
                textViewName.visibility = VISIBLE
                textViewName.text = text
            }
        }

        buttonNavigate.setOnClickListener {
            val intent = Intent(this, SecondActivity::class.java)
            intent.putExtra(NAME, editTextName.text.toString())
            startActivity(intent)
        }

        buttonSms.setOnClickListener {
            val sendIntent = Intent(Intent.ACTION_VIEW)
            sendIntent.data = Uri.parse("sms:")
            sendIntent.putExtra("address", "111222333")
            sendIntent.putExtra("sms_body", textViewName.text)
            startActivity(sendIntent)
        }

        initListView()
    }

    private fun initListView() {
        //ListView with dummy adapter can be replaced by some RecyclerView
        val items: List<String> = listOf("name1", "name2", "name3")
        val adapter: ArrayAdapter<String> = ArrayAdapter(this, android.R.layout.simple_list_item_1, items)
        listViewNames.adapter = adapter
        listViewNames.setOnItemClickListener { parent, view, position, id ->  textViewName.text = adapter.getItem(position)}
    }
}

Metoda testowa

Wykorzystanie zasady ActivityTestRule w klasie testowej sprawia, że aktywność jest uruchamiana przed startem każdego testu i zamykana po jego zakończeniu, redukując tym samym ilość powtarzającego się kodu co w przypadku testów Espresso jest znacznym uproszczeniem. Metoda testowa w Espresso przeprowadza operacje na widokach aktywności. Najpierw uzyskuje dostęp do komponentu interfejsu graficznego, następnie symuluje interakcje użytkownika z widokiem i na końcu weryfikuje jego stan z oczekiwanym. Aby jeszcze bardziej uprościć tworzenie testów warto wykorzystać Hamcrest, który oferuję elastyczną składnie zapytań.

@RunWith(AndroidJUnit4::class)
class EspressoTest {

    @get:Rule
    var activityRule: ActivityTestRule<MainActivity> = ActivityTestRule(MainActivity::class.java)
    
    @Test
    fun changeTextInTextViewFromEditText() {
        onView(withId(R.id.editTextName)) //find UI component
            .perform(typeText("value")) //do action

        onView(withId(R.id.buttonAction)) //find UI component
            .perform(click()) //do action

        onView(withId(R.id.textViewName)) //find UI component
            .check(matches(withText("value"))) //verify
    }
}

Dostęp do widoków

Aby znaleźć widok należy wywołać onView lub dla kolekcji widoków onData oraz przekazać zapytanie (matcher) wskazujące na oczekiwany widok np. na podstawie id (withId), zawartości (withText, containsString) czy typu (instanceOf). W rezultacie zostanie zwrócony obiekt typu ViewInteraction lub DataInteraction (dla kolekcji widoków), który umożliwia przeprowadzenie interakcji z widokiem. Trzeba mieć jednak na uwadze, że Android nie gwarantuje unikatowych ID elementów co w przypadku sytuacji używania tego samego ID przez kilka różnych widoków może powodować problemy (wyrzucenie wyjątku AmbiguousViewMatcherException). W takich sytuacjach warto wykorzystywać kombinacje dopasowań za pomocą metody allOf. W przypadku kolekcji widoków takich jak np. ListView nie ma pewności uzyskania żądanego widoku za pomocą onView (tylko część elementów jest widoczna) dlatego dostęp powinien odbywać się poprzez dane za pomocą onData. Espresso potrafi również poradzić sobie widokami typu WebView.

@RunWith(AndroidJUnit4::class)
class AccessViewTest {

    @get:Rule
    var activityRule: ActivityTestRule<MainActivity> = ActivityTestRule(MainActivity::class.java)
    
    @Test
    fun changeTextFromViewAccess() {
        onView(allOf(withId(R.id.editTextName), instanceOf(EditText::class.java))) //combine id with type
            .perform(typeText("value")) //do action

        onView(withText("action")) //only one matcher
            .perform(click())

        onView(withId(R.id.textViewName)) //only one matcher
            .check(matches(withText("value")))
    }

    @Test
    fun changeTextFromAdapterViewAccess() {
        onData(anything())
            .inAdapterView(withId(R.id.listViewNames))
            .atPosition(0)
            .perform(click())

        onView(withId(R.id.textViewName))
            .check(matches(withText("name1")))
    }
}

Przeprowadzenie akcji

Uruchomienie akcji następuje poprzez wyołanie metody perform klasy ViewInteraction lub DataInteraction w zależności od rodzaju elementu oraz przekazanie obiektów typu ViewAction jako argumentów. Akcje jakie można wykonać to m.in. click, typeText, scrollTo, pressKey, clearText. Ponadto jeśli podejmowana akcja dotyczy intencji (Intent) można wykorzystać zasadę IntentsTestRule, która waliduję intencje wysłane przez testowane aplikację oraz metody weryfikujące intended dla startActivity i intending dla startActivityForResult.

@RunWith(AndroidJUnit4::class)
class ActionViewTest {

    @get:Rule
    var intentsRule: IntentsTestRule<MainActivity> = IntentsTestRule(MainActivity::class.java)
    
    @Test
    fun verifyNavigateToSecondActivityWithMessage() {
        onView(withId(R.id.editTextName)).perform(typeText("value")) 
        onView(withId(R.id.buttonNavigate)).perform(closeSoftKeyboard(), click())

        intended(allOf(
                hasComponent(hasShortClassName(".SecondActivity")),
                toPackage("pl.androidcode.espresso"),
                hasExtra(MainActivity.NAME, "value")))
    }
}

Weryfikacja rezultatu

Sprawdzanie stanu widoków następuje poprzez wyołanie metody check klasy ViewInteraction lub DataInteraction w zależności od rodzaju elementu oraz przekazanie obiektów typu ViewAssertion jako argumentów. Asercje jakie można wykonać to m.in. doesNotExist, matches, selectedDescendentsMatch.

@RunWith(AndroidJUnit4::class)
class VerifyTest {

    @get:Rule
    var activityRule: ActivityTestRule<MainActivity> = ActivityTestRule(MainActivity::class.java)
    
    @Test
    fun verifyHideTextViewWhenHideTextTyped() {
        //actions
        onView(withId(R.id.editTextName)).perform(typeText("hide"))
        onView(withId(R.id.buttonAction)).perform(click())

        //verify
        onView(withId(R.id.textViewName)).check(matches(not(isDisplayed())))
    }
}

UI Automator

Testy przeprowadzane w UI Automator pozwalają na weryfikację zachowania testowanej w aplikacji w interakcjach z innymi aplikacjami lub komponentami systemu. Przebiegają wg podobnego scenariusza co testy w Espresso, tzn. znajdowany jest widok, wykonywane są akcje i finalnie następuje sprawdzenie poprawności oczekiwanego stanu. Na wstępie jednak trzeba uzyskać dostęp do testowanego urządzenia poprzez instancję UiDevice dzięki czemu możliwe jest znajdowanie i operowanie na widokach oraz manipulowanie stanem urządzania. Dobrą praktyką jest rozpoczynanie testów od głównego ekranu systemowego.

@RunWith(AndroidJUnit4::class)
class UIAutomatorTest {

    private val APP_PACKAGE = "pl.androidcode.espresso"
    private val SMS_APP_PACKAGE = "com.google.android.apps.messaging"

    private lateinit var device: UiDevice

    @Before
    fun startMainActivityFromHomeScreen() {
        device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) //initialize
        device.pressHome() //start every test from home screen
        deviceWait(device.launcherPackageName) //wait for launcher

        //start the app
        val context = ApplicationProvider.getApplicationContext<Context>()
        val intent = context.packageManager.getLaunchIntentForPackage(APP_PACKAGE)
            .apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) }
        context.startActivity(intent)
        deviceWait(APP_PACKAGE) //wait for app
    }

    private fun deviceWait(pkg: String) {
        device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 5000L)
    }

    //some tests
}

Aby otrzymać dostęp do komponentów widoków aplikacji (widocznych na ekranie) należy uzyskać instancję typu UiObject wywołując metodę findObject na obiekcie UiDevice przekazując jako argument odpowiedni UiSelector konstruowany na podstawie m.in. id (resourceId), zawartości (text) czy nazwy klasy obiektu widoku (className). O ile komplet informacji nt widoków testowanej aplikacji dostępny jest z poziomu kodu to w przypadku zewnętrznych aplikacji i komponentów systemu już niekoniecznie. W takim przypadku można posłużyć się narzędziem UI Automator Viewer dostępnym w Android Studio. Akcje jakie można wykonać to m.in. click, dragTo, setText, swipeUp.

@Test
fun verifyTypedMessageFromEditText() {
    //find views by some selectors (known from you app code)
    val editText = device.findObject(UiSelector().resourceId("${APP_PACKAGE}:id/editTextName"))
    val buttonAction = device.findObject(UiSelector().text("ACTION").className("android.widget.Button"))
    val buttonSms = device.findObject(UiSelector().text("SMS").className("android.widget.Button"))

    if(editText.exists() && buttonAction.exists() && buttonSms.exists()) {
        //do actions
        editText.setText("message")
        buttonAction.click()
        buttonSms.click()
        deviceWait(SMS_APP_PACKAGE) //wait for expected sms app

        //find view by some selectors (known from UiAutomator Viewer)
        val messageField = device.findObject(UiSelector().resourceId("${SMS_APP_PACKAGE}:id/compose_message_text"))
        Assert.assertEquals("message", messageField.text)
    }
    else throw UiObjectNotFoundException("Throw when no matching UI element is found")
}

Symulowanie interakcji z elementami kolekcji lub komponentami typu scrollable może odbywać się przy użyciu klas UiCollection i UiScrollable.

/*instead of findObject on UiDevice like below UiCollection and UiScrollable can be used
val listViewItem: UiObject = device.findObject(UiSelector().className("android.widget.ListView")
    .instance(0).childSelector(UiSelector().text("name1")))*/

@Test
fun verifyPassedMessageFromCollection() {
    val buttonSms = device.findObject(UiSelector().text("SMS").className("android.widget.Button"))
    val collection: UiCollection = UiCollection(UiSelector().className("android.widget.ListView"))
    val listViewItem: UiObject = collection.getChild(UiSelector().text("name1"))

    if(listViewItem.exists() && buttonSms.exists()) {
        listViewItem.click()
        buttonSms.click()
        deviceWait(SMS_APP_PACKAGE) //wait for expected sms app

        val messageField = device.findObject(UiSelector().resourceId("${SMS_APP_PACKAGE}:id/compose_message_text"))
        Assert.assertEquals("name1", messageField.text)
    }
    else throw UiObjectNotFoundException("Throw when no matching UI element is found")
}

@Test
fun verifyPassedMessageFromScrollable() {
    val buttonSms = device.findObject(UiSelector().text("SMS").className("android.widget.Button"))
    val scrollable = UiScrollable(UiSelector().className("android.widget.ListView"))

    if(buttonSms.exists()) {
        scrollable.getChildByText(UiSelector().className("android.widget.TextView"), "name1")
        buttonSms.click()
        deviceWait(SMS_APP_PACKAGE) //wait for expected sms app

        val messageField = device.findObject(UiSelector().resourceId("${SMS_APP_PACKAGE}:id/compose_message_text"))
        Assert.assertEquals("name1", messageField.text)
    }
    else throw UiObjectNotFoundException("Throw when no matching UI element is found")
}

Android Test Orchestrator

Wykonywanie testów interfejsu użytkownika w Android od czasu do czasu narażone jest na występowanie błędów z niespodziewanym zatrzymanie całego zestawu testowego oraz ze wzajemnym nakrywaniem się testów (np. test się zakończył, ale żądane operacje w tle nadal trwa co może mieć wpływ na kolejne testy). Wykorzystanie mechanizmu bezczynnych zasobów (idling resources) pozwala w kontrolowany sposób przeprowadzić operacje asynchroniczną, której wyniki wpływają na kolejne operacje w teście co jednak nie rozwiązuje całkowicie wspomnianych problemów. Z pomocą przychodzi narzędzie Android Test Orchestrator, które umożliwia wykonanie każdego testu na własnej odseparowanej instancji AndroidJUnitRunner.

Espresso Test Recorder

Narzędzie Espresso Test Recorder umożliwia tworzenie testów UI w Espresso poprzez rejestrowanie akcji oraz tworzenie asercji w graficznym panelu bez ręcznego pisania kodu. Aby nagrać test należy w Android Studio wybrać opcję Run/Record Espresso Test, uruchomić aplikację na wskazanym urządzeniu lub emulatorze oraz ręcznie wykonać akcje użytkownika w aplikacji dodając asercje dla stanu widoków. Nagrany test można zapisać do nowej klasy testowej i uruchomić tak samo jak każdy test instrumentalny. Tworzenie testów przy pomocy Espresso Test Recorder jest powolne, a asercje ograniczone do weryfikowania tekstu i istnienia widoku.

Nagrywanie testu