Testovanie v Androide: na testy UI triedou ActivityInstrumentationTestCase2

V minulom dieli sme videli, ako vieme zrecyklovať znalosti z JUnitu na testovanie biznis logiky. Ako však testovať samotné používateľské rozhranie?

Existuje viacero možností:

  • využitie inteligentného ľudského testera
  • implementácia automatických testov

Inteligentný ľudský tester môže dokola preklikávať aplikáciu a zisťovať, či sa v nej náhodou nenachádzajú chyby, či nové verzie nezaviedli okrem nových featúr aj nové možnosti pre pád (force close). Problémom je repetitívnosť, ktorá unaví každého a často sa stáva, že niektoré chyby si bystré oko a hlava po čase neuvedomia.

Automatické testy takéto problémy do veľkej miery odstránia, aj keď je treba venovať značné úsilie pri ich vývoji; na druhej strane raz napísaný test ušetrí práve množstvo opakovaných klikov a dotykov virtuálneho prsta.

Android má dva spôsoby, ktorými možno autopreklikávať UI; my si ukážeme starého klasika vychádzajúceho z JUnitu, a to na našej aplikácii z minula.

Podľa architektonického obrázka z predošlého dielu bude aplikácia pozostávať z jednej aktivity (+ jedného XML layoutu a jedného strings.xml s definíciou reťazcov). Nebude tu uvedená celá, lebo si ju môžete stiahnuť a poobdivovať sami.

Rovnako môžete obdivovať výsledný bežiaci stav:

currencr-application

Aktivita tak pozostáva z troch viewov: EditText nesie sumu, Spinner predstavuje rozbaľovadlo (combo box) s menou a TextView zobrazí výslednú sumu.

V kóde aktivity sa použijú týmto spôsobom:

    amountEditText = (EditText) findViewById(R.id.amountEditText);
    currencySpinner = (Spinner) findViewById(R.id.currencySpinner);
    resultTextView = (TextView) findViewById(R.id.resultTextView);

Spomínam to preto, lebo tento kód doslova skopírujeme do testu, čo urobíme o pár riadkov nižšie. Teraz trocha filozofie.

Trocha filozofie k testom aktivít

Test aktivity píšeme temer rovnakým spôsobom ako testy biznis logiky. Na rozdiel od bežného testu (podtriedy TestCase) budeme dediť od triedy z krásnym názvom ActivityInstrumentationTestCase2. (Áno, na konci je dva, oj ako zafúkal puch od končín tried z C++ API Microsoft Windows…)

Kostra kódu, tu hľa

public class MainActivityTest extends
        ActivityInstrumentationTestCase2<MainActivity> {


    private EditText amountEditText;
    private Spinner currencySpinner;
    private TextView resultTextView;

    public MainActivityTest() {
        super(MainActivity.class);
    }

    @Override
    protected void setUp() throws Exception {
        super.setUp();
        setActivityInitialTouchMode(false);

        amountEditText = (EditText) findViewById(R.id.amountEditText);
        currencySpinner = (Spinner) findViewById(R.id.currencySpinner);
        resultTextView = (TextView) findViewById(R.id.resultTextView);

    }

    private View findViewById(int id) {
        return getActivity().findViewById(id);
    }
}

Test aktivity potrebuje zavolať rodičovský konštruktor, kde uvedie triedu aktivity pod testom (to je rozdiel oproti bežnému testu, kde konštruktor nemusíme uvádzať):

    public MainActivityTest() {
        super(MainActivity.class);
    }   

Metóda setUp() zavolá rodičovskú implementáciu a pred spustením každej testovacej metódy nainicializuje všetky widgety, ktoré budeme používať. Presne tu je ten moment Ctrl-C, Ctrl-V spomínaný vyššie: komponenty doslova skopírujeme z kódu aktivity. Musíme však použiť drobný trik: vyrobiť si pomocnú metódu:

    private View findViewById(int id) {
        return getActivity().findViewById(id);
    }

Ostáva riadok:

setActivityInitialTouchMode(false);

Tým vypneme podporu pre dotyky, ktoré budeme simulovať stláčaním virtuálnych klávesov.

Testovacie metódy

V testovacej metóde musíme hlavne nasimulovať pohyb po ovládacích prvkoch. Nebudeme používať prst, ale virtuálny DPAD: teda kontrolér so štyroma smerovými klávesmi a Enterom v strede. Na moderných zariadeniach sa DPAD už nenachádza, niektoré ho emulovali trackpadom (napr. HTC Desire), ale to nám v emulátore neprekáža: máme totiž hardvérovú PC klávesnicu, kde máme i šípky, i Enter.

trackpad

Ovládanie DPADu riešime volaním metódy sendKeys() s parametrom obsahujúcim kód stlačeného virtuálneho klávesu.

Tipnite si, čo robí táto sekvencia?

sendKeys(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_RIGHT);
sendKeys(KeyEvent.KEYCODE_DEL, KeyEvent.KEYCODE_DEL, KeyEvent.KEYCODE_DEL);
sendKeys(KeyEvent.KEYCODE_1, KeyEvent.KEYCODE_0, KeyEvent.KEYCODE_0, KeyEvent.KEYCODE_DPAD_CENTER);

Áno, šípka vpravo, šípka vpravo, stlačenie Backspace, ešte jedno Backspace a ešte jedno Backspace, napísanie 1, 0 a 0 a potvrdenie Enterom.

Všimnite si, že sendKeys() prijíma viacero parametrov, čo sa môže hodiť.

Táto sekvencia je presne z unit testu: vymaže obsah textového políčka, zadá doň obnos 100 eúr a potvrdí ho Enterom.

Presun medzi ovládacími prvkami

