UI-testing
med
Page
Object
Pattern

Introduksjon

Testing er en vesentlig del av programvareutvikling og har blitt en formell prosess i både backend - og frontendutvikling. Vi er flinkere enn noen gang til å skrive enhetstester og vi bruker verktøy som rapporterer testdekningen vår. I noen tilfeller er det også minimumskrav for testdekning før byggverktøyet godkjenner bygget. Enhetstester sier dog ingenting om samspillet mellom komponenter. For disse tilfellene benytter man integrasjonstester. Integrasjonstester er en type testing som fokuserer på å sjekke hvordan individuelle programvarekomponenter fungerer sammen når de er integrert. I likhet med enhetstester så vil integrasjonstester fungere som dokumentasjon samt bidra til å finne eventuelle feil som oppstår når komponenter samhandler. Dette kan utføres både manuelt og automatisk. Når vi arbeider med nettsider så er erfaringen min at testingen blir utført manuelt. Ved funksjonelle og komplekse nettsider vil dette ta tid og regresjonstesting blir nesten ikke utført. I disse tilfellene vil det gi en stor gevinst å skrive gode automatiserte integrasjonstester. I denne artikkelen vil jeg introdusere et designmønster som blir brukt for å skrive automatiserte UI-tester, nemlig Page Object Pattern.

Hva er Page Object Pattern?

Page Object Pattern er et designmønster for testautomatisering som ble introdusert av Selenium-testautomatiseringssamfunnet. Selenium er et populært testautomatiseringsrammeverk for nettleserbaserte applikasjoner. Mange av utfordringene med å skrive robust og vedlikeholdbar testkode på denne plattformen førte til utviklingen av Page Object Pattern. Før Page Object Pattern ble introdusert var det vanlig å skrive testkoden direkte mot nettsiden uten noen form for abstraksjon. Dette gjorde testene sårbare for endringer i nettsidens layout som førte til at testene brøt sammen og ble vanskeligere å vedlikeholde.

Fordelene med Page Object Pattern

Page Object Pattern brukes til å skrive robust og vedlikeholdbar testkode. Det handler om å representere nettsider (eller deler av nettsider) som objekter i testkoden. Dette gjør det lettere å vedlikeholde testene når nettsiden endres, da endringene kun vil påvirke objektene i testkoden og ikke selve testlogikken. Hovedfordelen med Page Object Pattern er at det gjør testene mer leselige og forståelige. Ved å representere nettsiden som objekter, kan testene leses som en slags brukerhistorie, hvor handlingene som utføres på nettsiden, blir mer intuitive og forståelige. Dette kan også gjøre testene mer modulære og gjenbrukbare, da objektene kan brukes i forskjellige tester og på forskjellige steder i testene. En annen fordel med Page Object Pattern er at det kan gjøre testene mer robuste og pålitelige. Ved å bruke objekter for å representere nettsiden, kan testene håndtere feil og unntakstilfeller på en mer strukturert måte. For eksempel, hvis en side tar lang tid å laste, kan man bruke Page Object Pattern for å vente på at siden skal lastes før man utfører neste handling. Til slutt kan Page Object Pattern bidra til å redusere vedlikeholdskostnadene for testene. Når nettsiden endres, vil endringene kun påvirke objektene i testkoden og ikke selve testlogikken. Dette betyr at man kan opprettholde testene med minimal innsats, selv når nettsiden endres.

Vis med eksempel

For å demonstrere fordelene med Page Object Pattern har jeg opprettet et GIT-repository som inneholder en React applikasjon og et Java program som bruker Selenium og Page Object Pattern til UI-testing. React applikasjonen er en klassisk TODO-app som viser en liste med TODO-er med mulighet for å legge til flere, redigere og slette. I kontekst av at denne nettsiden er bygd utelukkende for å demonstrere Page Object Pattern så er den designet med flere unike komponenter. La oss ta en titt på hvordan vi kan skrive gode automatiserte tester for TODO-appen.

public class TodoComponentTest {
    
