Nasza przygoda z Cypress zaczęła się na etapie poszukiwań nowego framework do testów E2E (end to end), który miał zastąpić Behata. Były dwa powody tych poszukiwań. Pierwszym z nich, był brak rozwoju Behata i zakończenie jego wsparcia przez swoich twórców. Drugim powodem był szybki rozwój naszej aplikacji frontowej, pisanej w asynchronicznym Angular.js.  

Przed wyborem, razem z zespołem Frontend, zdiagnozowaliśmy kryteria, które miało spełnić nowe rozwiązanie w celu optymalizacji naszej pracy. Tymi kryteriami były:

- posiadanie zakresu funkcjonalnego jak Behat,
- bycie asynchronicznym rozwiązaniem,
- pomijanie warstwy Selenium.

Podczas testów różnych frameworków wybraliśmy Cypress’a. Już w momencie jego pierwszych testów, a było to kilka lat temu, zapowiadał się obiecująco. Jednym z głównych powodów jego wyboru była możliwość nagrywania przypadków testowych, co było i jest cennym dodatkiem przy szukaniu problemów z testem lub aplikacją oraz fakt posiadania bardzo czytelnych raportów generowanych przez plugin mocha. Poza tym, przy każdej kolejnej aktualizacji, twórcy Cypress zaskakiwali nas szybkością wprowadzania poprawek oraz dodatkowych funkcjonalności.

I tak, w roku 2019 roku, rozpoczęliśmy intensywne prace nad przeniesieniem scenariuszy z Behat do Cypress. Wymagało to od testerów zmiany (nie bez bólu) języka programowania z PHP na JavaScript. Finalnie, po realizacji kilku szkoleń oraz dzięki pomocy Frontend Developerów byliśmy w stanie dopasować Cypress do poziomu, który był dla nas zadowalający. A ten poziom charakteryzował się:

- obsługą kilkunastu projektów,
- posiadania transakcji językowych,
- nadawaniem się do "wpięcia" w proces CI/CC.

Czy Cypress jest uniwersalnym narzędziem do testów?

Wraz ze wzrostem popularności Cypress, jego twórcy starają się zapewnić mu multifunkcyjność. I tak z raczkującego frameworku, w którym zaczęliśmy pracę, stał się potężnym narzędziem, które wyprzedziło nasze oczekiwania i zapewniło możliwość pokrycia testami wszystkich poziomów standardowej piramidy testów. Do tego dokumentacja techniczna jest jedną z najlepszych jakie widziałem. Wszystko jest poparte przykładami użycia i zdjęciami, a aktualizacje dokonywane są na bieżąco i równolegle z rozwojem aplikacji.

Cypress jest nie tylko narzędziem do testów E2E aplikacji webowych, ale również umożliwia testowanie jednostkowe komponentów Angular czy React. Poniżej kilka przykładów użycia:

- Testy jednostkowe aplikacji frontowych React / Angular w izolacji.

   it('User can log in', () => {
       cy.mount(<UserLoginForm />);
       cy.contains('Login').find('input').type('login');
       cy.contains('Password').find('input').type('password');
       cy.get('button').contains('Submit').click();


       cy.get('[data-qa-info]').contains('User is logged in')
           .should('be.visible');
   });
1. Przykład zastosowania Cypress do testów komponentu formularza poprawnego logowania.
   it('Check user login validation, () => {
       cy.mount(<UserLoginForm />);
       cy.contains('Login').find('input').type('login');
       cy.contains('Password').find('input').type('password');
       cy.get('button').contains('Submit').click();


       cy.get('[data-qa-info]').contains('Invalid user or password')
           .should('not.be.visible');
   });
2. Przykład zastosowania Cypress do testów walidacji komponentu formularza rejestracji.

- Testy integracyjne - sprawdzanie requestów.

import { host } from '../../../support/AppConfig';


it('Get some object', () => {
   const id = 51;
   cy.request({
       method: 'get',
       url: `${host}/api/v2/get/some_object/${id}`,
       auth: {
           user: 'user',
           pass: 'password'
       }
   }).then((response) => {
       assert.deepEqual(response.body, {
           id,
           name: 'someName',
           symbol: 'sb'
       });
   });
});
3. Przykład zastosowanie Cypress do testów integracyjnych - request „get”.
import { host } from '../../../support/AppConfig';


it('Create some object', () => {
   cy.request({
       method: 'get',
       url: `${host}/api/v2/create/some_object`,
       auth: {
           user: 'user',
           pass: 'password'
       },
       body: {
           name: 'someName',
           symbol: 'sb'
       }
   }).then((response) => {
       assert.deepEqual(response.body, {
           name: 'someName',
           symbol: 'sb'
       });
   });
});
4. Przykład zastosowanie Cypress do testów integracyjnych - request „post”.

- Testy akceptacyjne E2E:

it('Create new user', () => {
   cy.createUser({
       login: 'user',
       password: 'password'
   }).then((user) => {
       cy.openUserLoginPage();
       cy.fillUserLogin(user.login);
       cy.fillUserPassword(user.password);
       cy.clickUserLoginSubmit().then(() => {
           cy.shouldSeeUserCorrectLoggedInMessage();
           cy.userIsLoggedIn(user.login)
       }); 
   });
});
5. Przykład zastosowanie Cypress do testów E2E z zastosowaniem commands

