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
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