End to end testing con Selenium, Playwright e Cucumber

Tra i diversi tipi di test che possono essere fatti relativamente ad un’applicazione, quelli end to end (e2e) ricoprono sicuramente un elevato interesse. Dando un’occhiata alla metafora introdotta da Mike Cohn, nota come Test Pyramid è chiaro come i test e2e siano la forma più elevata di test che possiamo avere, considerando il nostro software come una blackbox della quale c’interessa registrare comportamenti e funzionalità

Avere la possibilità di registrare in qualche modo di cosa è possibile fare nel mio sistema e verificarlo con il passare del tempo, ci permette di sapere in tempo reale se quello che stiamo modificando nella nostra applicazione introduce delle regressioni. Oltre a questo avere automatizzati questi tipi di test potrebbe essere anche utile per simulare in alcuni casi dei test di carico.

Uno degli approcci che sicuramente può essere utile nella definizione di test e2e è quello di definire il comportamento che la nostra applicazione web dovrebbe avere quando interagisce con l’utente (browser). Esistono framework e tool che ci permettono di gestire questa tipologia di test e BDD con la sintassi Gherkin, come visto in altri casi, è molto utile per modellare cosa viene eseguito nelle diverse funzionalità del nostro sistema

Gherkin e applicazione d’esempio

Immaginiamo quindi che il nostro sistema sia un’applicazione con frontend Angular, che dietro potrebbe avere una qualsiasi implementazione, particolare praticamente inutile ai fini del nostro test e2e. L’esempio che utilizzeremo è stato clonato direttamente da questo articolo ed è disponibile all’interno del repository Github con tutte le altre risorse del progetto.

Ai fini di questo articolo andiamo a definire soltanto gli scenari che ci permettono di testare la funzionalità di registrazione di un nuovo utente.

La sintassi Gherkin che ci permette di descrivere questi scenari è abbastanza eloquente e in questo caso siamo andati a descrivere 5 diversi scenari (4 dove la registrazione non viene effettuata e 1 dove tutto procede regolarmente)

Feature: System registration

Scenario: No fields
Given the registration form
When I enter no fields
And I submit the form to register
Then I receive an error related to "First Name" field required
And I receive an error related to "Last Name" field required
And I receive an error related to "Username" field required
And I receive an error related to "Password" field required

Scenario: Only "First name" field filled
Given the registration form
When I enter "Federico" as "First Name" field
And I submit the form to register
Then I receive an error related to "Last Name" field required
And I receive an error related to "Username" field required
And I receive an error related to "Password" field required

Scenario: Only "Last name" field filled
Given the registration form
When I enter "Paparoni" as "Last Name" field
And I submit the form to register
Then I receive an error related to "First Name" field required
And I receive an error related to "Username" field required
And I receive an error related to "Password" field required

Scenario: Only "Username" field filled
Given the registration form
When I enter "fpaparoni" as "Username" field
And I submit the form to register
Then I receive an error related to "First Name" field required
And I receive an error related to "Last Name" field required
And I receive an error related to "Password" field required

Scenario: Registration completed
Given the registration form
When I enter "fpaparoni" as "Username" field
And I enter "Paparoni" as "Last Name" field
And I enter "Federico" as "First Name" field
And I enter "troot123" as "Password" field
And I submit the form to register
Then I receive no error

Selenium & Cucumber test implementation

Come già detto esistono molti tool che ci permettono di realizzare dei test e2e, in questo caso andremo ad utilizzare Selenium e Cucumber. Selenium, progetto opensource che ha visto la luce nel lontano 2004, è stato considerato per molto tempo uno standard de facto. Intorno a questo strumento sono stati sviluppati molti tool e, nonostante l’età, continua ad essere un validissimo strumento.

In questo caso verrà utilizzato per definire la parte dei test che permette d’istanziare un browser e simulare le interazioni dell’utente, mentre Cucumber ci permetterà di utilizzare la sintassi Gherkin all’interno dei nostri test. Iniziamo quindi con la creazione di un semplice progetto Maven dove andremo ad effettuare i seguenti import