Każdy z poziomów (niekoniecznie piramidy) można przetestować za pomocą Cypress. Jednak w naszym przypadku główne zastosowanie to testy E2E.

Jak widać w ostatnim przykładzie, test został napisany przy pomocy komend Cypress i przed rozpoczęciem prac zastanawialiśmy się czy wybrać „Page object” czy ”Cypress commands”. Stosując się do zaleceń dokumentacji Cypress, jako że testy pisane z wykorzystaniem PO wydłużają ich czas trwania, zdecydowaliśmy się zastosować “Cypress commands”. Co nam to dało? W moim odczuciu poprawiona została czytelność testów dla osób, które nie muszą ich pisać. Komendy zdefiniowane są podobnie jak kroki “biznesowego” scenariusza testowego. Dzięki temu to, co test ma wykonać, jest zrozumiałe nie tylko dla testersko-developerskiej części naszego zespołu.

Kolejnym wyzwaniem, który musieliśmy rozwiązać było rozdzielenie Cypress na dużą ilość naszych projektów. Nie wszystkie projekty muszą mieć te same testy, nie wszystkie korzystają z tych samych elementów html. Udało się to wykonać dzięki możliwości załadowania odpowiedniego profilu podczas odpalenia testów.

6. Zastosowanie wielu plików konfiguracyjnych.
6. Zastosowanie wielu plików konfiguracyjnych.

Nazwę configu definiujemy w skrypcie napisanym w package.json. W skrypcie startu Cypress mamy możliwość wskazania pliku konfiguracyjnego oraz ścieżki do katalogu z testami. Skrypty te są naszymi skryptami startowymi Cypress’a w procesie CI/CD.

"scripts": {
 "cleanup": "rm -fr mochawesome-report",
 "merge": "mochawesome-merge mochawesome-report/*.json > mochawesome-report/output.json",
 "generate": "marge mochawesome-report/output.json --reportDir ./ --inline",
 "test": "echo \"Error: no test specified\" && exit 1",
 "cy:run": "cypress run",
 "cy:run:record": "cypress run --record",
 "cy:open": "cypress open",
 "e2e_mochawesome_raccoon": "npm run cleanup; cypress run --config-file=ci-configs/raccoon_cypress.config.ts --spec \"cypress/e2e/racoon/acceptance/*\" --browser chrome;",
 "e2e_mochawesome_frontend": "npm run cleanup; cypress run --config-file=ci-configs/frontend_cypress.config.ts --spec \"cypress/e2e/frontend/acceptance/*\" --browser chrome;",
 "e2e_mochawesome_admin": "npm run cleanup; cypress run --config-file=ci-configs/admin_cypress.config.ts --spec \"cypress/e2e/admin/acceptance/01/*\" --browser chrome;"
}
7. Przykład rozwinięcia skryptów dla wielu plików konfiguracyjnych.

Dodatkowo, w samym pliku konfiguracyjnym istnieje możliwość wyłączenia konkretnych plików z testami w sekcji e2e -> excludeSpecPattern, gdzie wskazujemy ścieżkę do plików, które nie powinny być obsłużone.

e2e: {
   supportFile: 'cypress/support/e2e.js',
   specPattern: 'cypress/e2e/frontend',
   excludeSpecPattern: [
     "**/*_b2b.cy.js",
     "**/recipe.cy.js",
     "**/cart.cy.js"
   ]
 }
8. Przykład wyłączania testów w pliku konfiguracyjnym.

Teraz trochę o możliwościach samego Cypress’a, które uratowały nas lub mocno pomogły w samym procesie tworzenia testów:

Dużą zaletą tego frameworku jest możliwość podania listy blokowanych hostów. Dzięki temu przestają być nam straszne, przykładowo: pop-upy dołączane przez klienta poprzez np. GTM. Wystarczy dodać host do listy w pliku konfiguracyjnym.

blockHosts: [
   'maps.googleapis.com',
   'sales-tracker.thulium.com',
   'chat-widget.thulium.com',
   'www.googletagmanager.com'
 ],
9. Przykład zastosowania blokedHosts w pliku konfiguracyjnym.

Inną, bardzo pomocną funkcją Cypress, jest możliwość mockowania requestów, czyli przechwytywania i podmieniania zawartości respons’a.

{
   "cart": {
       "id": "979ebc83-xxxx-xxxx",
       "currency": "PLN",
       "items": {
           "items": [
               {
                   "id": "1323",
                   "name": "Base",
                   "quantity": 1,
                   "price": {
                       "nett": 100,
                       "gross": 123,
                       "vat": 23,
                       "currency": "PLN"
                   }
               }
           ]
       },
       "finalizationOptions": [
           {
               "transportId": 1,
               "paymentId": 2,
               "name": "Poczta Polska  - przelew tradycyjny",
               "price": {
                   "nett": 18.7,
                   "gross": 23,
                   "vat": 23,
                   "currency": "PLN"
               }
           }
       ]
   }
}

