
27. Nov 2023Frontend
End-to-end testovanie
Naším cieľom v GoodRequeste je tvoriť zmysluplný a zodpovedný digitálny svet. Z tohto dôvodu sme sa už dlhodobo zamýšľali ako vylepšiť procesy testovania, zvýšiť efektivitu testovania v prípade väčších zmien v aplikácii, zaručiť funkčnosť kritických častí aplikácie, aby sme našim klientom doručili robustný a spoľahlivý softvérový produkt.


Aké sú dôvody a príčiny, prečo vývojári neimplementujú end-to-end testy?
Existuje mnoho dôvodov, prečo vývojári neimplementujú end-to-end testy, ale je dôležité mať na pamäti, že testovanie je kľúčovou súčasťou vývoja softvéru. End-to-end testy sú zvyčajne nákladné na implementáciu a udržiavanie, a niekedy je náročné ich napísať tak, aby boli spoľahlivé a efektívne.
Medzi dôvody a príčiny, prečo vývojári neimplementujú E2E testy, patrí:
- Nedostatok času: Vývojári často čelia nedostatku času počas vývoja a sústredia sa na implementáciu funkcionality namiesto testovania.
- Nedostatok zdrojov: Nie vždy majú tímy na testovanie dostatočný počet testovacích zdrojov, ako sú ľudské zdroje alebo finančné prostriedky na automatizáciu testovania.
- Komplexita testov: E2E testy môžu byť náročné na implementáciu a údržbu, pretože vyžadujú testovanie integrovaného systému, nie len jednotlivých častí. Problémom je aj celkové nastavenie testovacieho prostredia a samotné spúšťanie a vyhodnocovanie testov.
- Orientácia na iné typy testov: Vývojári sa môžu zamerať na iné druhy testov, ako sú napríklad unit testy alebo integračné testy, ktoré sú jednoduchšie na implementáciu a poskytujú rýchlejšiu spätnú väzbu.
- Nedostatočná motivácia: Ak nie je kladený dôraz na kvalitu softvéru alebo ak chýba povedomie o výhodách testovania, môže to viesť k nedostatku motivácie na implementáciu e2e testov.
Aj keď existujú tieto dôvody, je dôležité si uvedomiť, že testovanie je kľúčové pre zabezpečenie kvality softvéru a minimalizáciu chýb. Implementácia E2E testov môže viesť k robustnejším a spoľahlivejším softvérovým produktom, čo by malo byť cieľom každého vývojára.

Prečo sme zvolili Cypress?
V rámci tímu sme mali prvú skúsenosť s nástrojom Selenium, s ktorým je Cypress často porovnávaný. Samotné nastavenie a implementácia niektorých scenárov bola pomerne komplikovaná, a preto sme sa rozhodli skúsiť aj Cypress. Pri výbere medzi Selenium a Cypress je dôležité zvážiť potreby projektu a zručnosti tímu. Selenium je univerzálny a škálovateľný, zatiaľ čo Cypress je optimalizovaný pre moderné webové aplikácie s jednoduchším a rýchlejším vývojom testov, a práve preto sme si zvolili Cypress, ktorý viac vyhovuje naším potrebám.
Viac o Cypresse nájdeš na ich blogu. Bližšie informácie k architektúre a kľúčovým vlastnostiam Cypressu nájdeš v článku: Key differences.
Organizácia testov
Po úspešnej inštalácii Cypressu do projektu sa vytvorí nasledujúca súborová štruktúra. Viac informácii o štruktúre a jednotlivých zložkách nájdeš v tomto článku: Writing and organizing tests.