<dependency>
	<groupId>org.seleniumhq.selenium</groupId>
	<artifactId>selenium-java</artifactId>
	<version>4.5.3</version>
</dependency>
<dependency>
	<groupId>io.cucumber</groupId>
	<artifactId>cucumber-junit</artifactId>
	<version>7.8.1</version>
	<scope>test</scope>
</dependency>
<dependency>
	<groupId>io.cucumber</groupId>
	<artifactId>cucumber-java</artifactId>
	<version>7.8.1</version>
</dependency>

Gli scenari definiti in precedenza con Gherkin verranno salvati in un feature file e per innescare il motore di Cucumber andremo a definire una prima classe di test

package com.javastaff.test.e2e;

import io.cucumber.junit.CucumberOptions;
import io.cucumber.junit.Cucumber;
import org.junit.runner.RunWith;

@RunWith(Cucumber.class)
@CucumberOptions(features = {"src/test/resources/bdd/register.feature"},
         plugin = {"pretty",
        "json:target/cucumber_reports/register_selenium.json",
        "html:target/cucumber_reports/register_selenium.html"},
        glue = {"com.javastaff.test.e2e.selenium"})
public class RegisterSeleniumTest {
}

Ora possiamo al setup del motore di Selenium, attraverso una semplice classe che si occupa di istanziare il WebDriver che utilizzeremo nei nostri test. Per utilizzare uno specifico browser bisogna avere installato localmente il relativo driver. Di seguito i driver da installare e le variabili d’ambiente da settare nel vostro sistema per utilizzarlo (potete anche semplicemente invocare il programma d’esempio passando la stessa variabile)

BrowserDriverVariabile d’ambiente
ChromeChromeDriverwebdriver.chrome.driver=path/to/the/driver
FirefoxGeckoDriverwebdriver.gecko.driver=path/to/the/driver

Passando la variabile test-browser al nostro programma possiamo decidere con quale browser avviare il test, la scelta di default sarà Chrome.

package com.javastaff.test.e2e.selenium;

import io.cucumber.java.Before;

import java.time.Duration;

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.firefox.FirefoxDriver;

public class Setup {

    public static WebDriver driver;

    @Before
    public void setWebDriver() throws Exception {
    	if ("firefox".equals(System.getProperty("test-browser"))) {
    		driver = new FirefoxDriver();
        } else {
        	ChromeOptions chromeOptions = new ChromeOptions();
    		chromeOptions.addArguments("['start-maximized']");
    		driver = new ChromeDriver(chromeOptions);
    	}
    	driver.manage().window().maximize();
        driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(30));
        driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(30));
    }
}

Definiamo ora cosa succederà nel momento in cui finisce il nostro test. Di sicuro potrebbe essere utile creare uno screenshot del browser se l’applicazione andasse in errore e anche in questo ci viene in aiuto Selenium

package com.javastaff.test.e2e.selenium;

import io.cucumber.java.Scenario;
import io.cucumber.java.After;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;

public class TearDown {

    private WebDriver driver;

    public TearDown() {
        this.driver = Setup.driver;
    }

    @After
    public void quitDriver(Scenario scenario){
        if(scenario.isFailed()){
           saveScreenshotsForScenario(scenario);
        }
        this.driver.quit();
    }

    private void saveScreenshotsForScenario(final Scenario scenario) {

        final byte[] screenshot = ((TakesScreenshot) driver)
                .getScreenshotAs(OutputType.BYTES);
        scenario.attach(screenshot, "image/png",scenario.getName());
    }
}

Possiamo quindi passare all’implementazione dei casi di test, utilizzando le annotation di Cucumber che corrispondono al feature file che abbiamo definito. All’interno di ogni metodo Selenium verrà impiegato per gestire il comportamento del browser.

package com.javastaff.test.e2e.selenium;

import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;

import java.time.Duration;
import java.util.List;

import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedCondition;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;

public class RegisterSteps extends BaseStep{
	