    private final String URL = "http://localhost:3000/";
    private WebDriver webDriver;
    private HomePageObject homePageObject;

    @BeforeEach
    void setup() {
        webDriver = new ChromeDriver();
        webDriver.get(URL);
        this.homePageObject = new HomePageObject(webDriver);
    }

    @AfterEach
    void tearDown() {
        webDriver.quit();
    }
}

Vi oppretter en testklasse som vanlig i Java. Vi benytter selenium-java som motor for å kjøre testene. I koden over vil vi instansiere en ny Google Chrome Driver og peke den mot React-applikasjonen vår som kjører på localhost:3000. Slik testen er konfigurert her vil kjøringen av testen åpne et Google Chrome vindu og laste inn nettsiden. All interaksjon vil du derfor kunne observere foran deg. Om dete kjøres på en byggserver så er det mer hensiktsmessig å kjøre i headless-mode. Dersom du ønsker å kjøre dette selv så finnes det drivere for de fleste nettlesere. Vi ser også at det blir instansiert en ny HomePageObject-klasse. Dette er vår klasse og representer det som blir lastet når driveren laster inn localhost:3000. Vi kan bruke denne klassen til å skrive tester som ser slik ut:

@Test
public void verifyExistingTodosInTable() {
    homePageObject
    .countTodos(5)
    .verifyTodoDescriptionInTable(1, "Create a todo app that can be tested using the Page Object Pattern")
    .verifyTodoDescriptionInTable(2, "Conjure clever test data")
    .verifyTodoDescriptionInTable(3, "Use Bootstrap instead of styling the example app yourself")
    .verifyTodoDescriptionInTable(4, "Initialize git")
    .verifyTodoDescriptionInTable(5, "Write the article");
}

Personlig synes jeg dette er en lettlest enhetstest og hver handling er knyttet til en handling et menneske ville utført. La oss ta en titt på koden til HomePageObject.

public class HomePageObject {
    
    private WebDriver webDriver;

    @FindBy(id="todo-table")
    private WebElement todoTable;
    
    @FindBy(how = How.XPATH, using = "//table/tbody/tr")
    private List<WebElement> tableRows;

    public HomePageObject(WebDriver webDriver) {
        this.webDriver = webDriver;
        PageFactory.initElements(webDriver, this);
        new WebDriverWait(webDriver, Duration.ofSeconds(3)).until(ExpectedConditions.visibilityOf(todoTable));
    }

    public HomePageObject verifyTodoDescriptionInTable(int rowNumber, String expectedDescription) {
        WebElement tableDataElement = webDriver.findElement(descriptionFieldAtRowNumber(rowNumber));
        String actualDescription = tableDataElement.getText();
        Assertions.assertEquals(expectedDescription, actualDescription);
        return this;
    }

    public HomePageObject countTodos(int count) {
        Assertions.assertEquals(count, tableRows.size());
        return this;
    }
}

Hensikten med HomePageObject er å enkapsulere alle elementer man ønsker å verifisere eller interagere med. Elementene kan instansieres som fields i klassen og kan automatisk bli identifisert av Selenium ved å annotere dem med @FindBy. Denne metoden krever at elementet er synlig ved innlasting. Dynamiske elementer kan eksponeres via metoder i klassen. Disse metodene eksponeres til testklassen og bidrar til at testen på en lettlest måte kan utføre handlinger på nettsiden. Se eksempel under for avhukning av sjekkboksen med navn 'Completed'.

public HomePageObject clickOnCompletedCheckboxAtRowNumber(int rowNumber) {
    WebElement checkbox = webDriver.findElement(completedCheckboxAtRowNumber(rowNumber));
    checkbox.click();
    return this;
}

public HomePageObject checkboxAtRowShouldBe(int rowNumber, boolean expected) {
    WebElement checkbox = webDriver.findElement(completedCheckboxAtRowNumber(rowNumber));
    Assertions.assertEquals(expected, checkbox.isSelected());
    return this;
}

