Hlavní navigace

Testování webových aplikací – návrhový vzor PageObject

Pavel Herout

Tento článek je druhým článkem z minisérie týkající se funkcionálního testování webových aplikací. Dnes si připravíme PageObject, což je jednorázově pracná záležitost, která zpřehlední všechny další práce.

Doba čtení: 12 minut

Sdílet

Pro přístup k webové aplikaci jsem využíval Selenium WebDriver ve verzi 3.141.59 (dále jen Selenium). Jedná se o rozsáhlý a poměrně rozšířený framework umožňující ovládat webovou aplikaci přes běžný webový prohlížeč (Chrome, Firefox, Opera, …) pomocí programovacího jazyka. Tím byla v mém případě Java, ale na výběr jsou i C#, Ruby, Python a Javascript.

Selenium netestuje, je to prostředek pro práci s webovou stránkou zobrazovanou Seleniem ovládaným webovým prohlížečem. Pro vlastní testy se využívají jiné nástroje. V mém případě to byl framework JUnit5, který je sice primárně určen pro jednotkové testování, ale svými rozsáhlými možnostmi mi zcela vyhovoval i pro testování funkcionální. Podrobnější informace o tom, jak jsem používal JUnit5, budou uvedeny v následujícím dílu této minisérie.

PageObject v Seleniu je návrhový vzor, který umožňuje velmi elegantní oddělení ovládání webové stránky od jejích budoucích testů nebo od aktivit na této stránce. Tím se zajistí zvýšená přehlednost testů a jejich snazší modifikovatelnost.

Pro použití PageObject je nutné znát běžné příkazy Selenia, protože PageObject je nenahrazují, ale využívají.

Osobní poznámka:
O existenci PageObject jsem při vytváření předchozí verze testů sice věděl, ale to bylo asi tak všechno. Neznal jsem jejich výhody ani způsoby použití. Po důrazném doporučení jsem o nich začal hledat bližší informace, ale nebyl jsem moc úspěšný. Protože, pokud jsem našel použitelné příklady, byly vysloveně triviální a žádná zjevná výhoda konceptu PageObject mi z nich neplynula. Změna nastala až po přečtení knihy Mark Collin Mastering Selenium WebDriver (ISBN 978–1–78439–435–6), ve které jsou na cca 30 stránkách velmi srozumitelně koncept a výhody PageObject popsány. Tam uvedených příkladů jsem se držel a dle svých potřeb jsem je modifikoval, doufejme, že k lepšímu  ;-)

Porovnání programu zapsaného bez a s využitím PageObject

Budou uvedeny dvě ukázky, aby bylo zřejmé, že PageObject (stejně jako Selenium) není nutné používat jen pro testování.

Ve všech uvedených příkladech budeme předpokládat, že před nimi úspěšně proběhla konfigurace, tj. je správně nainicializovaný driver a aplikace má správně zobrazenu stránku na konkrétním URL.

První ukázka bude zalogování uživatele na stránce:


  • pro username je id="loginPage.userNameInput"
  • pro password je id="loginPage.passwordInput"
  • pro tlačítko je id="loginPage.loginFormSubmit"

Aktivita přihlášení uživatele strict (heslo je vždy pass) za použití pouze příkazů Selenia:

// username
List<WebElement> usernameList =
         driver.findElements(By.id("loginPage.userNameInput"));
if (usernameList.isEmpty()) {
  Fail.failTestDueToMissingElement("loginPage.userNameInput");
}
WebElement username = usernameList.get(0);
username.clear();
username.sendKeys("strict");

// password
List<WebElement> passwordList =
         driver.findElements(By.id("loginPage.passwordInput"));
if (passwordList.isEmpty()) {
  Fail.failTestDueToMissingElement("loginPage.passwordInput");
}
WebElement password = passwordList.get(0);
password.clear();
password.sendKeys("pass");

// button Login
List<WebElement> buttonList =
         driver.findElements(By.id("loginPage.loginFormSubmit"));
if (buttonList.isEmpty()) {
  Fail.failTestDueToMissingElement("loginPage.loginFormSubmit");

}
WebElement button = buttonList.get(0);
button.click();