	@Given("the registration form")
	public void the_registration_form() {
		driver.get("http://localhost");
		WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(30));
		ExpectedCondition<WebElement> condition =
          ExpectedConditions.elementToBeClickable(By.cssSelector(".btn-primary"));
		wait.until(condition);
		driver.findElement(By.linkText("Register")).click();
		ExpectedCondition<WebElement> condition2 = 
          ExpectedConditions.elementToBeClickable(By.cssSelector(".btn-primary"));
		wait.until(condition2);
	}

	@When("I enter no fields")
	public void i_enter_no_fields() {
	    System.out.println("Nothing...");
	}
	
	@When("I enter {string} as {string} field")
	public void i_enter_as_field(String value, String field) {
		FieldInfo fieldInfo=FieldInfo.getFieldInfo(field);
		driver.findElement(By.cssSelector(
           ".form-group:nth-child("+fieldInfo.getPos()+") > .form-control")).sendKeys(value);
	}

	@When("I submit the form to register")
	public void i_submit_the_form_to_register() {
		WebElement element=driver.findElement(By.cssSelector(".btn-primary"));
	    element.click();
	}

	@Then("I receive an error related to {string} field required")
	public void i_receive_an_error_related_to_field_required(String field) {
		FieldInfo fieldInfo=FieldInfo.getFieldInfo(field);
		
		assertThat(driver.findElement(By.cssSelector(
           ".form-group:nth-child("+fieldInfo.getPos()+") > .invalid-feedback > div")).getText(), is(fieldInfo.getLabel()+" is required"));
		
		List<WebElement> elements = 
           driver.findElements(By.cssSelector(
              ".form-group:nth-child("+fieldInfo.getPos()+") > .invalid-feedback > div"));
	    assert(elements.size() > 0);
	}
	
	@Then("I receive no error")
	public void i_receive_no_error() {
		//Back to login page
		WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(30));
		ExpectedCondition<Boolean> condition = 
           ExpectedConditions.textToBe(By.cssSelector(".card-header"),"Login");
		wait.until(condition);
		Wait wait2=new Wait(driver);
		wait2.forLoading(30);
		//Check positive outcome
		assertThat(driver.findElement(
           By.cssSelector("span")).getText(), is("Registration successful"));
	}
	
}

class FieldInfo {
	String label;
	int pos;
	
	public String getLabel() {
		return label;
	}
	public void setLabel(String label) {
		this.label = label;
	}
	public int getPos() {
		return pos;
	}
	public void setPos(int pos) {
		this.pos = pos;
	}
	
	public static FieldInfo getFieldInfo(String field) {
		int pos=0;
		String label="";
		switch (field) {
			case "First Name":
				pos=1;
				label="First Name";
				break;
			case "Last Name":
				pos=2;
				label="Last Name";
				break;
			case "Username":
				pos=3;
				label="Username";
				break;
			case "Password":
				pos=4;
				label="Password";
				break;
			default:
				pos=-1;
				break;
		} 
		FieldInfo fieldInfo=new FieldInfo();
		fieldInfo.setLabel(label);
		fieldInfo.setPos(pos);
		return fieldInfo;
	}
}

Come potete vedere il test che andiamo ad implementare cerca di descrivere esattamente quale è il risultato atteso. Ad esempio quando voglio essere sicuro che ho completato la registrazione con successo, verifico che la pagina caricata riporti la scritta “Registration successful”.

Playwright & Cucumber test implementation

Utilizzando gli stessi feature file definiti precedentemente ora proviamo a fare un’altra implementazione utilizzando Playwright.

Quest’ultimo è un progetto opensource Microsoft che mette a disposizione degli sviluppatori una serie di API in diversi linguaggi per la creazione di test end to end. Rispetto a Selenium ci troviamo davanti ad un progetto nuovo, che sicuramente potrebbe essere più pronto al test di applicazioni moderne ma allo stesso tempo è da valutare per capire quanto possa essere maturo.

Passiamo quindi ad aggiungere la dipendenza della libreria al nostro pom.xml

    <dependency>
        <groupId>com.microsoft.playwright</groupId>
        <artifactId>playwright</artifactId>
        <version>1.28.1</version>
    </dependency>

