Skip to main content

Pred pár mesiacmi sme dostali za úlohu zmeniť vizuál jednej z našich aplikácií a popritom urobiť refaktoring zdrojového kódu. Kvôli veľkosti aplikácie sme sa rozhodli pre postupný refaktoring od backendu a biznisovej logiky až k obrazovkám.

Nový kód sme prirodzene písali v jazyku Kotlin, ktorý sa medzičasom stal hlavným programovacím jazykom pre písanie androidových aplikácií. Ukázalo sa, že to bola dobrá voľba, keďže Kotlin je veľmi výstižný a programátorsky orientovaný jazyk.

Naša appka je nasadená v produkcii už niekoľko rokov. Za ten čas do nej pribudlo množstvo funkcionalít, preto sme mali napísaných veľa inštrumentačných testov pre otestovanie funkcionality všetkých obrazoviek. Pri ich písaní sme sa nedržali žiadneho návrhového vzoru, takže len veľmi málo z nich bolo po refaktoringu použiteľných.

Stabilné a ľahko udržiavateľné testy

Tvárou v tvár k tejto situácii sme začali pátrať po vhodnom prístupe k napísaniu nových testov, ktoré by boli robustné a ľahko udržiavateľné v budúcnosti. Hľadanie nás priviedlo k návrhovému vzoru s názvom Robot. Tento vzor bol pôvodne predstavený Jakeom Whartonom v jednej z jeho prednášok.

Hlavnou myšlienkou uvedeného návrhového vzoru je oddelenie jednotlivých úmyslov v testoch používateľského rozhrania (oddelenie “čo” od “ako”). V zdrojových kódoch aplikácie na to používame architektonický návrhový vzor MVVM. Čiže naším cieľom bolo napísať stabilné, čitateľné a ľahko udržiavateľné testy používateľského rozhrania.

Návrhový vzor typu Robot tak, ako je prezentovaný Jakeom Whartonom, je veľmi voľne definovaný, otvorený vlastným interpretáciám a nezávislý na programovacom jazyku. Jeho účelom je oddelenie samotných testov od logiky ovládania prvkov používateľského rozhrania.

Roboti

Toto je dosiahnuté zavedením tzv. robotov, ktorých jedinou úlohou je ovládanie individuálnych obrazoviek. Pre samotné testy nie sú odhalené detaily spôsobu identifikácie a ovládania jednotlivých prvkov rozhrania, napr. nájdenie a vyplnenie hodnoty prvku typu EditText alebo klik na konkrétne tlačidlo. Robot predstavuje “ako” a test predstavuje “čo”. Najlepšie na tom je, že pri takomto delení sú samotné testy krátke, ľahko čitateľné a samovysvetľujúce. Robot reprezentujúci jednu obrazovku je použiteľný s mnohými testami a v prípade zmeny obrazovky aplikácie spravidla nie je nutné meniť tucty testov, ale iba konkrétneho robota, ktorý reprezentuje zmenenú obrazovku. A s použitím pokročilých vlastností jazyka Kotlin je implementácia veľmi jednoduchá.

Väčšina realizácií návrhového vzoru typu Robot sa dostáva do bodu, kedy sú testy veľmi strohé, so zreťazenými funkciami ako v nasledujúcom príklade z prednášky J. Whartona:

payment {
    amount(4200)
    recipient("foo@bar.com")
} send() {
    isSuccessful()
}

Čo je síce perfektný príklad, avšak pri písaní testov v našej spoločnosti párujeme skúsených programátorov s programátormi, ktorý sa jazyk Kotlin iba učia. Práve testy sú totiž veľmi vhodné na naučenie sa nového programovacieho jazyka. Juniorom niektoré pokročilé koncepty jazyka Kotlin môžu byť vzdialené alebo málo intuitívne na pochopenie bez hlbších skúseností. Okrem toho sme dospeli k názoru, že potrebujeme docieliť jasnú a uniformnú štruktúru jednotlivých testov aj za cenu, že budú dlhšie.

Naša vlastná implementácia

Prišli sme teda s vlastnou implementáciou vzoru typu Robot, ktorá by zachovávala princípy vzoru, ohraničila by operácie na konkrétne obrazovky a dala by sa veľmi efektívne naučiť aj s minimom programovacích skúseností.

Pre tento účel sme ďalej rozdelili robotov, ktorý interagujú s obrazovkami, na tzv. účastníkov (actor) a inšpektorov (inspector). Roboty typu actor zodpovedajú za interakciu s elementami používateľského rozhrania a roboty typu inspector verifikujú zobrazené dáta a stavy. To znamená, že pre každú obrazovku existujú 2 roboty.

Robot typu actor je spolu s funkciou act() definovaný nasledovne:

interface Actor

abstract class ActorRobot : Actor {
    protected val events: Events = Events()
}

fun <T : Actor> act(actor: T, func: T.() -> Unit) = with(actor, func)

Robot typu inspector je spolu s funkciou inspect() definovaný takto:

interface Inspector

abstract class InspectorRobot : Inspector {
    protected val checkThat: Matchers = Matchers()
}

fun <T : Inspector> inspect(inspector: T, func: T.() -> Unit) = with(inspector, func)

