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.

Roman HaluškaFrontend developer

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

  1. Nedostatok času: Vývojári často čelia nedostatku času počas vývoja a sústredia sa na implementáciu funkcionality namiesto testovania.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Frontend

Ako na end-to-end testovanie pomocou aplikácie Cypress?

Roman Haluška18 Nov 2022

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.

Roman HaluškaFrontend developer