L’implementazione dei nostri casi di test segue lo stesso schema che abbiamo visto precedentemente, con un caso di test per collegare Cucumber e Playwright

package com.javastaff.test.e2e;

import io.cucumber.junit.CucumberOptions;
import io.cucumber.junit.Cucumber;
import org.junit.runner.RunWith;

@RunWith(Cucumber.class)
@CucumberOptions(features = {"src/test/resources/bdd/register.feature"},
         plugin = {"pretty",
        "json:target/cucumber_reports/register_playwright.json",
        "html:target/cucumber_reports/register_playwright.html"},
        glue = {"com.javastaff.test.e2e.playwright"})
public class RegisterPlaywrightTest {
}

e la definizione di una classe dove andiamo a gestire l’istanza di browser che vogliamo utilizzare in base ai parametri forniti

package com.javastaff.test.e2e.playwright;

import java.nio.file.Paths;
import java.util.Optional;

import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;

import io.cucumber.java.Before;

public class Setup {
    
    public static Page page;
    public static BrowserContext context;
    public static Browser browser;

    @Before
    public void setBrowser() throws Exception {
    	BrowserType browserType = null;
    	String browserTypeAsString=Optional.ofNullable(System.getProperty("test-browser")).orElse("chromium");
		switch (browserTypeAsString) {
		case "firefox":
			browserType = Playwright.create().firefox();
			break;
		case "chromium":
			browserType = Playwright.create().chromium();
			break;
		case "webkit":
			browserType = Playwright.create().webkit();
			break;
		default:
			browserType = Playwright.create().chromium();
			break;
		}
		if (browserType == null) {
			throw new IllegalArgumentException(
               "Could not launch a browser for type " + browserTypeAsString);
		}
		browser = browserType.launch(
           new BrowserType.LaunchOptions().setHeadless(false).setSlowMo(300));
		var videoPath = Paths.get("videos/");

	    var contextOptions = new Browser.NewContextOptions()
	            .setRecordVideoDir(videoPath);
		context = browser.newContext(contextOptions);
		context.setDefaultTimeout(5000);
		page = context.newPage();
    }
}

In questo caso possiamo notare che anche se non andiamo a scaricare e settare il driver specifico che vogliamo utilizzare, Playwright si occuperà di effettuare il download durante la prima esecuzione dei nostri casi di test. Inoltre abbiamo abilitato la possibilità di registrare dei video dei nostri test (feature molto interessante che manca in Selenium), che poi andremo successivamente a gestire. Abbiamo quindi l’implementazione dei vari scenari, dove andiamo ad utilizzare le API Playwright per gestire l’interazione con il browser

package com.javastaff.test.e2e.playwright;

import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.AriaRole;

import io.cucumber.java.After;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;

public class RegisterSteps extends BaseStep {
	
	@Given("the registration form")
	public void the_registration_form() {
		page.navigate("http://localhost/");
		page.getByRole(
           AriaRole.LINK, new Page.GetByRoleOptions().setName("Register")).click();
	}

	@When("I enter no fields")
	public void i_enter_no_fields() {
		System.out.println("Nothing...");
	}

	@When("I enter {string} as {string} field")
	public void i_enter_as_field(String value, String field) {
		FieldInfo fieldInfo = FieldInfo.getFieldInfo(field);
		page.getByRole(AriaRole.TEXTBOX).nth(fieldInfo.getPos()).click();
		page.getByRole(AriaRole.TEXTBOX).nth(fieldInfo.getPos()).fill(value);
	}

	@When("I submit the form to register")
	public void i_submit_the_form_to_register() {
		page.getByRole(
           AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Register")).click();
	}

	@Then("I receive an error related to {string} field required")
	public void i_receive_an_error_related_to_field_required(String field) {
		FieldInfo fieldInfo = FieldInfo.getFieldInfo(field);
		page.waitForSelector("text="+fieldInfo.getLabel() + " is required");
	}

	@Then("I receive no error")
	public void i_receive_no_error() {
		page.waitForSelector("text=Registrationz");
	}
	
	@After
	public void close() {
		context.close();
		page.close();
	}

}

class FieldInfo {
	String label;
	int pos;
	
