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:
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.
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 Enter
om.
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
- Android Activity Testing
- Unit Testing Android Activity — sada tipov a trikov a zákutí a pascí pri testovaní aktivít
- Android SDK JUnit Testing — rozsiahly príklad s kalkulačkou a jej testovania