10. Przykład fixtury (mocka) odpowiedzi dla requestu.

Tak przygotowaną zaślepkę, zapisaną wcześniej w fixturach, możemy wykorzystać w celu przyspieszenia testowania funkcjonalności, która wymaga odpowiedzi z aplikacji, by mogła się pojawić. Tak np. jest ze zgodami marketingowymi - wymagają zapewnienia zawartości koszyka oraz dostępnych metod transportu. „Zawartość” koszyka jest „zaślepiona” plikiem json. Tą możliwość daje nam komenda cy.intercept, dzięki której nie musimy tworzyć produktu, cennika, transportu, metody płatności, żeby móc zweryfikować zgody w koszyku. Zaoszczędzimy czas na wykonanie wszystkich tych operacji.

import cartsJson from '../../../fixtures/stubs/carts.json';


describe('Agreement', () => {
   it('Create agreement', () => {
       const symbol = 'a_' + getRandomLowCaseString(5);


       cy.openAgreementsMainPage();
       cy.clickAddNewAgreement();
       cy.setAgreementSymbol(symbol);
       cy.checkAgreementIsRequired();
       cy.submitAgreement().then(() => {
           cy.intercept('GET', 'api/frontend/*/carts/*', cartsJson);
           cy.visit('/cart');
           cy.shouldSeeAgreementInCart(symbol);
       });
   });
});
11. Przykład zastosowania komendy cy.intercept do mockowania zawartości requestu koszyka.

Teraz pokażę rozwój logowania użytkownika w celu przeprowadzania testów panelu administracyjnego. Aby przeprowadzić testy w panelu admina, musimy być zalogowani. Można to zrobić poprzez zalogowanie się, wpisując dane do logowania w sekcji beforeEach:

beforeEach(() => {
       cy.visit('admin/login');
       cy.get('#login').type('admin');
       cy.get('#password').type('password123');
       cy.get('#submit').click();
   });
12. Przykład logowania w sekcji BeforeEach.

Jednak proces ten jest po pierwsze czasochłonny, po drugie musi się wykonać przed każdym testem.

Innym rozwiązaniem jest zastosowanie autoryzacji logowania poprzez token JWT.

  let token;
   before(() => {
       cy.visit('admin/login');
       cy.get('#login').type('admin');
       cy.get('#password').type('password123');
       cy.get('#submit').click();
       cy.getCookie('auth_key').then((cookie) => {
           token = cookie.token;
       })
   });


   beforeEach(() => {
       cy.visit('admin');
       cy.setCookie('auth_key', token);
   });
13. Przykład logowania wykorzystujący ustawianie ciastka w sekcji BeforeEach.

Tutaj autoryzacja odbywa się w sekcji Before, a w sekcji BeforeEach podawany jest już token w formie ciastka i jesteśmy zalogowani przed każdym testem. Jak widać, zaoszczędziliśmy czas na kilkukrotne logowanie, jednakże nadal musimy otworzyć stronę, aby móc ustawić ciastko „auth_key”.

Tutaj z pomocą przychodzi Cypress ze swoimi możliwościami. Dodaliśmy i zrobiliśmy to w następujący sposób:

Cypress.Commands.add('visitPath', (url) => {
   if (url.indexOf('admin') === 0) {
       getConfigValue('token').then((tokens) => {
           if (tokens.normal.expirationTime < getTimestampInSeconds()) {
               cy.createUserToken('admin', 'password123');
           }
       }).then(() => {
           getConfigValue('token').then((token) => {
               return cy.visit(url, {
                   onBeforeLoad: (win) => {
                       window.document.cookie = `auth_key = ${token.accessToken}; expires=${token.expires}`;
                   }
               });
           });
       });
   } else {
       cy.visit(url)
   }
});
14. Przykład ustawienia tokenu JWT w onBeforeLoad w komendzie cy.visit

Użyliśmy funkcji getConfigValue, która pobiera nasz wcześniej zapisany token z pliku, następnie wpisujemy go w sekcję onBeforeLoad. Jest to bardzo dobre rozwiązanie, ponieważ przeglądarka przed wejściem na stronę ma już ustawione pliki cookies. Nie musimy uprzednio wchodzić na stronę, by (jak wcześniej podałem) skorzystać z komendy cy.setCookie. Oszczędzamy mnóstwo czasu, jednocześnie nie martwiąc się o logowanie i dodawanie zbędnego kodu w sekcjach before i BeforeEach.

Opisałem tylko kilka rozwiązań problemów, na które się napotkaliśmy oraz możliwości, jakie dał nam Cypress, aby je rozwiązać. Po latach doświadczenia z pracą nad automatyzacją testowania, jestem w stanie stwierdzić, iż wybór Cypress’a był strzałem w dziesiątkę. Jest narzędziem, które wyczerpuje w pełni nasze oczekiwania i zapotrzebowanie na automatyzację testów E2E oraz jest doskonałym uzupełnieniem testów integracyjnych opartych na codeception, testów jednostkowych oraz funkcjonalnych w naszym procesie CI/CD.

Autorem artykułu jest Łukasz Wieczorek