Aktivita přihlášení uživatele strict za použití PageObject:

Login_Page page = new Login_Page();
page.setUsername("strict").setPassword("pass").clickOnLoginButton();

Druhá ukázka bude test po přihlášení, zda má element First name hodnotu „Peter“:


pro firstname je id="overview.firstName"

Test jen s použitím příkazů Selenia:

List<WebElement> firstnameList =
         driver.findElements(By.id("overview.firstName"));
if (firstnameList.isEmpty()) {
  Fail.failTestDueToMissingElement("overview.firstName");
}
WebElement firstname = firstnameList.get(0);
String actual = firstname.getAttribute("value");
assertEquals("Peter", actual);

Test za použití PageObject:

Tea_Overview_Page page = new Tea_Overview_Page();
String actual = page.overview.getFirstname();
assertEquals("Peter", actual);

Z ukázek je doufejme dostatečně zřejmá přehlednost a čitelnost kódu při použití PageObject. Je z nich také vidět, že v případě kvalitně připravených PageObject může vlastní testy psát jiný programátor a s pravděpodobně i nižší kvalifikací.

Princip PageObject

Kód Selenia, který byl uveden na předchozích ukázkách, se samozřejmě musí někde v PageObject skrývat. V této části si ukážeme, kde a jak.

Celý základní princip fungování PageObject je realizován pomocí jedné anotace @FindBy a jedné statické metody PageFactory.initElements(). Je to skutečně tak jednoduché.

Každá webová stránka aplikace je nezávisle na ostatních popsána jednou odpovídající třídou typu PageObject.

Rodičovskou třídou těchto tříd je (jak jinak) PageObject, jejíž obsah může být např.:

public class PageObject {
  /**
   * Initialization of each web element annotated by @FindBy()
   */
  public PageObject() {
    PageFactory.initElements(Drivers.getDriver(), this);
  }

  /**
   * When a web element was initialized by PageFactory.initElements()
   * (e.g. web element was found on the web page = displayed)
   * the reference at it is an only one element of the list
   *
   * if a web element has not been initialized, the list is empty
   *
   * @param list list with one reference to a web element, or empty list
   * @return true, when list is not empty, false otherwise
   */
  public boolean isDisplayed(List list) {
    return list.size() == 1;
  }

  /**
   * Getting an initialized reference to a web element
   * or failing test by Fail.failTestDueToMissingElement(id);
   *
   * @param list list with one reference to a web element, or empty list
   * @param id ID of the web element - it is necessary for logging of a possible failed test
   * @return reference to a web element
   */
  public WebElement getElement(List list, String id) {
    if (isDisplayed(list) == true) {
      return list.get(0);
    }
    else {
      Fail.failTestDueToMissingElement(id);
      return null;
    }
  }
}

Klíčovou metodou je bezparametrický (tj. není třeba jej ve třídě potomka explicitně volat) konstruktor

public PageObject() {
    PageFactory.initElements(Drivers.getDriver(), this);
  }

který s využitím již dříve inicializovaného driveru a voláním metody PageFactory.initElements() nalezne všechny webové elementy specifikované ve třídě potomka anotací @FindBy (viz dále). Vnitřně je tento mechanismus zajištěn voláním známé Seleniovské metody findElements(), která vrací seznam ( List) nalezených WebElement. Pokud pro adresaci používáme ID, obsahuje seznam vždy jen jeden element, v případě, že byl nalezen. Nebo je seznam prázdný, v případě, že požadovaný element nebyl na stránce nalezen. Pozor – při absenci elementu na stránce není vyhozena žádná výjimka, pouze se vrátí prázdný seznam.

Této skutečnosti využívá metoda:

public boolean isDisplayed(List list) {
    return list.size() == 1;
  }

kterou asi není nutné více komentovat.

Pokud potřebuje třída potomka získat instanci WebElement pro další práci s tímto elementem, volá zděděnou metodu

public WebElement getElement(List list, String id) {
    if (isDisplayed(list) == true) {
      return list.get(0);
    }
    else {
      Fail.failTestDueToMissingElement(id);
      return null;
    }
  }

