Refaktoryzacja testów - App Actions vs Page Object Model

Wstęp

Jak niektórzy w komentarzach zdążyli zauważyć kod testów jest bardzo niskiej jakości, dlatego dzisiaj postaram się poprawić scenariusze i przejść proces ich refaktoryzacji. Pierwsze co przychodzi do głowy w tej sytuacji to użycie wzorcu Page Object Model, aby uczynić nasze testy bardziej logicznymi jednoznacznie uwypuklając ich sens biznesowy. W dokumentacji jednak, znalazłem taki wpis: Stop using Page Objects and Start using App Actions sugeruje on jednak, aby spróbować podejścia do problemu z innej strony - dlatego implementuje oba sposoby i zobaczę, co z tego będzie.

Poprzedni post w tej tematyce znajdziesz tutaj: Testy logowania z Cypress

Analiza historii użytkownika oparta na AI

Odblokuj pełny potencjał swojego procesu rozwoju oprogramowania dzięki naszemu narzędziu opartemu na sztucznej inteligencji! Znajdziesz je tutaj.

Defect zero

App Actions

Jako, że jestem ciekawy nowego podejścia zaczynam od App Actions, bo tak właśnie jest ono nazwane. Tworzę nowy plik o nazwie:

LoginTests.AppActions.spec.js

Następnie kopiuje do niego zawartość pliku LoginTests.spec.js Okazuję się, że twórcy narzędzia wystawili API umożliwiające rozszerzenie wachlarza funkcji dostępnych pod obiektem:

cy.<custom_command>

Do tworzenia tego typu funkcji przewidziano właściwe miejsce - plik w katalogu:

cypress/support/index.js

Dodaje tutaj część kodu, którą używam do procesu logowania się poprzez formatkę na stronie. Dodaje taki kod:

Cypress.Commands.add('login', (username, password) => {
    Cypress.log({
        name: 'login',
        message: `${username} | ${password}`,
    })
    cy.get(':nth-child(1) > .form-control')
        .type(username)
    cy.get(':nth-child(2) > .form-control')
        .type(password)
    cy.get('.btn')
           .click()
})

Następnie przechodzę do zmian w pliku: LoginTests.AppActions.spec zmieniając sposób obsługi wydarzenia logowania się użytkowników teście Successfull login:

it('Successfull login', function () {
    (...)
    cy.visit('http://localhost:4100/login')
    cy.login('test@test.com', 'test')
    cy.url()
    .should('contain', 'http://localhost:4100/')
    cy.get(':nth-child(4) > .nav-link')
    (...)
})

Zwijam również do osobnych funkcji zapytania do API tj. wszystkich akcji typu: cy.request:

Cypress.Commands.add('createNewUserAPI', (username, email, password) => {
    Cypress.log({
        name: 'createNewUserAPI',
        message: `${username} | ${email}| ${password}`
    })
    cy.request('DELETE', 'http://localhost:5000/users', {
        user: {
        username: 'test',
        email: 'test@test.com',
        password: 'test'
        }
    })
    cy.request('POST', 'http://localhost:5000/users', {
        user: {
        username: 'test',
        email: 'test@test.com',
        password: 'test'
        }
    })
})

Mój test wygląda teraz dużo prościej:

it('Successfull login', function () {
    cy.createNewUserAPI('test', 'test@test.com', 'test')
    cy.visit('http://localhost:4100/login')
    cy.login('test@test.com', 'test')
    cy.url()
        .should('contain', 'http://localhost:4100/')
    (...)
})

Podążam tym tropem podczas refaktoryzacji i aktualizuje kolejne przypadki. Okazuje się, że w wielu miejscach mogę użyc ponownie funkcje cy.login oraz cy.createNewUserAPI. Podczas pracy orientuje się, że potrzebuje jeszcze funkcji: cy.deleteUser:

Cypress.Commands.add('deleteUserAPI', (username, email, password) => {
    Cypress.log({
        name: 'deleteUserAPI',
        message: `${username} | ${email}| ${password}`
    })
    cy.request('DELETE', 'http://localhost:5000/users', {
        user: {
            username: 'test',
            email: 'test@test.com',
            password: 'test'
        }
    })
})