	public String getLabel() {
		return label;
	}
	public void setLabel(String label) {
		this.label = label;
	}
	public int getPos() {
		return pos;
	}
	public void setPos(int pos) {
		this.pos = pos;
	}
	
	public static FieldInfo getFieldInfo(String field) {
		int pos=0;
		String label="";
		switch (field) {
			case "First Name":
				pos=0;
				label="First Name";
				break;
			case "Last Name":
				pos=1;
				label="Last Name";
				break;
			case "Username":
				pos=2;
				label="Username";
				break;
			case "Password":
				pos=3;
				label="Password";
				break;
			default:
				pos=-1;
				break;
		} 
		FieldInfo fieldInfo=new FieldInfo();
		fieldInfo.setLabel(label);
		fieldInfo.setPos(pos);
		return fieldInfo;
	}
}

Infine abbiamo definito una classe che viene eseguita alla chiusura dei casi di test e, rispetto alla versione Selenium dove salvavamo una screenshot del test fallito, Playwright ci mette a disposizione la possibilità di registrare l’interazione con il browser. Questo video in formato webm viene salvato sul filesystem e noi lo andiamo ad aggiungere al report di Cucumber

package com.javastaff.test.e2e.playwright;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.Page;

import io.cucumber.java.After;
import io.cucumber.java.Scenario;

public class TearDown {

	protected Page page;
	protected BrowserContext context;
	protected Browser browser;

    public TearDown() {
        this.page = Setup.page;
        this.context = Setup.context;
        this.browser = Setup.browser;
    }

    @After
    public void quitDriver(Scenario scenario) throws IOException{
    	context.close();
		page.close();
        browser.close();
        Path path = page.video().path();
        if (scenario.isFailed()) {
            byte[] buffer = Files.readAllBytes(path);
            scenario.attach(buffer,"video/webm", scenario.getName()+".webm");
        } else {
        	Files.delete(path);
        }
    }
}

Come avviare il test

Prima di tutto bisogna avviare l’applicazione Angular, entrando nella cartella angular-9-registration-login-example creando l’immagine Docker

docker build --no-cache -t bddfe .

e poi avviandola

docker run -p 80:80 -t bddfe

In questo momento se andate con il browser su http://localhost dovreste vedere l’applicazione funzionante. A questo punto potete avviare l’applicazione di test nel seguente modo (webdriver.gecko.driver serve solo per Selenium se non l’abbiamo definito come variabile d’ambiente)

mvn install -Dtest-browser=firefox -Dwebdriver.gecko.driver=/home/federico/testdriver/geckodriver-v0.32.0-linux64/geckodriver

Il risultato sarà l’esecuzione dei test che abbiamo definito e implementato, con la creazione di un report Cucumber nella cartella target/cucumber_reports/ come il seguente

Avendo implementato i casi di test con i due engine Selenium e Playwright, avremo due diversi report. Se volessimo vedere cosa succede quando si verifica un errore, possiamo cambiare ad esempio la stringa “Registration successful” in “Registration” e rilanciando i test vedremo come all’interno del report Selenium sarà presente uno screenshot che mostra il motivo per cui il caso di test è andato in errore

mentre nel report Playwright avremo in allegato un video che mostra l’interazione che genera l’errore.

Conclusioni

L’implementazione che è stata realizzata in questo articolo sfrutta la possibilità di avere nello stesso progetto di test una libreria che permette la definizione dei test utilizzando un formato standard (Cucumber con Gherkin) ed agganciare questi test ad un framework che possa interagire con il browser (Selenium e Cucumber). Tutte queste librerie hanno implementazioni in altri linguaggi, quindi è possibile ricreare questa interessante sinergia anche utilizzando Typescript, Python o C#.

A questo repository è possibile trovare il codice sorgente dell’esempio illustrato

Federico Paparoni

Looking for a right "about me"...

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *

Questo sito usa Akismet per ridurre lo spam. Scopri come i tuoi dati vengono elaborati.