ta buď vrátí nalezený WebElement, nebo standardním jednotným způsobem ohlásí, že element s daným ID nebyl na webové stránce nalezen.

Třída potomka Login_Page (je samozřejmě pojmenovaná významově) má následující obsah (kráceno):

public class Login_Page extends PageObject {
  // ID's on web page
  public static final String USERNAME_INPUT = "loginPage.userNameInput";
  public static final String PASSWORD_INPUT = "loginPage.passwordInput";
  public static final String LOGIN_BUTTON = "loginPage.loginFormSubmit";

  ///////////
  @FindBy(id = USERNAME_INPUT)
  private List usernameInput;

  /**
   * Get username input placeholder text
   *
   * @return username input placeholder text
   */
  public String getUsernameInputPlaceholderText() {
    String text = getElement(usernameInput, USERNAME_INPUT).
                                  getAttribute("placeholder").trim();
    return text;
  }

  /**
   * Set username
   *
   * @param username new username
   * @return LoginPage for chain commands
   */
  public Login_Page setUsername(String username) {
    WebElement element = getElement(usernameInput, USERNAME_INPUT);
    element.clear();
    element.sendKeys(username);
    return this;
  }


  ///////////
  @FindBy(id = PASSWORD_INPUT)
  private List passwordInput;

  /**
   * Set password
   *
   * @param password new password
   * @return LoginPage for chain commands
   */
  public Login_Page setPassword(String password) {
    WebElement element = getElement(passwordInput, PASSWORD_INPUT);
    element.clear();
    element.sendKeys(password);
    return this;
  }

///////
  @FindBy(id = LOGIN_BUTTON)
  private List loginButton;

  /**
   * Performs login activity and without any alert
   *  goes to Student's Overview od Teacher's Overview page
   *
   * @return PageObject which must be changed to a propper type (StuOverviewPage or TeaOverviewPage)
   */
  public PageObject clickOnLoginButton() {
    Click.clickAndWaitURL(getElement(loginButton, LOGIN_BUTTON),
            Url.URL_STU_OVERVIEW, Url.URL_TEA_OVERVIEW);
    return new PageObject();
  }

}

Třída dědí od rodičovské třídy PageObject, čímž „zadarmo“ získá všechny dříve popisované schopnosti.

Na samém začátku třídy jsou konstanty pro jednotlivá ID:

 // ID's on web page
  public static final String USERNAME_INPUT = "loginPage.userNameInput";
  public static final String PASSWORD_INPUT = "loginPage.passwordInput";
  public static final String LOGIN_BUTTON = "loginPage.loginFormSubmit";

Konstanty mají public přístupové právo, aby je bylo možné využívat z vnějšku v případě konstrukce chybových hlášení.

Klíčovým prvkem (jak již bylo uvedeno dříve) je anotace @FindBy. Touto anotací je označen následující atribut. Pokud je atribut typu List<WebElement>, je v metodě PageFactory.initElements() konstruktoru rodičovské třídy použita pro nalezení elementu Seleniová metoda findElements(), což již bylo popsáno výše. Pokud by byl atribut pouze typu WebElement, pak by byla pro nalezení použita metoda findElement(). To by znamenalo sice jednodušší použití tohoto atributu, ale v případě nenalezení požadovaného elementu na webové stránce by skončilo hledání daného elementu metodou findElement() vyhozením výjimky. A to je nežádoucí chování.

Proto jsou pomocí @FindBy důsledně anotovány atributy pouze typu  List<WebElement>.

///////////
  @FindBy(id = USERNAME_INPUT)
  private List usernameInput;

Ještě jednou zdůrazňuji, že všechny takto anotované atributy se inicializují při vzniku instance třídy voláním bezparametrického konstruktoru s metodou PageFactory.initElements() v rodičovské třídě  PageObject.

Po inicializaci atributu následuje libovolný počet významově pojmenovaných instančních metod, pracujících s tímto webovým elementem, jehož instance se vždy získá voláním rodičovské metody getElement().

