Hi all, I will explain a sample test automation project with Selenium and Spring Boot in this article. It is a combination of the latest technologies in test automation. First, I will start with some Spring boot terminology, and then we will use them in our Selenium project. Our test site is an e-commerce website n11.com, and we will have scenarios for invalid login. Let’s Start!
Tech Stack
- Java 17
- Selenium 4.x
- JUnit 5.x (Jupiter)
- Cucumber 7.x
- Spring Boot 3.0.1
Summary of Spring Boot Features for the Selenium Automation Project
The project I will explain in this article will have Spring Boot’s features like Dependency Inversion, Spring profiles, Aspect-Oriented Programming (AOP), Parallel test execution, BDD with Cucumber, Selenium Grid, and more.
Spring Boot Quick Start Guide for Test Automation
First, we need to add the dependencies below in our pom.xml file.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.1</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>org.swtestacademy</groupId>
<artifactId>selenium-springboot</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<maven.surefire.version>3.0.0-M8</maven.surefire.version>
<java.version>17</java.version>
<selenium.version>4.8.0</selenium.version>
<junit.jupiter.version>5.9.2</junit.jupiter.version>
<junit.platform.version>1.9.2</junit.platform.version>
<testng.version>7.7.1</testng.version>
<lombok.version>1.18.24</lombok.version>
<cucumber.version>7.10.1</cucumber.version>
</properties>
<dependencies>
<!--Spring Boot-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--Selenium-->
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>${selenium.version}</version>
</dependency>
<!--JUnit5-->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.jupiter.version}</version>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<version>${junit.platform.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite</artifactId>
<version>${junit.platform.version}</version>
<scope>test</scope>
</dependency>
<!--Cucumber-->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-spring</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-testng</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit-platform-engine</artifactId>
<version>${cucumber.version}</version>
</dependency>
<!--TestNG-->
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>${testng.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven.surefire.version}</version>
<configuration>
<reportFormat>plain</reportFormat>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
</plugins>
</build>
</project>You can also start a spring boot project via https://start.spring.io/, and then you will add the other testing dependencies, which you can find in the pom.xml above.
Note: I have used 3.0.1 version and updated the pom.xml on 10 Jan 2023.
We need a spring boot application class in the main package below.
@SpringBootApplication()
public class SpringSeleniumApplication {
public static void main(String[] args) {
SpringApplication.run(SpringSeleniumApplication.class, args);
}
}
Below are the Spring Boot annotations that I used in the project.
@Autowired: This annotation automatically creates the instances declared with @Component annotation with @Bean annotation. It does the dependency inversion-related operations behind the scenes.
@PostConstruct: It is a part of the spring bean life cycle, and it does the operations after the post-construction of the beans. @Postconstruct annotated method will be invoked after the bean has been constructed using the default constructor and just before its instance is returned to requesting object.
@Component: @Component is an annotation allowing Spring to detect our custom beans automatically.
@Lazy: @Lazy annotation indicates whether a bean is to be lazily initialized. It can be used on @Component and @Bean definitions. A @Lazy bean is not initialized until referenced by another bean or explicitly retrieved from BeanFactory. Beans that are not annotated with @Lazy are initialized eagerly.
@Bean: Spring @Bean annotation tells that a method produces a bean to be managed by the Spring container. It is a method-level annotation. During Java configuration (@Configuration), the method is executed, and its return value is registered as a bean within a BeanFactory.
@Configuration: @Configuration tags the class as a source of bean definitions for the application context.
@Value: Spring @Value annotation assigns default values to variables and method arguments. We can read spring environment variables and system variables using @Value annotation.
@Profile: This annotation loads the specific profiles. Profiles are a core feature of the framework, allowing us to map our beans to different profiles, for example, dev, test, stage, etc.
Custom Annotations
I used some custom annotations to gather multiple annotations in one annotation. Below are some examples of custom annotations.
@Lazy and @Autowired annotation combination, and I named it as @LazyAutowired:
@Lazy
@Autowired
@Documented
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface LazyAutowired {
}@Lazy and @Component annotation combination was named as @LazyComponent:
@Lazy
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Documented
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface LazyComponent {
}@ElapsedTime – Spring AOP:
@Documented
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface ElapsedTime {
}@TakeScreenshot – Spring AOP:
@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface TakeScreenshot {
}For configurations:
@Lazy
@Configuration
@Documented
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface LazyConfiguration {
}For Tests:
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = SpringSeleniumApplication.class)
public @interface SeleniumTest {
}
For parallel test execution:
@Bean
@Scope("webdriverscope")
@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface WebdriverScopeBean {
}Spring Boot Selenium Test Automation Classes
Let’s start with Configurations. We need to define the objects from external libraries as beans like WebDriver instances in the configurations.
WebdriverConfig Class
@Profile(“!grid”) tells us that this configuration works when the profile is not the grid.
@WebdriverScopeBean is for parallel test execution.
@ConditionalOnProperty annotation helps us use the specific beans based on the properties in the configuration properties file which is application.properties in the resource folder.
@Primary annotation gives a higher preference to a bean when there are multiple beans of the same type.
@ConditionalMissingBean annotation lets a bean be included based on the absence of specific beans.
@Profile("!grid")
@LazyConfiguration
public class WebDriverConfig {
@WebdriverScopeBean
@ConditionalOnProperty(name = "browser", havingValue = "firefox")
@Primary
public WebDriver firefoxDriver() {
FirefoxOptions firefoxOptions = new FirefoxOptions();
Proxy proxy = new Proxy();
proxy.setAutodetect(false);
proxy.setNoProxy("no_proxy-var");
firefoxOptions.setCapability("proxy", proxy);
return new FirefoxDriver(firefoxOptions);
}
@WebdriverScopeBean
@ConditionalOnProperty(name = "browser", havingValue = "edge")
@Primary
public WebDriver edgeDriver() {
return new EdgeDriver();
}
@WebdriverScopeBean
@ConditionalOnMissingBean
@ConditionalOnProperty(name = "browser", havingValue = "chrome")
@Primary
public WebDriver chromeDriver() {
return new ChromeDriver();
}
}
WebDriverWaitConfig Class
This class is for auto-wiring the WebdriverWait instance in the tests. The default timeout duration is set as 30 seconds, and for parallel test execution, the bean is annotated with @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE). A bean with the prototype scope will return a different instance every time it is requested from the container.
@LazyConfiguration
public class WebDriverWaitConfig {
@Value("${default.timeout:30}")
private int timeout;
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public WebDriverWait webdriverWait(WebDriver driver) {
return new WebDriverWait(driver, Duration.ofMillis(this.timeout));
}
}RemoteWebDriverConfig Class
@Profile(“grid”) annotation is for Selenium Grid and remotewebdriver. When we run the tests with “spring.profiles.active=grid” environment variable, the tests will use application-grid.properties file under the resources folder as the main configuration file.
@Profile("grid")
@LazyConfiguration
public class RemoteWebDriverConfig {
@Value("${selenium.grid.url}")
private URL url;
@WebdriverScopeBean
@ConditionalOnProperty(name = "browser", havingValue = "firefox")
@Primary
public WebDriver remoteFirefoxDriver(){
FirefoxOptions firefoxOptions = new FirefoxOptions();
return new RemoteWebDriver(this.url, firefoxOptions);
}
@WebdriverScopeBean
@ConditionalOnProperty(name = "browser", havingValue = "edge")
@Primary
public WebDriver remoteEdgeDriver(){
EdgeOptions edgeOptions = new EdgeOptions();
return new RemoteWebDriver(this.url, edgeOptions);
}
@WebdriverScopeBean
@ConditionalOnMissingBean
@ConditionalOnProperty(name = "browser", havingValue = "chrome")
@Primary
public WebDriver remoteChromeDriver(){
ChromeOptions chromeOptions = new ChromeOptions();
return new RemoteWebDriver(this.url, chromeOptions);
}
}
OtherBeansConfigs Class
For the other beans, I used this class. The class below has the JavaScriptExecutor bean.
@LazyConfiguration
public class OtherBeansConfigs {
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public JavascriptExecutor javascriptExecutor(WebDriver driver) {
return (JavascriptExecutor) driver;
}
}For parallel test execution, I have three scope classes as follows.
WebdriverScope Class
It extends the SimpleThreadScope based on the webdriver session and cleans the threadScope map. It is a ThreadLocal map inside SimpleThreadScope class.
public class WebdriverScope extends SimpleThreadScope {
@Override
public Object get(String name, ObjectFactory<?> objectFactory) {
Object o = super.get(name, objectFactory);
SessionId sessionId = ((RemoteWebDriver)o).getSessionId();
if(Objects.isNull(sessionId)){
super.remove(name);
o = super.get(name, objectFactory);
}
return o;
}
@Override
public void registerDestructionCallback(String name, Runnable callback) {
}
}WebdriverScopeConfig Class
It is a configuration class, and it creates the WebdriverScopePostProcessor bean.
@Configuration
public class WebdriverScopeConfig {
@Bean
public static BeanFactoryPostProcessor beanFactoryPostProcessor(){
return new WebdriverScopePostProcessor();
}
}WebdriverScopePostProcessor Class
This class implements the BeanFactoryPostProcessor functional interface and registers the scope as “webdriverscope” by using WebDriverScope class as an argument.
public class WebdriverScopePostProcessor implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
beanFactory.registerScope("webdriverscope", new WebdriverScope());
}
}Let’s continue with Page Classes.
BasePage Class
This is the base class for all pages, and all pages extend the BasePage class. Here I autowired the WebDriver, WebDriverWait, JavascripExecutor, LogUtil classes and in this way, I can use the instances of these classes. Also, with @PostConstruct annotation after the creation of this page, I initiated the elements with “PageFactory.initElements(this.driver, this);” this line for PageFactory usage.
package com.swtestacademy.springbootselenium.pages;
import com.swtestacademy.springbootselenium.utils.LogUtil;
import jakarta.annotation.PostConstruct;
import lombok.SneakyThrows;
import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
public abstract class BasePage {
@Autowired
protected WebDriver driver;
@Autowired
protected WebDriverWait wait;
@Autowired
protected JavascriptExecutor javascriptExecutor;
@Autowired
protected LogUtil logUtil;
@PostConstruct
private void init() {
PageFactory.initElements(this.driver, this);
}
public abstract boolean isAt();
public <T> void waitElement(T elementAttr) {
if (elementAttr
.getClass()
.getName()
.contains("By")) {
wait.until(ExpectedConditions.presenceOfElementLocated((By) elementAttr));
} else {
wait.until(ExpectedConditions.visibilityOf((WebElement) elementAttr));
}
}
public <T> void waitElements(T elementAttr) {
if (elementAttr
.getClass()
.getName()
.contains("By")) {
wait.until(ExpectedConditions.presenceOfAllElementsLocatedBy((By) elementAttr));
} else {
wait.until(ExpectedConditions.visibilityOfAllElements((WebElement) elementAttr));
}
}
//Click Method by using JAVA Generics (You can use both By or Web element)
public <T> void click(T elementAttr) {
waitElement(elementAttr);
if (elementAttr
.getClass()
.getName()
.contains("By")) {
driver
.findElement((By) elementAttr)
.click();
} else {
((WebElement) elementAttr).click();
}
}
public void jsClick(By by) {
javascriptExecutor.executeScript("arguments[0].click();", wait.until(ExpectedConditions.visibilityOfElementLocated(by)));
}
//Write Text by using JAVA Generics (You can use both By or WebElement)
public <T> void writeText(T elementAttr, String text) {
waitElement(elementAttr);
if (elementAttr
.getClass()
.getName()
.contains("By")) {
wait.until(ExpectedConditions.presenceOfAllElementsLocatedBy((By) elementAttr));
driver
.findElement((By) elementAttr)
.sendKeys(text);
} else {
wait.until(ExpectedConditions.visibilityOf((WebElement) elementAttr));
((WebElement) elementAttr).sendKeys(text);
}
}
//Read Text by using JAVA Generics (You can use both By or WebElement)
public <T> String readText(T elementAttr) {
if (elementAttr
.getClass()
.getName()
.contains("By")) {
return driver
.findElement((By) elementAttr)
.getText();
} else {
return ((WebElement) elementAttr).getText();
}
}
@SneakyThrows
public <T> String readTextErrorMessage(T elementAttr) {
Thread.sleep(2000); //This needs to be improved.
return driver
.findElement((By) elementAttr)
.getText();
}
//Close popup if exists
public void handlePopup(By by) throws InterruptedException {
waitElements(by);
List<WebElement> popup = driver.findElements(by);
if (!popup.isEmpty()) {
popup
.get(0)
.click();
Thread.sleep(200);
}
}
}
HomePage Class
Homepage class extends the BasePage class and does the related operations for the Homepage of n11.com like “goToHomePage()” and “goToLoginPage()”. I also overrode the isAt method of the BasePage class to wait for the HomePage class to load completely.
@LazyComponent
public class HomePage extends BasePage {
@Autowired
LoginPage loginPage;
@Value("${application.url}")
private String baseURL;
//*********Web Elements By Using Page Factory*********
@FindBy(how = How.CLASS_NAME, using = "btnSignIn")
public WebElement signInButton;
//*********Page Methods*********
//Go to Homepage
public HomePage goToHomePage() {
driver.get(baseURL);
return this;
}
//Go to LoginPage
public HomePage goToLoginPage() {
click(signInButton);
return this;
}
@Override
public boolean isAt() {
return this.wait.until((d) -> this.signInButton.isDisplayed());
}
}LoginPage Class
LoginPage class extends the BasePage class and does the login page-related operations.
@LazyComponent
public class LoginPage extends BasePage {
//********* Web Elements by using Page Factory *********
@FindBy(how = How.ID, using = "email")
public WebElement userName;
@FindBy(how = How.ID, using = "password")
public WebElement password;
//********* Web Elements by using By Class *********
By loginButtonBy = By.id("loginButton");
By errorMessageUsernameBy = By.xpath("//*[@id=\"loginForm\"]/div[1]/div/div");
By errorMessagePasswordBy = By.xpath("//*[@id=\"loginForm\"]/div[2]/div/div");
By errorMessagePasswordCssBy = By.cssSelector("div[data-errormessagefor='password'] > .errorText");
//*********Page Methods*********
public LoginPage login(String userName, String password) {
writeText(this.userName, userName);
writeText(this.password, password);
jsClick(loginButtonBy);
return this;
}
public LoginPage verifyLoginUserNameErrorMessage(String expectedText) {
assertEquals(expectedText, readText(errorMessageUsernameBy));
return this;
}
public LoginPage verifyPasswordErrorMessage(String expectedText) {
assertEquals(expectedText, readText(errorMessagePasswordBy));
return this;
}
public LoginPage verifyPasswordErrorMessageWithCss(String expectedText) {
assertEquals(expectedText, readTextErrorMessage(errorMessagePasswordCssBy));
return this;
}
public LoginPage verifyLogEntryFailMessage() {
logUtil.isLoginErrorLog(driver);
return this;
}
@Override public boolean isAt() {
return this.wait.until((d) -> this.userName.isDisplayed());
}
}
We will continue with the Steps class, and we have only LoginSteps class for our scenario.
LoginSteps Class
As seen below, in LoginSteps class, I auto-wired the page classes and defined the scenario methods. The @ElapsedTime and @TakeScreenshot annotations are Spring AOP (aspect-oriented programming) annotations which I will share their details later. I also read the browser value from the configuration properties by using @Value annotation of the spring framework.
@LazyComponent
public class LoginSteps {
@Value("${browser}")
private String browser;
@LazyAutowired
HomePage homePage;
@LazyAutowired
LoginPage loginPage;
public LoginSteps givenIAmAtLoginPage() {
homePage
.goToHomePage()
.goToLoginPage();
return this;
}
@ElapsedTime
public LoginSteps whenILogin(String userName, String password) {
loginPage
.login(userName, password);
return this;
}
public LoginSteps thenIVerifyUserNameErrorMessages(String expected) {
loginPage
.verifyLoginUserNameErrorMessage(expected);
return this;
}
@TakeScreenshot
public LoginSteps thenIVerifyInvalidLoginMessage() {
if(!browser.equalsIgnoreCase("firefox")) {
loginPage
.verifyLogEntryFailMessage();
} else {
loginPage.verifyPasswordErrorMessageWithCss("E-posta adresiniz veya şifreniz hatalı");
}
return this;
}
@TakeScreenshot
public LoginSteps thenIVerifyPasswordErrorMessage(String expected) {
loginPage
.verifyPasswordErrorMessage(expected);
return this;
}
@TakeScreenshot
public LoginSteps thenIVerifyPasswordErrorMessageWithCss(String expected) {
loginPage
.verifyPasswordErrorMessageWithCss(expected);
return this;
}
}I created some util classes like LogUtil, ScreenshotUtil, WindowSwitchUtil, etc. You can find all of them on the GitHub page of the project.
BrowserOps Class
This class is for preparing and getting the browser-specific options.
package com.swtestacademy.springbootselenium.utils;
import com.swtestacademy.springbootselenium.annotations.LazyComponent;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.firefox.FirefoxProfile;
import org.openqa.selenium.logging.LogType;
import org.openqa.selenium.logging.LoggingPreferences;
import java.util.logging.Level;
@LazyComponent
public class BrowserOps {
public ChromeOptions getChromeOptions() {
ChromeOptions chromeOptions = new ChromeOptions();
LoggingPreferences logPrefs = new LoggingPreferences();
logPrefs.enable(LogType.BROWSER, Level.ALL);
logPrefs.enable(LogType.DRIVER, Level.ALL);
chromeOptions.setCapability("goog:loggingPrefs", logPrefs);
return chromeOptions;
}
public FirefoxOptions getFireFoxOptions() {
FirefoxProfile firefoxProfile = new FirefoxProfile();
firefoxProfile.setPreference("devtools.console.stdout.content", true);
FirefoxOptions firefoxOptions = new FirefoxOptions();
LoggingPreferences logPrefs = new LoggingPreferences();
logPrefs.enable(LogType.BROWSER, Level.ALL);
logPrefs.enable(LogType.DRIVER, Level.ALL);
firefoxOptions
.setProfile(firefoxProfile)
.setCapability("moz:loggingPrefs", logPrefs);
return firefoxOptions;
}
}
ElementContainsText Class
This custom-expected condition class waits until the element contains a specific text.
//Custom Expected Condition Class
public class ElementContainsText implements ExpectedCondition<Boolean> {
private final String textToFind;
private final By by;
//Constructor (Set the given values)
public ElementContainsText(final By by, final String textToFind) {
this.by = by;
this.textToFind = textToFind;
}
//Override the apply method with your own functionality
@Override
public Boolean apply(WebDriver webDriver) {
//Find the element with given By method (By CSS, XPath, Name, etc.)
WebElement element = Objects
.requireNonNull(webDriver)
.findElement(this.by);
//Check that the element contains given text?
return element
.getText()
.contains(this.textToFind);
}
//This is for log message. I override it because when test fails, it will give us a meaningful message.
@Override
public String toString() {
return ": \"Does " + this.by + " contain " + this.textToFind + "?\"";
}
}LogUtil Class
This class is for log utility for the tests.
@LazyComponent
public class LogUtil {
public static LogEntries getLogs(WebDriver driver) {
return driver
.manage()
.logs()
.get(LogType.BROWSER);
}
public void isLoginErrorLog(WebDriver driver) {
//Check logs (works only Chrome and Edge)
LogEntries logEntries = driver
.manage()
.logs()
.get(LogType.BROWSER);
Assert.assertTrue(logEntries
.getAll()
.stream()
.anyMatch(logEntry -> logEntry
.getMessage()
.contains("An invalid email address was specified")));
}
}ScreenshotUtil Class
This utility class is responsible for taking screenshots.
@LazyComponent
public class ScreenshotUtil {
@Autowired
private ApplicationContext ctx;
@Value("${screenshot.path}")
private Path path;
public void takeScreenShot(String testName) throws IOException {
File sourceFile = this.ctx.getBean(TakesScreenshot.class).getScreenshotAs(OutputType.FILE);
FileCopyUtils.copy(sourceFile, this.path.resolve( testName + ".png").toFile());
}
public byte[] getScreenshot(){
return this.ctx.getBean(TakesScreenshot.class).getScreenshotAs(OutputType.BYTES);
}
}WindowSwitchUtil Class
This class is for switching windows.
@LazyComponent
public class WindowSwitchUtil {
@Autowired
private ApplicationContext ctx;
public void switchByWindowTitle(final String title) {
WebDriver driver = this.ctx.getBean(WebDriver.class);
driver
.getWindowHandles()
.stream()
.map(handle -> driver
.switchTo()
.window(handle)
.getTitle())
.filter(t -> t.startsWith(title))
.findFirst()
.orElseThrow(() -> {
throw new RuntimeException("There is no such window available.");
});
}
public void switchByIndex(final int index) {
WebDriver driver = this.ctx.getBean(WebDriver.class);
String[] handles = driver
.getWindowHandles()
.toArray(new String[0]);
driver
.switchTo()
.window(handles[index]);
}
}Now it is time to continue with test classes.
BaseTest Class
The base test class is annotated by @SeleniumTest annotation and the Lombok library’s @Getter annotation to get the instances from the base test class. We have here the common logger, application context instances, and by using the applicationContext’s WebDriver bean, we can quit the browser in the teardown() method.
@SeleniumTest
@Getter
public class BaseTest {
protected Logger logger = LoggerFactory.getLogger(this.getClass());
@BeforeEach
public void setup() {
}
@LazyAutowired
public ApplicationContext applicationContext;
@AfterEach
public void teardown() {
this.applicationContext
.getBean(WebDriver.class)
.quit();
}
}LoginTest Class
The LoginTest class extends the BaseTest class and is annotated with Junit Jupiter’s @Execution(ExecutionMode.CONCURRENT) annotation for parallel test execution. In this class, I auto-wired the LoginSteps then I used its methods to implement test scenarios for each test.
@Execution(ExecutionMode.CONCURRENT)
public class LoginTest extends BaseTest {
@LazyAutowired
LoginSteps loginSteps;
@Test
public void invalidUserNameInvalidPassword() {
loginSteps
.givenIAmAtLoginPage()
.whenILogin("onur@", "11223344")
.thenIVerifyInvalidLoginMessage();
}
@Test
public void emptyUserEmptyPassword() {
loginSteps
.givenIAmAtLoginPage()
.whenILogin("", "")
.thenIVerifyUserNameErrorMessages("Lütfen e-posta adresinizi girin.")
.thenIVerifyPasswordErrorMessage("Bu alanın doldurulması zorunludur.");
}
}
Cucumber BDD with Spring Boot and Selenium
I also added Cucumber BDD support to this project. You can find all details under the cucumber package. Let’s check the classes and files under this package.
Login.Feature Feature File
Login.feature is the cucumber feature file for login tests. Here we have two negative login scenarios that are annotated by @negative tag.
Feature: Login Feature
@negative
Scenario Outline: I login the website with invalid username and invalid password
Given I am on the login page
When I try to login with "<username>" and "<password>"
Then I verify invalid login message
Examples:
| username | password |
| onur@ | 11223344 |
@negative
Scenario Outline: I login the website with empty username and empty password
Given I am on the login page
When I try to login with "<username>" and "<password>"
Then I verify invalid login message
Examples:
| username | password |
| | |LoginSteps Class
Our step definitions are in this class. Here I auto-wired the page classes and defined the steps of our scenarios.
public class LoginSteps {
@Value("${browser}")
private String browser;
@LazyAutowired
private HomePage homePage;
@LazyAutowired
private LoginPage loginPage;
@Given("I am on the login page")
public void iAmOnTheLoginPage() {
homePage
.goToHomePage()
.goToLoginPage();
}
@When("I try to login with {string} and {string}")
public void iTryToLoginWithAnd(String userName, String password) {
loginPage
.login(userName, password);
}
@Then("I verify invalid login message")
public void iVerifyInvalidLoginMessage() {
if (!browser.equalsIgnoreCase("firefox")) {
loginPage
.verifyLogEntryFailMessage();
} else {
loginPage.verifyPasswordErrorMessageWithCss("E-posta adresiniz veya şifreniz hatalı");
}
}
}Cucumber Hooks Class
Cucumber hooks are essential to do operations before or after the steps. Here I defined these operations by using Cucumber Hooks.
public class CucumberHooks {
@LazyAutowired
private ScreenshotUtil screenshotUtil;
@LazyAutowired
private ApplicationContext applicationContext;
@AfterStep
public void afterStep(Scenario scenario){
if(scenario.isFailed()){
scenario.attach(this.screenshotUtil.getScreenshot(), "image/png", scenario.getName());
}
}
@After
public void afterScenario(){
this.applicationContext.getBean(WebDriver.class).quit();
}
}CucumberSpringContextConfig Class
This class is necessary for Cucumber and Spring boot integration. I annotated this class with @CucumberContextConfiguration and @SpringBootTest annotations.
@CucumberContextConfiguration
@SpringBootTest
public class CucumberSpringContextConfig {
}RunCucumberTest Class
This class is for running the Cucumber tests. I used Cucumber JVM 7.x version and Junit Jupiter’s @Suite annotations. @SelectDirectories annotation is for the feature files, and @ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = “com.swtestacademy.springbootselenium.cucumber”) is for gluing the steps files.
@Suite
@IncludeEngines("cucumber")
@SelectDirectories("src/test/java/com/swtestacademy/springbootselenium/cucumber/features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.swtestacademy.springbootselenium.cucumber")
@ConfigurationParameter(key = Constants.PLUGIN_PUBLISH_QUIET_PROPERTY_NAME, value = "true")
//@ConfigurationParameter(key = Constants.FILTER_TAGS_PROPERTY_NAME, value = "@negative")
public class RunCucumberTest {
}After implementing this class, we can run the cucumber test with the command below.
mvn -Dtest="com.swtestacademy.springbootselenium.cucumber.RunCucumberTest" test
or we can use the command below as well.
mvn clean install -Dcucumber.glue="com.swtestacademy.springbootselenium.cucumber.steps" -Dcucumber.plugin="com/swtestacademy/springbootselenium/cucumber/features"
We can also add Cucumber properties to junit-platform.properties file as shown below.
cucumber.publish.enabled=true cucumber.glue=com.swtestacademy.springbootselenium.cucumber cucumber.execution.parallel.enabled=true cucumber.execution.parallel.config.strategy=dynamic cucumber.plugin=pretty, html:target/cucumber-reports/Cucumber.html, json:target/cucumber-reports/Cucumber.json, junit:target/cucumber-reports/Cucumber.xml
I will share the other properties files that I used in the project.
application.properties
The default configuration properties file is application.properties.
application.url=http://www.n11.com/
screenshot.path=/Users/onur/Desktop/temp
browser=chrome
logging.level.root=INFO
logging.file.name=${screenshot.path}/test-execution.log
default.timeout=30
#logging.pattern.file=%d %p %c{1.} [%t] %m%n
#logging.pattern.console=%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%napplication-grid.properties
When we run the tests on Selenium Grid, I have to use application-grid.properties file as a configuration file.
selenium.grid.url=http://localhost:4444/wd/hub browser=chrome
junit-platform.properties
These are the properties of JUnit Jupiter for parallel test execution and cucumber test executions.
junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.config.strategy=dynamic cucumber.publish.enabled=true cucumber.glue=com.swtestacademy.springbootselenium.cucumber cucumber.execution.parallel.enabled=true cucumber.execution.parallel.config.strategy=dynamic cucumber.plugin=pretty, html:target/cucumber-reports/Cucumber.html, json:target/cucumber-reports/Cucumber.json, junit:target/cucumber-reports/Cucumber.xml
Spring Aspect Oriented Programming classes in the project are ElapsedTimeAspect and ScreenshotAspect classes.
ElapsedTimeAspect Class
This class is annotated with @Aspect annotation, and it uses the @Around annotation of aspectjweaver to measure the elapsed time of the methods annotated with @ElapsedTime annotation.
@Aspect
@Configuration
@Slf4j
public class ElapsedTimeAspect {
@Around("@annotation(com.swtestacademy.springbootselenium.annotations.ElapsedTime)")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
Object obj = proceedingJoinPoint.proceed();
long duration = System.currentTimeMillis() - startTime;
log.info("Elapsed time of {} class's {} method is {}", proceedingJoinPoint
.getSignature()
.getDeclaringTypeName(),
proceedingJoinPoint
.getSignature()
.getName(), duration + " ms.");
return obj;
}
}ScreenshotAspect Class
This class is annotated with @Aspect annotation, and it uses the @After annotation of aspectjweaver to take screenshots after the methods annotated with @TakeScreenshot annotation.
@Aspect
@Component
public class ScreenshotAspect {
@Autowired
private ScreenshotUtil screenshotUtil;
@After("@annotation(takeScreenshot)")
public void after(JoinPoint joinPoint, TakeScreenshot takeScreenshot) throws IOException {
this.screenshotUtil.takeScreenShot(joinPoint.getSignature().getName());
}
}How to Run Tests
We can run the test in the command line with the maven command below. The below command is for the zhs terminal.
mvn -Dtest="com.swtestacademy.springbootselenium.tests.**" test
The command below is for the bash terminal.
mvn -Dtest=com.swtestacademy.springbootselenium.tests.** test
If we want to select a specific profile, we have to specify this as shown below.
mvn -Dtest="com.swtestacademy.springbootselenium.tests.**" -Dspring.profiles.active=grid test
For selenium grid execution, we should activate the selenium grid by running the Selenium Docker compose file.
docker-compose -f docker-compose-v3.yml up
# To execute this docker-compose yml file use `docker-compose -f docker-compose-v3.yml up`
# Add the `-d` flag at the end for detached execution
# To stop the execution, hit Ctrl+C, and then `docker-compose -f docker-compose-v3.yml down`
version: "3"
services:
chrome:
image: selenium/node-chrome:4.1.2-20220131
shm_size: 2gb
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=5
edge:
image: selenium/node-edge:4.1.2-20220131
shm_size: 2gb
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=5
firefox:
image: selenium/node-firefox:4.1.2-20220131
shm_size: 2gb
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=5
selenium-hub:
image: selenium/hub:4.1.2-20220131
container_name: selenium-hub
ports:
- "4442:4442"
- "4443:4443"
- "4444:4444"After this step, we can check the grid via this link: http://localhost:4444/ui/index.html#/
And then, we can run the tests via IntelliJ or maven.
On IntelliJ, you need to specify the spring profile as grid: spring.profiles.active=grid
When tests are running, you can see the status on the dashboard.
Via maven you should use the command below:
mvn -Dtest="com.swtestacademy.springbootselenium.tests.**" -Dspring.profiles.active=grid test
If you want to run the cucumber tests, you can use the command below:
mvn -Dtest="com.swtestacademy.springbootselenium.cucumber.RunCucumberTest" test
or
mvn clean install -Dcucumber.glue="com.swtestacademy.springbootselenium.cucumber.steps" -Dcucumber.plugin="com/swtestacademy/springbootselenium/cucumber/features"
On IntelliJ, you can right-click the “RunCucumberTest” class and run the tests.
That’s all for this article. It is a long yet informative and comprehensive article.
GitHub Project for Selenium Spring Boot Cucumber Test Automation Project
https://github.com/swtestacademy/selenium-springboot/tree/junit-springboot-selenium
Thanks for reading.
Onur Baskirt