Konfigurácia Cypressu
Naše odporúčania v prípade konfigurácie jednotlivých možností:
- V prípade, ak pozorujete veľkú spotrebu pamäte počas vykonávania testov, je možné nastaviť option numTestsKeptInMemory na 0, čo ju optimalizuje.
- V prípade optimalizácie a šetrenia zdrojov počas vykonávania testov sa nám osvedčilo vypnutie kompresie videí. → videoCompression: false.
- Občas sa stáva, že test počas vykonávania padne z neznámeho dôvodu. Preto odporúčame využiť option retries, ktorá zabezpečí opätovné spustenie testu a jeho možné úspešné vykonanie. Pri tomto probléme sa nám osvedčila optimálna hodnota 3. Bližšie informácie nájdeš v článku: Test retries.
- Niektoré testy môžu padnúť z dôvodu, že Cypress “nevyberie” element v časovom limite z dôvodu animácie alebo asynchrónnej operácie, ktorá blokuje zobrazenie elementu. V tomto prípade je potrebné navýšiť predvolenú hodnotu defaultCommandTimeout na 6000.
Viac informácií o základných a ďalších možnostiach sa môžeš dočítať v článku: Configuration.
Náš konfiguračný súbor
import { defineConfig } from 'cypress'
export default defineConfig({
  projectId: '<projectID>',
  scrollBehavior: 'center',
  viewportWidth: 1920,
  viewportHeight: 1080,
  retries: 3,
  numTestsKeptInMemory: 10,
  videoCompression: false,
  screenshotOnRunFailure: false,
  e2e: {
    setupNodeEvents: (on, config) => {
		require('cypress-localstorage-commands/plugin')(on, config)
		require('./cypress/plugins/index.ts').default(on, config)
		require('@cypress/code-coverage/task')(on, config)
		on('before:browser:launch', (browser, launchOptions) => {
			// Chrome is used by default for test:CI script
			if (browser.name === 'chrome') {
				launchOptions.args.push('--disable-dev-shm-usage')
			} else if (browser.name === 'electron') {
				launchOptions.args['disable-dev-shm-usage'] = true
			}
			return launchOptions
		})
		return config
    },
	env: {
		auth_email: process.env.AUTH_EMAIL,
		auth_password: process.env.AUTH_PASSWORD,
		sign_in_url: process.env.SIGN_IN_URL
	},
	experimentalRunAllSpecs: true,
	baseUrl: 'http://localhost:3001',
	defaultCommandTimeout: 6000
  },
})Príklady test suitu a test casu
TestSuite → describe, context | TestCase → it, specify
Bližšie informácie nájdeš v článku: Writing tests.
describe('Users crud operations', () => {
    it('Create user', () => {
      ...
    })
		it('Show user', () => {
      ...
    })
    it('Update user', () => {
      ...
    })
		it('Delete user', () => {
      ...
    })
})
// alebo
context('Users crud operations', () => {
    specify('Create user', () => {
      ...
    })
		specify('Show user', () => {
      ...
    })
    specify('Update user', () => {
      ...
    })
		specify('Delete user', () => {
      ...
    })
})Naming convention of folders
Tento model pomenovania a hierarchie priečinkov/testov (viď. na obrázku) sa nám osvedčil pri väčšom projekte, kde bolo potrebné implementovať veľké množstvo testov.