V příkladu je to např. pro ukázku možností:

 public String getUsernameInputPlaceholderText() {
    String text = getElement(usernameInput, USERNAME_INPUT).
                                  getAttribute("placeholder").trim();
    return text;

  }

Poznámka: Toto je neocenitelná výhoda konceptu PageObject, kdy všechny požadované činnosti s konkrétním webovým elementem, jsou naprogramovány na jednom a pouze jednom přesně definovaném místě. Takže v případě úprav webové stránky se okamžitě ví, kde je třeba upravit i příslušný kód Selenia.
Stejně tak funguje případné doplňování metod. Když připravujeme třídu popisující konkrétní stránku, snažíme se samozřejmě připravit dopředu co nejvíce metod, které bude tato třída poskytovat. Pokud ale v průběhu využívání této třídy zjistíme, že by se nám hodila ještě nějaká další metoda navíc, není problém ji do příslušné třídy a příslušného místa dopsat. A to bez ovlivnění zbytku kódu.

Pokračujme ale ve výkladu principů PageObject. V příkladu výše byla ukázka, jak lze elegantně provést jedním příkazem celé logování:

page.setUsername("strict").setPassword("pass").clickOnLoginButton();

Jak je to možné? Jednoduše. Metoda setUsername() by měla být typu void. Místo toho vrací pomocí this odkaz na svoji instanci (tj. na tutéž webovou stánku), takže se dají souvisejícíá příkazy zřetězit. To dále zvyšuje eleganci zápisu. Samozřejmě je možné volat každou z těchto metod samostatně, což je výhodné např. v případě debuggování.

  /**
   * Set username
   *
   * @param username new username
   * @return LoginPage for chain commands
   */
  public Login_Page setUsername(String username) {
    WebElement element = getElement(usernameInput, USERNAME_INPUT);
    element.clear();
    element.sendKeys(username);
    return this;
  }

Jako poslední popíši metodu

  public PageObject clickOnLoginButton() {
    Click.clickAndWaitURL(getElement(loginButton, LOGIN_BUTTON),
            Url.URL_STU_OVERVIEW, Url.URL_TEA_OVERVIEW);
    return new PageObject();
  }

Tato metoda využívá metodu Click.clickAndWaitURL(), která po kliknutí na příslušné tlačítko počká, dokud se neobjeví jedno ze dvou požadovaných URL. V aplikaci UIS je to totiž tak, že stránka Login je společná pro studenty i učitele. A až po úspěšném přihlášení se rozliší, zda bude zobrazována stránka studenta či učitele.

Metoda vrací odkaz na jinou novou stránku, což bude důležité při dalším použití.

Skládání stránky z podstránek

Toto je další neocenitelná výhoda konceptu PageObject. Webová stránka bývá často značně složitá, takže popis všech jejích elementů (a korespondujících obslužných metod) by činil třídu dlouhou a nepřehlednou. Nebo, což je častý případ, je webová stránka složena z opakujících se částí. Pak by bylo výhodné tyto části popsat pouze jednou třídou a tuto třídu vložit (kompozicí) do hlavní třídy.

Obě tyto možnosti koncept PageObject podporuje jednoduchým a transparentním způsobem.

Když se podíváme na tyto dvě stránky:



Je na nich vidět, že:

  • hlavička (oblast header) se opakuje u studenta i učitele beze změny (pouze mají jinak barevné pozadí)
  • totéž platí pro informaci o FirstName, LastName a Email (oblast overview)
  • třetí objekt na stánkách je menu, které je buď studentské, nebo učitelské (oblast menu)

Tyto skutečnosti pak umožní vytvořit následující třídy.

Třída pro Header (kráceno):

public class Header_Page extends PageObject {
  // ID's on web page
  public static final String LOGOUT_LINK = "header.link.logout";

////////
  @FindBy(id = LOGOUT_LINK)
  private List logout;