Automatizovaný presun medzi ovládacími prvkami je prekérna vec: a to hlavne z technického hľadiska. Každý ovládací prvok/view má metódu requestFocus(), ktorým vie získať fokus — teda všetky akcie budú smerované práve doň. Nafokusované textové políčko má textový kurzor, v nafokusovanom spinneri možno vyberať jednu z možností atď.

Získavanie focusu musíme urobiť v pomocnej metóde: ak vás nezaujímajú technické detaily, viete, že potrebujete do testu dopísať novú pomocnú metódu:

private void requestFocus(final View view) {
    getActivity().runOnUiThread(new Runnable() {
        @Override
        public void run() {
            view.requestFocus();
        }
    });
    getInstrumentation().waitForIdleSync();
}

a následne ju volať napr:

requestFocus(amountEditText);

čím presuniete focus do textového políčka s hodnotou.

Technické detaily: vlákna a UI

Šialenosť v metóde requestFocus() súvisí s ideou vlákien a rýchlych používateľských rozhraní. Platí zásada, že pracovať s widgetami možno len v rámci vlákna používateľského rozhrania, tzv. UI Thread. Keďže test beží v samostatnom vlákne, metódou runOnUiThread() zabezpečíme, že kód v metóde run() sa nespustí vo vlákne testu, ale vo vlákne UI. (Viac k tomu v tejto chvíli nepovieme, lebo vlákna a UI je téma na tri samostatné články ;-)

Kompletný kód unit testu

Celý kód unit testu zahŕňa všetko, čo sme spomenuli vyššie. Zatiaľ máme jedinú metódu, ktorá automaticky vyplní v textovom políčku obnos 100 eúr, zo spinnera si vyberie menu českej koruny (Enterom zobrazí rozbaľovadlo, šípkou dole zvolí druhú hodnotu v poradí a ďalším Enterom ju potvrdí).

Overenie vypočítanej a očakávanej hodnoty vykonáme tradičným Assert.assertEquals().

package sk.upjs.ics.currencr.test;

import sk.upjs.ics.currencr.MainActivity;
import sk.upjs.ics.currencr.R;
import android.test.ActivityInstrumentationTestCase2;
import android.view.KeyEvent;
import android.view.View;
import android.widget.EditText;
import android.widget.Spinner;
import android.widget.TextView;

public class MainActivityTest extends
        ActivityInstrumentationTestCase2<MainActivity> {


    private EditText amountEditText;
    private Spinner currencySpinner;
    private TextView resultTextView;

    public MainActivityTest() {
        super(MainActivity.class);
    }

    @Override
    protected void setUp() throws Exception {
        super.setUp();
        setActivityInitialTouchMode(false);

        amountEditText = (EditText) findViewById(R.id.amountEditText);
        currencySpinner = (Spinner) findViewById(R.id.currencySpinner);

        resultTextView = (TextView) findViewById(R.id.resultTextView);

    }

    public void testCZK() {
        requestFocus(amountEditText);
        sendKeys(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_RIGHT);
        sendKeys(KeyEvent.KEYCODE_DEL, KeyEvent.KEYCODE_DEL, KeyEvent.KEYCODE_DEL);
        sendKeys(KeyEvent.KEYCODE_1, KeyEvent.KEYCODE_0, KeyEvent.KEYCODE_0, KeyEvent.KEYCODE_DPAD_CENTER);
        requestFocus(currencySpinner);

        sendKeys(KeyEvent.KEYCODE_DPAD_CENTER);
        // move to 'CZK'
        sendKeys(KeyEvent.KEYCODE_DPAD_DOWN);
        // confirm 'CZK'
        sendKeys(KeyEvent.KEYCODE_DPAD_CENTER);

        assertEquals("2594.200", resultTextView.getText().toString());
    }

    private void requestFocus(final View view) {
        getActivity().runOnUiThread(new Runnable() {
            @Override
            public void run() {
                view.requestFocus();
            }
        });
        getInstrumentation().waitForIdleSync();
    }

    private View findViewById(int id) {
        return getActivity().findViewById(id);
    }
}

Test už môžeme spustiť klasickým spôsobom.

Poznámka k Assert.assertEquals()

Metóda assertEquals() môže niekedy produkovať divné výsledky. Nože si skúste upraviť test tak, že na konci overíte:

assertEquals("2594.20", resultTextView.getText().toString());

Test zlyhá a uvidíte chybovú hlášku

junit.framework.ComparisonFailure: expected:<...> but was:<...0>

WTF?! Čakáme tri bodky a dostaneme štyri bodky a nulu?! Mnoho vlasov bolo vytrhaných a ešte viac ošedivených, lebo absolútne netušíme, čo to má znamenať.

Na príčine je podivná implementácia JUnitu a metódy assertEquals() na reťazcoch. V jej vnútri je inteligentná logika, ktorá sa snaží povedať, kde presne sa dva reťazce nezhodujú. Ak sú reťazce pridlhé, JUnit jednoducho odsekne zhodné predpony a prípony a nahradí ich trojbodkami. Niekdy to funguje správne, ale niekedy to skracovanie je jednoducho divné.

Android sa snažil porovnať 2594.20 a 2594.200 ako reťazce (tak sme mu to totiž povedal), zistil, že sa nezhodujú, odsekol spoločnú predponu (2594.20) a nahradil ju trojbodkou.

Z očakávanej hodnoty sa tak stal reťazec s troma bodkami (...) a vypočítanej hodnoty ostala trojbodka a odlišná neočakávaná nula na konci.

Novšie verzie JUnitu to majú opravené, ale nezľaknite sa, keď uvidíte takéto podivnosti.

Výsledný projekt

Stiahnite si dvojicu projektov.

Odkazy

Pridaj komentár

Vaša e-mailová adresa nebude zverejnená. Vyžadované polia sú označené *