Hooks
V rámci nášho tímu využívame tieto hooky na autorizáciu a následne uloženie a obnovenie tokenov, tak aby nebolo potrebné sa prihlasovať pred každým testom, keďže testy sú izolované.
before(() => {
	loginViaApi(email, password)
})
beforeEach(() => {
	// restore local storage with tokens and salon id from snapshot
	cy.restoreLocalStorage()
})
afterEach(() => {
	// take snapshot of local storage with new refresh and access token
	cy.saveLocalStorage()
})Hooks, ktoré sú definované v zložke support a súbore e2e.ts sa automaticky spúšťajú pred každým test suitom. Tento spôsob môžete využiť pri prihlasovaní.
describe('Hooks', () => {
	it('loginViaApi', () => {
		cy.log(`sign_in_url is ${Cypress.env('sign_in_url')}`)
		cy.log(`auth_email is ${Cypress.env('auth_email')}`)
		cy.log(`auth_password is ${Cypress.env('auth_password')}`)
		cy.apiAuth(Cypress.env('auth_email'), Cypress.env('auth_password'), Cypress.env('sign_in_url'))
	})
})Príklad funkcie, ktorú využívame na získanie tokenov potrebných na autorizáciu pri spúšťaní testov.
export const loginViaApi = (user?: string, password?: string) => {
	cy.apiAuth(user || Cypress.env('auth_email'), password || Cypress.env('auth_password'), Cypress.env('sign_in_url'))
}V hookoch je taktiež možné spúšťat príkazy, prípadne funkcie, na prípravu dát, ktoré sa očakávajú pri testoch. Pre viac informácií si prečítaj nasledujúci článok: Hooks.
Excluding and Including Tests
Viac o Excluding a Including testoch sa dozvieš v tomto článku: Excluding and Including tests.
Debugging
Pre viac informácií o debuggingu si prečítaj článok: Debugging.
Testing/data strategies
Pred vykonaním každého testu je nevyhnutné mať testovacie údaje uložené napríklad v databáze. Existuje mnoho spôsobov, ako pripraviť tieto údaje pred testovaním.
Viac informácií nájdeš: Testing strategies.
Seeding
Možnosti ako si pripraviť dáta potrebné pre jednotlivé testy
describe('Users CRUD operations', () => {
  beforeEach(() => {
    // pred každým testom môžete spustiť príkaz, ktorý premaže databázu
		// a vytvorí nové záznamy
    cy.exec('npm run db:reset && npm run db:seed')
    // vytvorenie používateľa
    cy.request('POST', '/user', {
      body: {
				// ... testovacie data
			},
    })
  })
	...
})Viac informácií nájdeš v článku: Database initialization & seeding.
Stubbing
Príklad “odchytenia” post requestu.
{
	...
	cy.intercept('POST', '/user/*', {
		statusCode: 200,
		body: {
			name: 'John Garfield'
		}
	}).as('createUser')
	...
}Pre viac informácií čítaj tu.
Best practices, tips and tricks
Selektovanie elementov - a.k.a. selectors
Príklad správneho identifikátora komponentu.
...
<div className={cx('input-inner-wrapper', { 'to-check-changes': toCheck })}>
				<Input
					{...input}
					id={formFieldID(form, input.name)}
					{/* alebo */}
					data-cy={formFieldID(form, input.name)}
					className={cx('input', { 'input-filter': fieldMode === FIELD_MODE.FILTER })}
					onChange={onChange}
					onBlur={onBlur}
					addonBefore={addonBefore}
					size={size || 'middle'}
					onFocus={onFocus}
					value={input.value}
				/>