  /**
   * Go to LoginPage
   *
   * @return LoginPage
   */
  public Login_Page clickOnLogout() {
    if (isDisplayed(logout)) {
      Click.clickAndWaitAlert(getElement(logout, LOGOUT_LINK),
              Login_Page.LOGOUT_ALERT_SUCCESS,
              Login_Page.LOGOUT_ALERT_ERROR);
    }
    return new Login_Page();
  }
}

Třída pro Overview (kráceno):

public class Overview_Page extends PageObject {
  // ID's on web page
  public static final String FIRST_NAME_INPUT = "overview.firstName";
  ///////////
  @FindBy(id = FIRST_NAME_INPUT)
  private List firstnameInput;

  /**
   * Get first name
   *
   * @return first name
   */
  public String getFirstname() {
    String text = getElement(firstnameInput, FIRST_NAME_INPUT).getAttribute("value").trim();
    return text;
  }

  /**
   * Set first name
   *
   * @param firstname new first name
   * @return OverviewPage for chain commands
   */
  public Overview_Page setFirstname(String firstname) {
    WebElement element = getElement(firstnameInput, FIRST_NAME_INPUT);
    element.clear();
    element.sendKeys(firstname);
    return this;
  }
}

Třída pro učitelské Menu (kráceno):

public class Tea_Menu_Page extends PageObject {
  // ID's on web page
  public static final String MYSUBJECTS_LINK = "tea.menu.mySubjects";
////////////
  @FindBy(id = MYSUBJECTS_LINK)
  private List mySubjects;

  /**
   * Go to Teacher's My Subjects page
   *
   * @return TeaMySubjectsPage
   */
  public Tea_MySubjects_Page clickOnMySubjectsLink() {
    Click.clickAndWaitURL(getElement(mySubjects, MYSUBJECTS_LINK),
            Url.URL_TEA_MYSUBJECTS);
    return new Tea_MySubjects_Page();
  }
}

A když se vytváří třída pro popis webové stránky Teacher's View, je tato třída sympaticky jednoduchá (nekráceno!):

public class Tea_Overview_Page extends PageObject {
  // parts of the web page
  public Header_Page header= new Header_Page();
  public Tea_Menu_Page menu= new Tea_Menu_Page();
  public Overview_Page overview = new Overview_Page();
}

Všimněte si přístupových práv public u jednotlivých částí (tj. atributů). To pak umožňuje elegantní použití, které již bylo uvedeno dříve v ukázce:  page.overview.getFirstname();

Takže ještě jednou, jak lze otestovat, že element First name má hodnotu „Peter“:

Tea_Overview_Page page = new Tea_Overview_Page();
String actual = page.overview.getFirstname();
assertEquals("Peter", actual);

Závěr

Dobře rozmyšlené a pečlivě připravené PageObject jsou sice jednorázově pracná záležitost, ale výrazným způsobem zpřehlední všechny další práce s ovládáním webových stránek. Navíc jsou jednoduše rozšiřitelné a v případě potřeby lze snadno ověřit jejich správnost. Pokud jsem v nich psal nějakou složitější metodu (typicky to byl nějaký složitější parametrizovaný výběr z tabulky), hned jsem si tuto metodu otestoval jednotkovým testem. Samozřejmě, že pro naprostou většinu jednoduchých metod toto nebylo zapotřebí.

Poznámka k opakujícímu se kódu:
V mnoha třídách PageObject je víceméně se opakující kód. Tomu jsem se snažil bránit tím, že některé tyto kódy jsou ve třídách balíku selenium_utils. Ovšem nechtěl jsem opakující se kód odstraňovat důsledně (např. využitím různých možností objektově orientovaného programování jako je dědění, default metody v rozhraní apod.) protože jsem dával přednost čitelnému kódu. Je třeba si uvědomit, že se pohybujeme v testovací aplikaci, která by měla být především čitelná a až následně elegantní. Pokud ovšem např. PageObject dovolí skloubit čitelnost a elegancí, pak je to skvělé.

Dobře připravené PageObject mohou sloužit i jako knihovna pro další aktivity s aplikací. V případě UIS uvažujeme o jejich použití pro testy scénářů psané v RobotFrameworku. Pokud se to podaří, sepíši zkušenosti do další série článků.