Testování webových aplikací Seleniem

O tomto tématu jsem už něco napsal. Například jak nainstalovat a začít či nějaké best practices. Dneska chci napsat o tom, jak vlastně testujeme u nás, co jsem už párkrát školil.

Ukázky budou v Pythonu. Knihovna pro komunikaci se Seleniem je WebDriver Wrapper, kterou jsem sepsal pro jednodušší ovládání prohlížeče. Aby se nemuselo myslet na všechny ty detaily. A samotné testy budou ukázány s pytestem, který jsem si v nedávné době oblíbil.

Pohyb po webu

Takový základ na webu je přesun mezi stránkami. Selenium nabízí metodu get. Nevýhoda je, že se musí pokaždé předávat absolutní cesta. Proto jsem přidal motodu go_to, díky které stačí předat relativní cesta. Například:

driver.go_to('http://seznam.cz')
driver.go_to('prihlaseni')

Musí se ale myslet na to, že někde se musí začít s absolutní. Nejvhodnější hned po vytvoření driveru. Aby to bylo ještě jednodušší, lze předávat parametry jako slovník!

driver.go_to('search', {'q': 'Selenium'})

Což přejde na URL http://seznam.cz/search?q=Selenium.

Často padá dotaz, zda lze stránky testovat na status kód. Nejde. Protože to prý k tomuto toolu nepatří. Samozřejmě teoreticky mají pravdu, prakticky se mýlí. Kluci ze Zboží si s tím poradili tak, že nastartovali proxy, přes kterou ženou všechny požadavky, a zkoumají veškeré HTTP kódy a hlavičky tam. Výhoda je jasná, mohou jednoduše monitorovat veškerý provoz, i třeba JavaScriptové volání.

Pokud není potřeba takovýto kanón, přidal jsem do wrapperu metodu download_url. Buď předáte URL, kterou stáhnout, nebo se stáhne aktuální stránka. Vlastně je to samé, jako například volání request.get(url), jen s tím rozdílem, že to za vás pořeší sušenky. Podobně jsem přidal metodu download_file, která je dostupná nad odkazem či formulářem. Například si představte formulář a při jeho odeslání se stáhne požadovaný soubor. Jak ověřit jeho obsah, status kód a hlavičky? Tak, že ze stránky vytáhnete, zda je formulář POST či GET, všechny jeho formulářové políčka, cookie ve stránce a složíte patřičný request. Nebo…

form = driver.get_elm('form_id')
res = form.download_file()
assert res.status_code == 200

Co vše se v odpovědi nachází, viz dokumentace.

Formuláře

Tím se dostáváme k dalšímu důležitému aspektu a to je vyplňování formulářů. S čistým Seleniem je taková akce utrpením. Jelikož naše aplikace je, dalo by se říct, jedním obrovským formulářem, byla to první věc, kterou jsme si zjednodušili. Jak se vyplňuje například formulář v Djangu či WTForms? Předáním slovníku z requestu. Proč to nezkusit tedy i pro testování webovek…

form = driver.get_elm('form_id')
form.fill_out_and_submit({
    'string': u'Nějaký text',
    'number': 42,
    'checkbox': True,
    'radio': 'value',
    'select': 'value',
    'multiselect': ['value1', 'value5'],
    'file': '/tmp/file.txt',
})

Vyhnuli jsme se tedy různým způsobům vyplňováním na pozadí. Klíč je vždy name formulářového políčka a hodnota jak byste ji reprezentovali v Pythonu. O zbytek se postará wrapper za vás.

Například se v poslední době rozmáhá Bootstrap a jiné knihovny sloužící nejen programátorům vytvářet rychle pohledný web. Problém je, že všude je moderní klasický checkbox skryt a přestylován k obrazu svému. Tím poté po volání metody click vyskočí výjimka o skrytém elementu a tudíž na něj „uživatel“ nemůže kliknout. Musí se tedy klikat místo toho na label kolem checkboxu. Jelikož je to rozšířená praktika, wrapper si s tímto popere. Stejně tak u radio tlačítek.