Ponadto użycie tego podejścia pozwala na chaining funkcji. Praktyczne użycie App Actions powoduje, że test Successfull login wygląda dużo przejrzyściej.

it('Successfull login', function () {
    cy.createNewUserAPI('test', 'test@test.com', 'test')
    .visit('http://localhost:4100/login')
    .login('test@test.com', 'test')
    .url()
        .should('contain', 'http://localhost:4100/')
    .get(':nth-child(4) > .nav-link')
        .should('have.attr', 'href', '/@test')
    .get(':nth-child(3) > .nav-link')
        .should('have.attr', 'href', '/settings')
    .get('.container > .nav > :nth-child(2) > .nav-link')
        .should('have.attr', 'href', '/editor')
})

Myślę, że można jeszcze ukryć techniczne zawiłości w asercji testu ukrywając jej logikę w Custom Command:

    Cypress.Commands.add('shouldBeLoggedIn', (username, email, password) => {
        Cypress.log({
            name: 'shouldBeLoggedIn',
            message: `${username} | ${email}| ${password}`
        })
        cy.get(':nth-child(4) > .nav-link')
            .should('have.attr', 'href', '/@test')
            .get(':nth-child(3) > .nav-link')
            .should('have.attr', 'href', '/settings')
            .get('.container > .nav > :nth-child(2) > .nav-link')
            .should('have.attr', 'href', '/editor')
    })

To samo mogę zrobić dla walidowania wyświetlonego komunikatu o błędzie i walidacji adresu URL:

Cypress.Commands.add('shouldErrorMessageBeValid', (text) => {
    Cypress.log({
        name: 'shouldErrorMessageBeValid',
        message: `${text}`
    })
    cy.get('.error-messages > li')
        .should('have.text', text)
})

Cypress.Commands.add('shouldErrorMessagesBeValid', (message, secondeMessgae) => {
    Cypress.log({
        name: 'shouldErrorMessagesBeValid',
        message: `${message} | ${secondeMessgae}`
    })
    cy.get('.error-messages > :nth-child(1)')
        .should('have.text', message)
        .get('.error-messages > :nth-child(2)')
        .should('have.text', secondeMessgae)
})

Cypress.Commands.add('shouldUrlContain', (url) => {
    Cypress.log({
        name: 'shouldUrlContain',
        message: `${url}`
    })
    cy.url()
        .should('contain', url)
})

Po zastosowaniu powyższych praktyk kończymy z testami wyglądającymi w ten sposób:

describe('Login Tests App Actions', function () {
    it('Successfull login', function () {
        cy.createNewUserAPI('test', 'test@test.com', 'test')
            .visit('http://localhost:4100/login')
            .login('test@test.com', 'test')
            .shouldUrlContain('http://localhost:4100/')
            .shouldBeLoggedIn('test', 'test@test.com', 'test')
    })

    it('Incorrect password', function () {
        cy.createNewUserAPI('test', 'test@test.com', 'test')
            .visit('http://localhost:4100/login')
            .login('test@test.com', 'test2')
            .shouldUrlContain('http://localhost:4100/login')
            .shouldErrorMessageBeValid('Error Invalid email / password.')
    })

    it('Not existing user', function () {
        cy.deleteUserAPI('test', 'test@test.com', 'test')
            .visit('http://localhost:4100/login')
            .login('test@test.com', 'test')
            .shouldUrlContain('http://localhost:4100/login')
            .shouldErrorMessageBeValid('Error Invalid email / password.')
    })

    it('Empty fields', function () {
        cy.visit('http://localhost:4100/login')
            .login('', '')
            .shouldUrlContain('http://localhost:4100/login')
            .shouldErrorMessagesBeValid('\'Email\' must not be empty.', '\'Password\' must not be empty.')
    })
})