private By completedCheckboxAtRowNumber(int rowNumber) {
    return By.xpath(String.format("//table/tbody/tr[%d]/td[3]/input", rowNumber));
}

Om man har flere sider man ønsker å teste så oppretter man et Page Object for hver av disse. I vårt eksempel tilbyr HomePageObject funksjonalitet som navigerer testen til en ny side hvor det er mulig å legge til flere TODO-er.

@FindBy(id="addNewTodoButton")
private WebElement addNewTodoButton;

(...)

public AddPageObject clickOnAddNewTodo() {
    addNewTodoButton.click();
    return new AddPageObject(webDriver);
}

Metoden clickOnAddNewTodo trykker på en knapp på nettsiden som navigerer brukeren til en ny side. For å sørge for at dette gjenspeiles i testen så returneres også et nytt Page Object til den kjørende testen; AddPageObject. AddPageObject følger samme mønster som HomePageObject:

public class AddPageObject {

    private WebDriver webDriver;

    @FindBy(id="description-field")
    private WebElement descriptionField;

    @FindBy(id="cancel-button")
    private WebElement cancelButton;

    @FindBy(id="save-button")
    private WebElement saveButton;

    public AddPageObject(WebDriver webDriver) {
        this.webDriver = webDriver;
        PageFactory.initElements(webDriver, this);
    }

    public AddPageObject fillDescriptionField(String description) {
        descriptionField.sendKeys(description);
        return this;
    }

    public HomePageObject clickCancelButton() {
        cancelButton.click();
        return new HomePageObject(webDriver);
    }

    public HomePageObject clickSaveButton() {
        saveButton.click();
        return new HomePageObject(webDriver);
    }

 }

Dette designmønsteret bidrar til at man kan skrive automatiserte integrasjonstester som emulerer hvordan man ville testet nettsiden manuelt for flere brukerhistorier. Testene kan kjøres som regresjonstester ved endringer for å forsikre at tidligere implementert funksjonalitet fungerer som tiltenkt. Personlig opplever jeg også testene blir mer lettlest. Her er flere eksempler på tester av TODO-applikasjonen.

@Test
public void addNewTodo() {
    homePageObject
    .countTodos(5)
    .clickOnAddNewTodo()
    .fillDescriptionField("A brand new TODO")
    .clickSaveButton()
    .countTodos(6)
    .verifyTodoDescriptionInTable(6, "A brand new TODO");
}

@Test
public void markSomeTodosAsCompleted() {
    homePageObject
    .countTodos(5)
    .checkboxAtRowShouldBe(1, false)
    .clickOnCompletedCheckboxAtRowNumber(1)
    .clickOnCompletedCheckboxAtRowNumber(3)
    .checkboxAtRowShouldBe(1, true)
    .checkboxAtRowShouldBe(2, false)
    .checkboxAtRowShouldBe(3, true);
}

@Test
public void AddNewTodoButChangeYourMindAndCancel() {
    homePageObject
    .countTodos(5)
    .clickOnAddNewTodo()
    .clickCancelButton()
    .countTodos(5);
}

@Test
public void editATodoDescription() {
    homePageObject
    .verifyTodoDescriptionInTable(1, "Create a todo app that can be tested using the Page Object Pattern")
    .clickEditButtonAtRowNumber(1)
    .fillDescription("This description has been altered")
    .clickSaveButton()
    .countTodos(5)
    .verifyTodoDescriptionInTable(1, "This description has been altered");
}

@Test
public void deleteATodo() {
    homePageObject
    .countTodos(5)
    .clickEditButtonAtRowNumber(1)
    .clickDeleteButton()
    .countTodos(4);
}

Avslutning

Dersom du har kommet helt ned hit så takker jeg for at du har lest artikkelen min. Jeg håper den ga verdi for deg! Det finnes flere eksempler på Github dersom du er interessert i å utforske mer på egenhånd. Dersom du lurer på noe, feedback, eller ser en feil. Ikke nøl med å kontakt meg på oya@progit.no

Kilder

* Selenium Page Object Pattern