Dále bývá problém při onchange událostech. Takové události se spustí po opuštění políčka. Takže pokud budeme mít navázaný JavaScript na změnu políčka a v testu na tuto změnu budeme čekat… nedočkáme se, pokud explicitně políčko neopustíme. Například stiskem klávesy tabulátor.

from selenium.webdriver.common.keys import Keys

input = driver.get_elm('input_id')
elm.send_keys('text' + Keys.TAB)

Což pro jistotu wrapper dělá automaticky.

Běžně, díky metodě fill_out, není potřeba obsluhovat selecty ručně, ale stejně takové případy občas nastanou. Normálně byste buď obsluhu museli řešit komplikovaně klikáním, nebo si vytvořit instanci Select:

select = driver.get_elm('select_id')
select = selenium.webdriver.support.select.Select(select)
select.deselect_all()

S wrapperem není potřeba. Select je automaticky Select a můžete volat jeho metody.

select = driver.get_elm('select_id')
select.deselect_all()

Práce s elementy

Možná se divíte, co to stále používám za metodu get_elm. Je to zkratka pro metody find_element_by_*. Tedy původně jsem ji napsal kvůli výjimkám. Selenium totiž umí povědět, zda je něco špatně, ale detailů se nedočkáte. Když nám padaly testy na NoSuchElementException, neměli jsme ponětí, co je špatně. A když jsme měli ponětí (z tracebacku bylo jasné, žádná dynamika), nevěděli jsme na jaké stránce.

Jde o to, že chyba NoSuchElement nemusí nutně znamenat chybějící element. Požadovaný element může na své stránce být, ale problém nastal při přechodu na požadovanou stránku. Například odkaz byl špatný. Takové věci se bez patřičných informací blbě ladí a proto jsem si vytvořil vlastní metodu, která přidává potřebné informace.

>>> driver.get_elm('resultCount')
NoSuchElementException: Message: u'No element <* id=resultCount> found at http://www.seznam.cz'

Hned je vidět, že chybí element s ID resultCount na homepage Seznamu. Tam opravdu daný element není a tudíž problém bude někde jinde.

Jakmile jsem vyřešil, aby takové výjimky házely i metody find_element_by_*, přemýšlel jsem o zrušení. Ale lenost vyhrála. Lenost psaní dlouhých testů. Za prvé první parametr je ID, což se používá nejčastěji a tudíž kratší zápis; co je ale důležitější je kombinace s parent_* atributy.

elm = driver.find_element_by_id('id')
elms = elm.find_elements_by_tag_name('a')
# =>
elms = driver.get_elms(parent_id='id', tag_name='a')

Asi to nezní moc jako důvod, proč by to tu mělo zůstávat. Zajímavé to začne být v kombinaci s dalšími metodami. Například možnost nemuset získat element a poté na něj kliknout, ale rovnou kliknout.

elm = driver.get_elm('id')
elm.click()
# =>
driver.click('id')

Nebo při řešení slavné chyby StaleElementException. Přihlašte se, kdo se s ní setkal! Je velice jednoduché na ni narazit, především, pokud máte tu čest s JavaScriptem. Jakmile vám JavaScript pod rukama začně mazat a znovu vytvářet elementy, staré reference přestanou existovat. (Více jsem o tom už psal.)

Při kliknutí na klasický odkaz Selenium počká na načtení stránky. Při kliknutí na cokoliv jiného, co má na starost JavaScript, Selenium nečeká. Neví totiž, zda má čekat. A na co vlastně. Stejně jako uživatel by nevěděl na co má čekat. Takové akce si musíte zařídit explicitně. time.sleep rozhodně nechcete, a všude dávat ručně cyklus taky ne. Selenium nabízí WebDriverWait, já nabízím jednoduší možnost:

elm = driver.wait_for_element('id')

Občas je potřeba opak – počkat, až se něco skryje. Například nějaké nastavení v modálním okně apod.

elm = driver.wait_for_element_hide('id')

Testování