Okazuje się, że test: Empty fields zakończył się niepowodzeniem z powodu błędu: CypressError: cy.type() cannot accept an empty String. You need to actually type something. Oznacza to, że wysłanie ciągu znaków o wartości: do funkcji cy.type kończy się niepowodzeniem, ponieważ nie jest to poprawne użycie tej funkcji. Wolę podejście z Selenium, gdzie nie jest to problemem. Okazuje się jednak, że twórcy Cypressa są innego zdania na ten temat i nie jest to defekt w ich mniemaniu: GitHub Issue - .type() will not accept an empty string. Ja wolę móc swobodnie sparametryzować utworzoną przeze mnie funkcję w sposób, w jaki ją utworzyłem, dlatego modyfikuje kod funkcji cy.login aby pominąć interakcje z polami login i password, jeżeli argumenty przekazane do funkcji są pustymi ciągami znaków:

Cypress.Commands.add('login', (username, password) => {
    Cypress.log({
        name: 'login',
        message: `${username} | ${password}`,
    })
    if (username) {
        cy.get(':nth-child(1) > .form-control')
            .type(username)
    }
    if (password) {
        cy.get(':nth-child(2) > .form-control')
            .type(password)
    }
    cy.get('.btn')
        .click()
})

Po powyższych zmianach okazuje się, że same testy są dużo czytelniejsze, jednak sporo ze złych praktyk zostało przesuniętych do pliku commands.js. Większośći z nich nie będę na ten moment poprawiał, zamierzam jednak logiczniej ułożyć pliki i odseparować od siebie osobne bloki logiczne. Tworzę nowy folder o nazwie login na ścieżce cypress/support/. W tym folderze tworzę plik o nazwie: loginCommands.js i kopiuję z pliku commands.js implementacje funkcji cy.login. Następnie tworzę kolejny plik loginAssertionsCommands.js i kopiuję do niego implementacje funkcji: cy.shouldBeLoggedIn, cy.shouldErrorMessageBeValid(arg) i cy.shouldErrorMessagesBeValid(arg, arg) Analogiczne operacje wykonuję dla funkcji cy.createNewUserAPI, cy.deleteUserAPI i cy.shouldUrlContain Ostatecznie, aby mieć dostęp do tych funkcji w nowo utworzonych plikach muszę zmodyfikować plik: index.js w katalogu: cypress/support/index.js i dodać odpowiednie importy:

import './commands'
import './api/apiCommands'
import './common/urlAssertionsCommands'
import './login/loginAssertionsCommands'
import './login/loginCommands'

Po tej operacji na pewno będzie łatwiej znaleźć interesujące nas funkcje, ponieważ w mojej opinii są o wiele logiczniej poukładane. Benefity tej refaktoryzacji będzie można odczuć dopiero wtedy, kiedy ilość funkcji będzie rosnąć wraz z pokryciem testami funkcji aplikacji.

Nasuwa się jeszcze jedno pytanie: dlaczego to działa? Przecież te funkcje nie powinny być widoczne w testach. Ten mechanizm funkcjonuje, ponieważ wszystkie zależności z pliku: cypress/support/index.js są automatycznie załadowane przed wykonaniem każdej specyfikacji przez mechanizm Cypressa.

App Actions - commit

Wszystkie zmiany w kodzie dotyczące App Actions znajdziesz tutaj.

{% include_relative subForm.markdown %}

Page Object Model

Zastosowanie wzorca Page Object Model jest również jak najbardziej możliwe.

Tworzę nowy plik o nazwie:

LoginTests.PageObjectModel.spec.js

Następnie kopiuje do niego zawartość pliku LoginTests.spec.js Kolejnym krokiem jest utworzenie nowej klasy, która będzie reprezentować obiekt strony logowania. Tworzę ją w tym miejscu: cypress/pageobjects/LoginPage.js Następnie prototypuje jak będzie wyglądać użycie klasy w teście, wiem, że kod będzie wyglądał mniej więcej tak:

it('Successfull login', function () {
  (...)
  
  cy.visit('http://localhost:4100/login')
        
  const homePage = new LoginPage()
    .login('test@test.com', 'test')
  
  cy.url()
    .should('contain', 'http://localhost:4100/')
  (...)
})

Widzę też, że będę potrzebował klasy HomePage która będzie reprezentować stronę domową aplikacji. Tworzę ją w tym miejscu projektu: cypress/pageobjects/HomePage.js i przechodzę do implementacji funkcji LoginPage.login:

import HomePage from './HomePage'

class LoginPage {
    login(email, password) {
        cy.get(':nth-child(1) > .form-control')
            .type(email)
        cy.get(':nth-child(2) > .form-control')
        .type(password)
        cy.get('.btn')
            .click()

        return new HomePage()
    }
}

export default LoginPage;

Uruchamiam test, otrzymuje błąd: ReferenceError: LoginPage is not defined okazuję się, że w pliku z testami zapomniałem zaimportować użytych przeze mnie nowo utworzonych klas, naprawiam to dodając następujący kod w pliku: LoginTests.PageObjectModel.spec.js

    import LoginPage from '../pageobjects/LoginPage'

Kontynuuje prototypowanie dla asercji:

it('Successfull login', function () {
    (...)

    cy.visit('http://localhost:4100/login')

    const homePage = new LoginPage()
        .login('test@test.com', 'test')

    homePage.url()
        .should('contain', 'http://localhost:4100/')
    homePage.userProfile()
        .should('have.attr', 'href', '/@test')
    homePage.settings()
        .should('have.attr', 'href', '/settings')
    homePage.editor()
        .should('have.attr', 'href', '/editor')
})

Już wiem jak ma wyglądać mój interfejs(to na razie tylko klasa, jednak myślę o niej jak o przyszłym interfejsie), dlatego też przechodzę do implementacji:

class HomePage {
    url() {
        return cy.url()
    }

    userProfile() {
        return cy.get(':nth-child(4) > .nav-link')
    }

    settings() {
        return cy.get(':nth-child(3) > .nav-link')
    }

    editor() {
        return cy.get('.container > .nav > :nth-child(2) > .nav-link')
    }
}

export default HomePage;

Następnie wykonuję te same operacje dla reuqestów do API. Utworzę nową klasę, którą odpowiedzialnością będzie zarządzanie stanem użytkownika. Prototypuję jak chciałbym żeby wyglądał test:

it('Successfull login', function () {
    new User('test', 'test@test.com', 'test')
        .remove()
        .create()

    cy.visit('http://localhost:4100/login')
    const homePage = new LoginPage()
        .login('test@test.com', 'test')

    homePage.url()
        .should('contain', 'http://localhost:4100/')
    homePage.userProfile()
        .should('have.attr', 'href', '/@test')
    homePage.settings()
        .should('have.attr', 'href', '/settings')
    homePage.editor()
        .should('have.attr', 'href', '/editor')
})

Kiedy już wiem co chcę osiągnąć przechodzę do implementacji klasy: cypress/requests/User.js:

class User {

    constructor(username, email, password) {
        this.username = username;
        this.email = email;
        this.password = password;
    }

    create() {
        Cypress.log({
        name: 'Log.User.create()',
        message: `${this.username} | ${this.email} | ${this.password}`
        })

        cy.request('POST', 'http://localhost:5000/users', {
        user: {
            username: this.username,
            email: this.email,
            password: this.password
        }
        })
        return this;
    }

    remove() {
        Cypress.log({
        name: 'Log.User.remove()',
        message: `${this.username} | ${this.email} | ${this.password}`
        })
        cy.request('DELETE', 'http://localhost:5000/users', {
        user: {
            username: this.username,
            email: this.email,
            password: this.password
        }
        })
        return this;
    }
}

export default User;

Teraz kiedy mam pierwszy test, który wygląda tak jakbym tego oczekiwał kontynuuję refaktoryzacje kolejnych scenariuszy. Okazuje się że dla testu: Incorrect password utworzona funkcja LoginPage.login(arg, arg) nie jest wystarczająca, ponieważ zwraca obiekt typu HomePage, a wiemy, że po niepoprawnym logowaniu powinniśmy pozostać na PageObject typu LoginPage. Dlatego też wymagana jest zmiana tej klasy. Klasa LoginPage po refaktoryzacji będzie wyglądać tak:

import HomePage from './HomePage'