</div>
...Príklad príkazu (“comandu”) na nastavenie hodnoty pre “pin” komponent. Ako vidieť nižšie na získanie elementu sa využíva unikátne ID, ktoré sa v našom prípade skladá z unikátneho názvu formulára a políčka.
Cypress.Commands.add('setValuesForPinField', (form: string, key: string, value: string) => {
	const elementId: string = form ? `#${form}-${key}` : `#${key}`
	const nthInput = (n: number) => `${elementId} > :nth-child(${n})`
	const pin = [...value]
	pin.forEach((char: string, index) =>
		cy
			.get(nthInput(index + 1))
			.type(char)
			.should('have.value', char)
	)
})Vytváranie vlastných príkazov (”comandov”)
Príklad ako využívame “custom commands” pri selektovaní a nastavovaní hodnôt jednotlivým elementom.
Cypress.Commands.add('selectOptionDropdownCustom', (form?: string, key?: string, value?: string, force?: boolean) => {
	const elementId: string = form ? `#${form}-${key}` : `#${key}`
	cy.get(elementId).click({ force })
	if (value) {
		// check for specific value in dropdown
		cy.get('.ant-select-dropdown :not(.ant-select-dropdown-hidden)', { timeout: 10000 })
			.should('be.visible')
			.find('.ant-select-item-option')
			.each((el: any) => {
				if (el.text() === value) {
					cy.wrap(el).click({ force })
				}
			})
	} else {
		// default select first option in list
		cy.get('.ant-select-dropdown :not(.ant-select-dropdown-hidden)', { timeout: 10000 }).should('be.visible').find('.ant-select-item-option').first().click({ force: true })
	}
})
Cypress.Commands.add('clickDropdownItem', (triggerId: string, dropdownItemId?: string, force?: boolean) => {
	cy.get(triggerId).click({ force })
	if (dropdownItemId) {
		// check for specific value in dropdown
		cy.get('.ant-dropdown :not(.ant-dropdown-hidden)', { timeout: 10000 })
			.should('be.visible')
			.find('.ant-dropdown-menu-item')
			.each((el: any) => {
				if (el.has(dropdownItemId)) {
					cy.wrap(el).click({ force })
				}
			})
	} else {
		// default select first item in list
		cy.get('.ant-dropdown :not(.ant-dropdown-hidden)', { timeout: 10000 }).should('be.visible').find('.ant-dropdown-menu-item').first().click({ force: true })
	}
})Viac informácií sa dozvieš v tomto článku: Custom commands.
Assigning Return Values
Namiesto definovania konštánt a uloženia vybraného elementu je potrebné využívať takzvané aliasy.
// DONT DO THIS. IT DOES NOT WORK
// THE WAY YOU THINK IT DOES.
const a = cy.get('a')
cy.visit('https://example.cypress.io')
// nope, fails
a.first().click()
// Instead, do this.
cy.get('a').as('links')
cy.get('@links').first().click()Viac informácií nájdeš v článku: Variables and aliases.
Testovanie emailových notifikácií
Čo ak potrebujeme otestovať registráciu, ktorá zahŕňa potvrdenie emailu alebo aktivačný kód? Tak určite využite “task plugin” na načítanie emailu z mailovej schránky.
V Cypressi je možné použiť “task plugin” na vykonávanie “vlastných” úloh a úpravu chovania testovacieho prostredia. Tieto úlohy môžu zahŕňať rôzne operácie, ako sú prístup k súborom, manipulácia s dátami, volanie API, nastavovanie rôznych premenných a ďalšie.
V našom prípade používame “task” na načítanie emailov a získanie aktivačného kódu. Nižšie môžete vidieť príklad definovania tasku a následné využitie v teste.
- príklad “tasku” → ./plugins/index.ts
import axios from 'axios'
/**
 * @type {Cypress.PluginConfig}
 */
export default (on: any, config: any) => {
	on('task', {
		getEmail(email: string) {
			return new Promise((resolve, reject) => {
				axios
					.get(`http://localhost:1085/api/emails?to=${email}`)
					.then((response) => {
						if (response) {
							resolve(response.data)
						}
					})
					.catch((err) => {
						reject(err)
					})
			})
		}
	})
	return on
}- príklad testu
it('Sign up', () => {
		cy.intercept({
			method: 'POST',
			url: '/api/sign-up'
		}).as('registration')
		cy.visit('/')
		cy.wait('@getConfig').then((interceptionGetConfig: any) => {
			// check status code of login request
			expect(interceptionGetConfig.response.statusCode).to.equal(200)
			cy.clickButton(SIGNUP_BUTTON_ID, FORM.LOGIN)
			// check redirect to signup page
			cy.location('pathname').should('eq', '/signup')
			cy.setInputValue(FORM.REGISTRATION, 'email', userEmail)
			cy.setInputValue(FORM.REGISTRATION, 'password', user.create.password)
			cy.setInputValue(FORM.REGISTRATION, 'phone', user.create.phone)
			cy.clickButton('agreeGDPR', FORM.REGISTRATION, true)
			cy.clickButton('marketing', FORM.REGISTRATION, true)
			cy.clickButton(SUBMIT_BUTTON_ID, FORM.REGISTRATION)
			cy.wait('@registration').then((interceptionRegistration: any) => {
				// check status code of registration request
				expect(interceptionRegistration.response.statusCode).to.equal(200)
				// take local storage snapshot
				cy.saveLocalStorage()
			})
			// check redirect to activation page
			cy.location('pathname').should('eq', '/confirmation')
			cy.task('getEmail', userEmail).then((email) => {
				if (email && email.length > 0) {
					const emailHtml = parse(email[0].html)
					const htmlTag = emailHtml.querySelector('#confirmation-code')
					if (htmlTag) {
						cy.log('Confirmation code: ', htmlTag.text)
						cy.visit('/confirmation')
						cy.intercept({
							method: 'POST',
							url: '/api/confirmation'
						}).as('activation')
						cy.setValuesForPinField(FORM.ACTIVATION, 'code', htmlTag.text)
						cy.clickButton(SUBMIT_BUTTON_ID, FORM.ACTIVATION)
						cy.wait('@activation').then((interception: any) => {
							// check status code of registration request
							expect(interception.response.statusCode).to.equal(200)
							// take local storage snapshot
							cy.saveLocalStorage()
						})
					}
				}
			})
		})
	})Viac informácií sa dozvieš v tomto článku: Task.