Pravá sranda nastává až se samotným testováním. Jak si vytvořit driver? Jak se případně přihlásit do aplikace? Co testovat? Jak neustále hlídat nečekané chybové hlášky? Či dokonce chybové stránky? …

Samotné Selenium s ničím takovým nepomáhá a proto jsem do wrapperu přidal i podportu pro snadné testování. Pro pytest stačí vytvořit fixturu s názvem _driver, která vytváří samotný driver a specifikuje jeho scope.

Wrapper obsahuje fixturu driver, který se využívá v testech a definuje nějaké chování, o které se nemusíte starat. Například test může kdykoliv otevřít novou záložku, třeba i omylem, a v každém testu byste museli řešit jejich zavření a v dalším testu pokračovat v hlavním okně.

@pytest.yield_fixture(scope='session')
def _driver():
    driver = Chrome()
    yield driver
    driver.quit()

V případě, že potřebujete před/po každé testovací funkcí driver nějak resetovat, můžete postupovat standardně.

@pytest.yield_fixture(scope='session')
def session_driver():
    driver = Chrome()
    yield driver
    driver.quit()


@pytest.fixture(scope='function')
def _driver(session_driver):
    reset_driver(session_driver)  
    return session_driver

Fixtura driver se nestará pouze o aktivní tab, ale také o detekci chybových hlášek a stránek. Automaticky se hledají CSS třídy error a pokud se nalezne, test skončí chybou. Dále se hledá CSS třída error-page pro chybové stránky jako 500, 404, 403, …

Tedy jakákoliv chybová hláška či stránka je špatně. Nemusíte ručně psát testování chybových hlášek a test sám selže.

def test_save(driver):
    driver.fill_out_and_submit({'username': 'michael', 'password': 'abcd'})

# ErrorMessagesException: Unexpected error messages "Password is not correct".

Samozřejmě ale chceme testovat aplikaci i při selhání. Ověřit, že se uživatel dozví potřebné informace. Na to tu existují dekorátory…

@expected_error_messages('Password is not correct')
def test_save(driver):
    driver.fill_out_and_submit({'username': 'michael', 'password': 'abcd'})

# OK.

Test naopak selže, pokud na stránce nalezne jiné (znamená i žádné) chybové hlášky.

Někdy může nastat situace, kdy se může zobrazit chybová hláška, nebo taky nemusí. Ale to nám nevadí. Třeba že některý server není dostupný, ale pro daný test nás tato informace nezajímá. Pak lze použít allowed_error_messages. Či dokonce allowed_any_error_message pokud je nám text chyby úplně jedno.

Podobně jsou dekorátory pro stránky: expected_error_page a allowed_error_pages. Či dokonce pro informační hlášky typu „úspěšně uloženo“: expected_info_messages a allowed_info_messages. Více v dokumentaci.

Aby toho nebylo málo, wrapper kontroluje taky chyby v JS na stránce. Aby tato kontrola mohla fungovat, je potřeba přidat do stránky následující JS kód:

<script>
    window.jsErrors = [];
    window.onerror = function(errorMessage) {
        window.jsErrors[window.jsErrors.length] = errorMessage;
    }
<script>

Teď ale zní otázka – co když mám chybové hlášky definováno jinak?

driver = Chrome()

# Lepsi nastavit na instanci, takze pri zmene prohlizece neni potreba menit tento kod.
driver.__class__.get_error_page = your_method
driver.__class__.get_error_traceback = your_method
driver.__class__.get_error_messages = your_method
driver.__class__.get_info_messages = your_method

Ještě se zmíním o metodě break_point, díky které se kód zastaví a čeká na signál k pokračování (enter v konzoli). Tím můžete snadno test pozastavit a podívat se do Chrome konzole a podobně.

Pro UnitTesty fungují dekorátory stejně. Pouze použijte připravený WebdriverTestCase.

Další drobnosti

To není vše. Ještě je zde několik detailů. Například metoda contains_text, vylepšená switch_to_window a další pro práci s taby, alerty a dalšími detaily. Projděte dokumentaci.


A to je vše. Máte nějaké dotazy či nápady na vylepšení? Sem s nimi!