class LoginPage {
    login(email, password) {
        if (email) {
            cy.get(':nth-child(1) > .form-control')
                .type(email)
        }
        if (password) {
            cy.get(':nth-child(2) > .form-control')
                .type(password)
        }
        cy.get('.btn')
            .click()
    }

    loginIncorrectPass(email, password) {
        this.login(email, password)
        return new LoginPage()
    }

    loginCorrectPass(email, password) {
        this.login(email, password)
        return new HomePage()
    }

    url() {
        return cy.url()
    }

    errorMessage() {
        return cy.get('.error-messages > li')
    }
}

export default LoginPage;

Po refaktoryzacji wszystkich testów używając wzorca otrzymuję taki kod:

import LoginPage from '../pageobjects/LoginPage'
import User from '../requests/User'

describe('Login Tests Page Objects', function () {
  it('Successfull login', function () {
    new User('test', 'test@test.com', 'test')
      .remove()
      .create()

    cy.visit('http://localhost:4100/login')
    const homePage = new LoginPage()
      .loginCorrectPass('test@test.com', 'test')

    homePage.url()
      .should('contain', 'http://localhost:4100/')
    homePage.userProfile()
      .should('have.attr', 'href', '/@test')
    homePage.settings()
      .should('have.attr', 'href', '/settings')
    homePage.editor()
      .should('have.attr', 'href', '/editor')
  })

  it('Incorrect password', function () {
    new User('test', 'test@test.com', 'test')
      .remove()
      .create()

    cy.visit('http://localhost:4100/login')

    const loginPage = new LoginPage()
      .loginIncorrectPass('test@test.com', 'test-incorrect')

    loginPage.url()
      .should('contain', 'http://localhost:4100/login')
    loginPage.errorMessage()
      .should('have.text', 'Error Invalid email / password.')
  })

  it('Not existing user', function () {
    new User('test', 'test@test.com', 'test')
      .remove()

    cy.visit('http://localhost:4100/login')

    const loginPage = new LoginPage()
      .loginIncorrectPass('test@test.com', 'test')

    loginPage.url()
      .should('contain', 'http://localhost:4100/login')
    loginPage.errorMessage()
      .should('have.text', 'Error Invalid email / password.')
  })

  it('Empty fields', function () {
    cy.visit('http://localhost:4100/login')

    const loginPage = new LoginPage()
      .loginIncorrectPass('', '')

    loginPage.url()
      .should('contain', 'http://localhost:4100/login')
    loginPage.errorMessage()
      .should('have.length', 2)
    loginPage.errorMessage().first()
      .should('have.text', '\'Email\' must not be empty.')
      .next()
      .should('have.text', '\'Password\' must not be empty.')
  })
})

Z powodzeniem udało mi się zaimplementować wzorzec Page Object Model. Tak samo, jak dla App Actions, w dalszym ciągu jest sporo do poprawy, jednak na ten moment skupiłem się na podstawowej implementacji założeń wzorca.

Page Object Model - commit

Wszystkie zmiany w kodzie dotyczące Page Object Model znajdziesz tutaj.

Podsumowanie

To, co spodobało mi się to możliwość głębokiej integracji z narzędziem, nie potrzebujemy - przynajmniej na tym etapie produkcji testów, tworzyć kolejnego frameworku, bo mamy już gotowy i swobodnie nim zarządzamy rozszerzając jego funkcje. Ponadto testy napisane tym podejściem są bardzo czytelne. Spodziewałem się jednak czegoś więcej, wydaje mi się, że wraz z rozwojem zarządzanie tymi funkcjami stanie się uciążliwe i zbyt mało zorganizowane. Równocześnie myślę, że dopisanie większej ilości scenariuszy sprawi, że będę musiał głębiej przeanalizować problem i zmusi mnie to do okrycia potencjału drzemiącego w tym podejściu. Cieszy mnie za to fakt, że mamy wybór, z łatwością można zaimplementować wzorzec Page Object i pozostać przy starych nawykach, jeżeli nam na tym zależy.

Całość zmian znajdziesz w moim repo na branchu, tutaj:

https://github.com/12masta/react-redux-realworld-example-app/tree/3-cypress

Changeset:

https://github.com/12masta/react-redux-realworld-example-app/pull/3/files