Code coverage
Na "inštrumentovanie” kódu používame knižnicu babel-plugin-istanbul a cypress/instrument-cra. V prípade knižnice cypress/instrument-cra je možné aktivovať inštrumentovaný kód aj v “dev móde” respektíve spustenia lokálneho dev servera.
{
  "scripts": {
		...
    "start": "react-scripts -r @cypress/instrument-cra start",
		...
  }
}Skript, ktorý používame na inštrumentovaný build aplikácie pre E2E testy.
{
  "scripts": {
		...
		"build:coverage": "cross-env CYPRESS_INSTRUMENT_PRODUCTION=true NODE_ENV=production SKIP_PREFLIGHT_CHECK=true REACT_APP_VERSION=$npm_package_version react-scripts -r @cypress/instrument-cra build",
		...
	}
}V package.json súbore je možné definovať, ktoré súbory v rámci “code basu” budú zahrnuté do “code coverage” reportu.
}
	...
	"nyc": {
	  "include": [
	    "src/pages/*"
	  ],
	  "exclude": [
	    "src/pages/Calendar/**/*"
	  ]
	}
}Knižnica @cypress/code-coverage slúži na integráciu → (spracovanie, aktualizáciu a uloženie) výsledkov “code coverage” počas vykonávania testov. Viac informácií nájdeš tu.
Test Automation and CI/CD Integration
Odporúčame si zvoliť takého CI poskytovateľa, ktorý má možnosť škálovať hardvérové zdroje (napr. Github actions a podobne…), nakoľko vykonávanie veľkého množstva testov, je náročná operácia.
V prípade, že nie je možné škálovať hardvérové zdroje alebo zmigrovať na iného CI poskytovateľa, tak ďalšou možnosťou je “postupné” spúšťanie testov. Takto je možné spúšťať špecifické testy pre rôzne role.
- Run specific test: Pre každé spustenie si vytvoríme skript do package.json súboru.
{
	/* spustenie konrétneho test suitu */
	"test:CI:auth": "AUTH_EMAIL=admin@test.com  AUTH_PASSWORD=test SIGN_IN_URL=http://localhost:3000/api/login cypress run --spec cypress/e2e/01-users/auth.cy.ts",
	/* spustenie všetkých test suitov, ktoré sa nachádzajú v priečinku "01-users" */
	"test:CI:users": "AUTH_EMAIL=manager@test.com AUTH_PASSWORD=test SIGN_IN_URL=http://localhost:3000/api/login cypress run --spec cypress/e2e/01-users/*.ts",
}- Cypress settings - env. variables: Do Cypress konfiguračného súboru je možné pridať všetky potrebné premenné, ktoré je možné následne používať v testoch.
...
env: {
		auth_email: process.env.AUTH_EMAIL,
		auth_password: process.env.AUTH_PASSWORD,
		sign_in_url: process.env.SIGN_IN_URL
},
...Integráciu je možné rozšíriť o možnosť Cypress cloud. Hlavnou výhodou je možnosť paralelizácie vykonávania testov a spravovanie výsledkov testov.
Viac informácií o integrácii nájdeš v článku: Running Cypress in continuous integration.

Ďalšie články

Praktický sprievodca vizuálnou prístupnosťou


Prístupný web jednoducho: Praktický checklist pre developerov


Webinár: Prístupnosť – prečo je dôležitá a čo vám prikazuje zákon