Onur Baskirt is a Software Engineering Leader with international experience in world-class companies. Now, he is a Software Engineering Lead at Emirates Airlines in Dubai.












This is pretty neat!! Nice blog 👍
How is your set-up different from the usual Selenium+JUnit+Cucumber+Grid set-up?
Being relatively new to Spring boot and Selenium, I am not able to comprehend the advantage of using Spring boot for web application test automation
Running the grid part is the same. I used docker compose and brought the grid up and running. Implementation side, I used grid properties in the resource folder, created a configuration for the grid, and created beans in the run time with the help of spring boot.
Spring boot has many advantages like dependency inversion (the beans/instances are created on the runtime). You do not need to instantiate the instances classical way. You need to autowire them. Also, spring boot comes with many annotations, which I shared in the article, providing many features. If you use spring boot in test automation, you will have more artillery in your arsenal, and you can do many more with the help of these.
Does this project support TestNG? I see TestNG related dependencies added to maven. I was not able to configure for Selenium+SpringBoot+TestNG. May be you can assist
Spring Beans are not injected into the Test Class when TestNG is used, However it works fine when JUnit is used. I posted in Stackoverflow regarding this issue (was not able to post the link here )
There are courses on Udemy by Vinoth Selvaraj and Karthik K.K. They used TestNG instead of JUnit 5. I have finished both courses. I also suggest checking those. Whenever I will have time, I will add TestNG support and put that code in another branch in github.
com.swtestacademy.springbootselenium.configuration.WebDriverConfig.java
should have below line for Chrome to work properly
WebDriverManager.chromedriver().setup();
similarly for other browsers as well in their respective methods before returning the corresponding driver;
example:
public WebDriver chromeDriver() {
WebDriverManager.chromedriver().setup();
return new ChromeDriver();
}
First, thank you very much for your comment. I have the native browser executables in my machine that’s why for these cases no need to use bonigarcia’s webdrivermanager and I faced several issues with it while I was running the tests. Please refer here: https:/install-chrome-driver-on-mac/
Hello, very details project, thank you for your work. Tell me please, when I run your example I can see colourful log evert scenario name, given, when, then and also name of class test which has all it. But in my project it is not work. Is this switchable function?
I think for that you need to install cucumber plugin to your IntelliJ IDEA.
I’m learning to take advantage of Spring Boot in my UI tests with the Selenium WebDriver API.
On that note, why does IntelliJ complain about there not being any beans of “WebDriver” type found in your BasePage class?
It should not complain because we are declaring the beans in configurations. It is working fine on my machine.
cannot get it to setup sir
hi, great blog!
what is the advantage of SimpleThreadScope if you are anyway closing the WebDriver after every test?
Doesn’t closing the WebDriver after every test make it slow?
also, maybe I am wrong, could you please give it a try?
In my opinion, if you had for e.g. 10 test cases, all of them would spin up a new chrome instance for local. this is because I guess parallel tests use fork join pool behind the scenes, so SimpleThreadScope treats them as separate threads.
You could maybe emulate this behavior by just copy-pasting what is there in Login.feature file 4-5 times over there itself.
This is for full parallelism for each atomic tests. If you run the tests in parallel it is fine imo.
Hi, my chrome version is “101.0.4951.67” but ı get error;
“org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name ‘homePage’: Unsatisfied dependency expressed through field ‘driver’; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name ‘chromeDriver’ defined in class path resource [com/swtestacademy/springbootselenium/configuration/WebDriverConfig.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.openqa.selenium.WebDriver]: Factory method ‘chromeDriver’ threw exception; nested exception is java.lang.IllegalStateException: The path to the driver executable The path to the driver executable must be set by the webdriver.chrome.driver system property; for more information, see https://github.com/SeleniumHQ/selenium/wiki/ChromeDriver. The latest version can be downloaded from https://chromedriver.storage.googleapis.com/index.html”
how can resolve? Thanks.
Would you try these in this article: https:/install-chrome-driver-on-mac/
Hi Onur,
How do we retry failed scenarios with this framework ?
Regards,
Quy
You can check here: https:/junit-5-how-to-repeat-failed-test/
Hi Onur,
Great guide.. have learned a lot from it.
Could you explain the scoping in bit more detail please.
Thanks
For more details, I highly suggest this course: https://www.udemy.com/course/cucumber-with-spring-boot/
Great article! I loved the ability to clone the project and get a complete, up-to-date setup for Selenium and Cucumber in a Spring setup.
I second the motion to include “WebDriverManager.chromedriver().setup();” in the code, at least as commented code. The line will help people who are not too technical or have issues downloading and using a native browser.
Also having two “steps”-directories, with two LoginSteps-classes got me confused for a bit. I prefer all steps under cucumber, but I am guessing this was just some forgotten cleanup?
Thank you so much for your comment Dag. In 2023, I will also try to update the articles and the codes. :-) When I wrote the article, I used the latest technology stack. I am happy to hear that you did not face any errors.
For some reason parallelism is not working. Even though I set to 2, it is still launching as many browser windows as the number of tests to execute. Do I have to make any changes to get it working?
Hi, maybe the reason is dynamic parallelism. In the below config properties, I suggest changing the strategy to fixed rather than dynamic.
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.config.strategy=dynamic
cucumber.publish.enabled=true
cucumber.glue=com.swtestacademy.springbootselenium.cucumber
cucumber.execution.parallel.enabled=true
cucumber.execution.parallel.config.strategy=dynamic
Details:
dynamic
Computes the desired parallelism based on the number of available processors/cores multiplied by the junit.jupiter.execution.parallel.config.dynamic.factor configuration parameter (defaults to 1).
fixed
Uses the mandatory junit.jupiter.execution.parallel.config.fixed.parallelism configuration parameter as the desired parallelism.
custom
Allows you to specify a custom ParallelExecutionConfigurationStrategy implementation via the mandatory junit.jupiter.execution.parallel.config.custom.class configuration parameter to determine the desired configuration.
Please try below configs:
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism = 2
cucumber.publish.enabled=true
cucumber.glue=com.swtestacademy.springbootselenium.cucumber
cucumber.execution.parallel.enabled=true
cucumber.execution.parallel.config.strategy=fixed
cucumber.execution.parallel.config.fixed.parallelism = 2