Kde objekty events a checkThat sú súborom funkcií slúžiacich na interakciu s rôznymi prvkami rozhrania v prípade events, resp. súborom vyhodnocovacích funkcií (angl. assertions) v prípade checkThat. Programátori testov následne píšu testy ako množiny funkcií typu act() a inspect() v ohraničení definovanom konkrétnym robotom obrazovky. Toto ohraničenie (angl. scope) je vstupným parametrom uvedených funkcií.

Práve limitovanie možnosti zavolať jednotlivé funkcie definovaným ohraničením prispieva k tomu, aby programátori písali čitateľnejšie a úhľadnejšie. K tomu, aby sa tieto funkcie nemohli medzi jednotlivými robotmi pomiešať, prispieva návrhmi aj vývojové prostredie.

TVMZ

Pre demonštrovanie výhod nami navrhnutého prístupu sme vytvorili demo aplikáciu, ktorú sme nazvali TVMZ. Jej názov je odvodený od API tvmz.com, poskytujúceho vyhľadávací nástroj medzi televíznymi seriálmi. Jednotlivé obrazovky aplikácie sú na nasledujúcich obrázkoch.

Obrazovky aplikácie TVMZ

Štandardný inštrumentačný test v nástroji Espresso (bez použitia vzoru typu Robot), ktorý skontroluje obsadenie seriálu Star Trek a preskúma obrazovky rozhrania od spustenia až po obsadenie, by mohol vyzerať nejako takto:

@Test
fun showStarTrekDetailNoPattern() {
// Vyplň pole hľadania a klikni na tlačidlo “vyhľadať”.
onView(ViewMatchers.withId(R.id.editTextSearchTitle))
.perform(ViewActions.clearText(), ViewActions.typeText("Star Trek"))
onView(ViewMatchers.withId(R.id.buttonSearch))
.perform(ViewActions.click())
​
​
​
// Skontroluj nadpis fragmentu a očakávaný počet prvkov.
onView(AllOf.allOf(IsInstanceOf.instanceOf(TextView::class.java), ViewMatchers.withParent(ViewMatchers.withId(R.id.toolbar)))).check(ViewAssertions.matches(ViewMatchers.withText("Found shows")))
onView(ViewMatchers.withId(R.id.recyclerView)).perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(9))
onView(ViewMatchers.withId(R.id.recyclerView)).check(RecyclerViewItemCountAssertion.withItemCount(9))
​
​
​
// Klikni na prvú položku zoznamu.
onView(ViewMatchers.withId(R.id.recyclerView)).perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, ViewActions.click()))
​
​
​
// Vyhodnoť dáta na obrazovke detailu.
onView(AllOf.allOf(IsInstanceOf.instanceOf(TextView::class.java), ViewMatchers.withParent(ViewMatchers.withId(R.id.toolbar))))
.check(ViewAssertions.matches(ViewMatchers.withText("Show detail")))
onView(ViewMatchers.withText("Star Trek")).check(ViewAssertions.matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))

Vyznať sa v ňom je nočnou morou. Nehovoriac o množstve práce potrebnej na prepísanie podobných testov v prípade, že sa zmenia elementy na obrazovke. Našťastie však máme náš zlepšovák, ktorý nám pomôže upratať neporiadok.

Pre každú z uvedených obrazoviek sme napísali 2 robotov (jedného typu actor a druhého typu inspector). Tieto roboty sú zodpovedné iba za interakciu s elementami rozhrania a za verifikáciu zobrazených dát. Príkladom oboch robotov pre obrazovku vyhľadávania sú:

class SearchScreen : ActorRobot() {
    fun fillShowName(showName: String) {
        events.typeText(R.id.editTextSearchTitle, showName)
    }


    fun submit() {
        events.clickOnView(R.id.buttonSearch)
    }
}

class SearchCheck : InspectorRobot() {
    fun isScreenVisible() {
        checkThat.viewIsVisible(R.id.editTextSearchTitle)
        checkThat.viewIsVisible(R.id.buttonSearch)
    }
}

Pôvodný test je potom možné prepísať do nasledujúcej podoby:

@Test
fun showStarTrekDetail() {
     act(SearchScreen()) {
         fillShowName(showName)
         submit()
     }
     inspect(ShowsListCheck()) {
         isScreenVisible()
         isExpectedItemCount(9)
     }
     act(ShowsListScreen()) {
         clickOnItemAtPosition(0)
     }
     inspect(ShowDetailCheck()) {
         isScreenVisible()
         isTitleVisible(showName)
     }
}

V tejto podobe sú testy omnoho čitateľnejšie a zrozumiteľnejšie. Napriek tomu, že uvedený test je dlhší ako zreťazený test z prvého príkladu od J. Whartona, je napísaný podľa deterministického vzoru a je z neho vidieť, že ak zmeníme prvky na obrazovke, nemusí to nutne znamenať aj prepísanie testu.

Pretože naše testy sledujú jasný návrhový vzor, hocikto sa môže ľahko naučiť ich písať. Noví a neskúsení členovia tímu môžu začať testami a na zapracovanie potrebujú omnoho kratší čas. Pozitíva uvedeného prístupu sa nám v našej spoločnosti viackrát potvrdili. Odkedy sme všetky testy prepísali, pridávali sme novú funkcionalitu a refaktorovali zdrojový kód, pričom zmeny aplikácie si vyžiadali iba minimálne zmeny v kóde testov.