Selenium WebDriver And Java
Categories:
WebDriver is a popular Browser Automation Library that has done more to push for standardised interfaces for browser automation than any other.
Introduction
This page provides an overview of working with WebDriver and Java.
- Basics
- A first test
- Using JUnit to make execution robust
- Abstractions to make maintenance easier (Page Objects)
- Synchronization to avoid flakiness
- Waits vs Assertions
- WebDriver in CI
Video Overview
The video shows the steps of working through from a simple test, to a reliable test backed by easy to maintain abstractions.
Using Selenium WebDriver With Java
Selenium WebDriver provides official documentation for getting started:
This section provides an overview of the most common first steps for working with Selenium WebDriver.
Source code shown in this section is available in Github:
Preqrequisites:
- Install and install a Java SDK
- Install an IDE
- Install a dependency management system
- I default to Maven and this guide uses Maven
- https://maven.apache.org/install.html
Getting Started Quickly With
Add WebDriver and Junit to Maven
We only need to add two dependencies in our project to work with Selenium WebDriver and Java.
<dependencies>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.36.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.14.0</version>
<scope>test</scope>
</dependency>
</dependencies>
You can find the most up to date versions on the Junit and WebDriver sites:
- Selenium WebDriver Version: https://www.selenium.dev/downloads/
- Junit version: https://junit.org/
See example pom.xml on Github
Write a First Test
Your first test is likely to be something like this:
public class AFirstChromeTest {
@Test
public void myFirstWebDriverTest(){
WebDriver driver = new ChromeDriver();
driver.get(
"https://testpages.eviltester.com/pages/basics/basic-web-page/"
);
WebElement button = driver.findElement(By.id("button1"));
Assertions.assertEquals("Click Me", button.getText());
driver.quit();
}
}
The following code instantiates a new ChromeDriver, which means that we will be automating the Chrome browser.
WebDriver driver = new ChromeDriver();
WebDriver uses the Selenium Manager to download the browser and driver needed to execute the test.
Then we need to navigate to the page:
driver.get(
"https://testpages.eviltester.com/pages/basics/basic-web-page/"
);
WebDriver will wait, until the page has loaded so you don’t need to synchronize on page loading.
We then find an element on the page that we want to check has a specific value, for this test we are finding the H1 heading. To find an element we have to choose a method with which to find it By, this is the locator strategy. In this case I am just using the id. There are many By methods and you can use tagName, className or Css Selectors or XPath.
WebElement button = driver.findElement(By.id("button1"));
We can then use the WebElement to click, or getAttributes, but in this case we are checking the text of the button.
Assertions.assertEquals("Click Me", button.getText());
And then we close the browser:
driver.quit();
A fairly typical first test and it should run fine.
See example on Github
Make Test Robust Using JUnit
One issue with this test is that if it fails, we will not close the browser.
This is why we tend to use JUnit features like @BeforeAll and @BeforeEach to start the browser and load the page. Then @AfterEach and @AfterAll to close the browser.
For this test, I will:
- create the ChromeDriver instance before any test has run using
@BeforeAll- so a single Driver instance is used for all tests
- load the page using
@BeforeEach- so if anything goes wrong, the application is reset for each test
- then close the browser using
@AfterAll- so if a test fails, we still close the browser at the end
This means that we are using the Test Execution Framework (JUnit) to handle setup and tear down, and our actual @Test method for condition checking is much smaller and easier to read.
public class BeforeAfterChromeTest {
static WebDriver driver;
@BeforeAll
public static void initiateWebDriver(){
driver = new ChromeDriver();
}
@BeforeEach
public void loadPage(){
driver.get(
"https://testpages.eviltester.com/pages/basics/basic-web-page/"
);
}
@Test
public void buttonHasCorrectText(){
WebElement button = driver.findElement(By.id("button1"));
Assertions.assertEquals("Click Me", button.getText());
}
@AfterAll
public static void closeWebDriver(){
driver.quit();
}
}
See example on Github
Make Tests Maintainable - Page Objects
If all your tests looked like this however, you would create a very hard to maintain set of tests.
Using driver at a test level makes the test harder to maintain.
We tend to create classes which represent the application we are testing, these are known as Page or Component Objects.
This way our tests are more maintainable because the test focuses on ‘what we want to do’ and the abstraction classes focus on ‘how we do it’. We maintain the abstraction classes when the application changes, and maintain the tests when the intent of what we want to check changes.
For this simple example I will create a:
SiteConfigclass which abstracts away the environment we are working withBasicWebPageclass which abstracts away the structure of the page
See example on Github
Simpler Test Code
My @Test method will become more readable and maintainable:
@Test
public void pageHasCorrectButtonText(){
Assertions.assertEquals(page.getButtonText(), "Click Me");
}
I would now only have to change my @Test method if the condition I am asserting for changes.
I setup the page object in the @BeforeEach, it could be done in any of the @Before... methods.
@BeforeEach
public void loadPage(){
page = new BasicWebPage(driver);
page.get();
}
POJO for Page Object
And the Page Object itself is a simple Plain Old Java Object.
public class BasicWebPage {
private final WebDriver driver;
// locators
public final By CLICK_ME_BUTTON = By.id("button1");
public BasicWebPage(WebDriver driver) {
this.driver = driver;
}
// navigators
public void get() {
driver.get(SiteConfig.getDomain() + "/pages/basics/basic-web-page/");
}
// element accessors
private WebElement getButton(){
return driver.findElement(CLICK_ME_BUTTON);
}
// helpers
public String getButtonText() {
return getButton().getText();
}
}
Site Config
And I created a SiteConfig object:
public class SiteConfig {
public static String getDomain(){
return "https://testpages.eviltester.com";
}
}
This would make it easier for me to switch environments and run the automated coverage against my local machine, or a staging environment, or even production (which is the case for this setup).
Pros and Cons
There are many ways to design Page Objects, and the style you choose will depend on your experience, and the needs of your team and project.
In this Page Object, the only publicly available method is getButtonText. My test coverage is very simple so this is all I need at the moment. I might choose to make the Element Accessor methods public, so that I can access the raw WebElement in the @Test, this might make my tests more vulnerable to changes in the application.
I haven’t chosen to make the locators public CLICK_ME_BUTTON but for some types of automating I might need access to this.
When first starting out and learning to automate, I recommend using more ‘high level’ access methods, and not exposing the WebElement accessors or locators. This will help keep your tests abstracted from the structure of the page. But as you expand the amount of coverage and you grow in experience, you will make choices that allow you the flexibility you require to automate and you will understand the associated maintenance trade offs that the decisions incur.
Also, I recommend that you refactor to Page Objects, rather than writing Page Objects to cover the full scope of the page.
- write the test code, however you like
- refactor working test code, into Page Objects, in a way that makes the test more readable
- assess the maintainability of your Page Objects and refactor them further to improve the design
If you write your Page Objects in advance of using them to support automated coverage then you run the risk of:
- creating Page Objects with methods you never use
- creating Page Objects with bugs that are harder to debug because you never saw the code working before being used in an abstraction
- spending a lot of time creating a ‘framework’ with no automated coverage to justify the effort
- creating Page Objects that don’t meet the needs of the automated execution
Additional Advantages of Page Objects
One beneficial side-effect of Page Objects and abstraction layers is that they provide an easy to use mechanism for assessing coverage of page functionality.
If you are using page objects in most tests then you can see, by reviewing the page object, which parts of the page you have used. The Page Object methods essentially map to coverage of the page.
If you primarily use logical functional methods then you can see the type of logical functionality you have covered in your execution.
There is no guarantee of how much, or how well, you have covered the functionality from a test condition perspective, but at least you can see you have used the element or functionality.
Without page objects your review would be performed at a Test method and test Class level, and usage coverage might be harder to assess.
Synchronization with WebDriverWait
One other very important concept to be aware of is the notion of synchronization.
This means waiting for the state of the page to be what you want before performing the next action.
e.g.
- waiting for a button to become enabled before trying to click it
- waiting for message to appear before checking results
Failing to synchronize properly can make your tests have intermittent results. Meaning that sometimes they pass and sometimes they fail. We want our automated execution to be robust, and pass reliably, any failure is something that should be treated seriously, investigate and fixed.
It is important to learn about the Selenium WebDriver Support class WebDriverWait.
In the @Test method below I have created a WebDriverWait, which is configured to timeout after 5 seconds if the condition it is waiting for is not met.
The condition is using a built in support class called ExpectedConditions this has a lot of static methods for common synchronization patterns e.g. waiting for an element to be enabled, or not visible, or clickable, etc.
@Test
public void buttonHasCorrectText(){
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(5));
wait.until(ExpectedConditions.elementToBeClickable(page.getButton()));
Assertions.assertEquals("Click Me", page.getButtonText());
}
Also now you can see that I have made the Element Accessor getButton in the page public so that I an use it an a synchronization role. I could have chosen to make the locator public instead.
wait.until(
ExpectedConditions.elementToBeClickable(
page.CLICK_ME_BUTTON
));
The synchronization might also be added to the Page Object e.g.:
- a
waitTillPageReadymethod that waits until the page is populated with dynamic data - a
submitFormmethod that waits until a success or failure method is shown on the page
There are many approaches. There are also support classes in WebDriver like SlowLoadableComponent that you might use in the future.
See example on Github
Waiting as an Assertion
When the button on the page is clicked, a message is shown “You clicked the button!”.
We could test for that by clicking the button and checking that the message is displayed.
@Test
public void clickingButtonShowsMessage(){
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(5));
wait.until(ExpectedConditions.elementToBeClickable(page.CLICK_ME_BUTTON));
page.getButton().click();
WebElement message = page.getClickMessage();
Assertions.assertEquals("You clicked the button!", message.getText());
}
The assertion on the button text might introduce intermittency if some delay was introduced prior to the message text being visible.
So we might choose to wait for the message text.
String successMessage = "You clicked the button!";
wait.until(ExpectedConditions.textToBe(page.CLICK_MESSAGE, successMessage));
Assertions.assertEquals(successMessage, message.getText());
If the wait timed out then a TimeoutException exception would be thrown. This would fail the @Test execution.
We could then decide that we do not need the assert which follows the wait condition because, if the wait succeeds then we know the text “You clicked the button!” is present in the element, so why should we assert a condition which we already know to be true?
This is the type of decision that you have to make as a team.
Pros:
- we can amend the synchronization condition independent of the assertion to check for different conditions
- we can see in the test that it is asserting something
- someone might remove the wait condition thinking it to be superfluous and we would still be left with an assertion
- we could add static analysis to make sure that all
@Testmethods have assertions
Cons:
- it is another line of code to maintain
- it is more code to read
- we might have to keep justifying why we are asserting something we know to be true
We might choose to move the wait into an abstraction. Or even use a different ExpectedCondition, for example, we could wait for the value to be not "", and then we are not repeating the assertion.
wait.until(
ExpectedConditions.not(
ExpectedConditions.textToBe(
page.CLICK_MESSAGE,
""
)));
When working on personal projects I sometimes do not add the asserts because I’m comfortable reviewing my code that the failing wait is good enough.
When working on commercial projects I want to add an assert regardless of the waiting condition to future proof my test code from other people’s changes and to make code reviews easier. So I adopt strategies like:
- wait for conditions different to the assertion
- move the synchronization into an abstraction so that it doesn’t add noise to the
@Testcode
As an example, moving the condition into the page abstraction makes the test clean.
This way the Page Object does not ‘say’ what the message is, but it knows when the message is shown or not.
The synchronization and assertion can use different conditions
wait.until(page.showsMessage());
Assertions.assertEquals("You clicked the button!", message.getText());
wait.until(page.successMessageNotShown());
Assertions.assertEquals("", message.getText());
Using Different Browsers: Firefox, Edge, etc.
At the moment the test only runs on Chrome.
I could have chosen to run on Firefox, or Edge or Safari (if I was running mac).
This is done by instantiating a different Driver.
WebDriver driver = new FirefoxDriver();
See example on Github
Headless Execution
Sometimes, primarily for Continuous Integration, we might want to run the automated coverage with the browsers in headless mode, where the UI is not rendered.
This can be achieved by using the Options class when we instantiate the browser.
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless");
driver = new ChromeDriver(options);
The code below show the use of an environment variable, configuring the use of headless or not.
ChromeOptions options = new ChromeOptions();
if(System.getenv().
getOrDefault("BROWSER_STATE","show").
equals("Headless")){
options.addArguments("--headless");
}
driver = new ChromeDriver(options);
See example on Github
Non-Headless in CI
With most CI systems it is possible to run the browsers without requiring headless mode.
This is done by using the virtual X server (Xvfb).
There is a handy Github Action to help with this.
- name: Run tests using a virtual display
uses: GabrielBB/xvfb-action@v1
with:
run: mvn test
How will you know if this is an issue?
If ChromeDriver does not start or shows errors in your CI logs.
- run the driver with verbose logging
- review the logs for the error
When attempting to debug this it can be helpful to run a single test.
e.g. mvn test -Dtest=AFirstChromeTest
If the error occurs with this test then enable the verbose logging:
- choose a location for the log file with environment variable
webdriver.chrome.logfile
- turn on verbose logging with environment variable
webdriver.chrome.verboseLogging=true
e.g.
mvn -Dwebdriver.chrome.logfile=/home/runner/chromedriver.log
-Dwebdriver.chrome.verboseLogging=true test -Dtest=AFirstChromeTest
The logs are written to the file and you an spot the error.
To do this in Github actions:
- name: Test with Maven (and xvfb)
run: mvn -Dwebdriver.chrome.logfile=${{ github.workspace }}/chromedriver.log -Dwebdriver.chrome.verboseLogging=true test
- name: Show logs
if: failure()
run: |
cat ${{ github.workspace }}/chromedriver.log
See example on Github
How to Learn WebDriver API?
WebDriver has a strong focus on Browser Automating, therefore it actually has quite a small API.
Much of it can be learned on the official documentation.
I recommend learning how to read the WebDriver source and examine the package structure of the library.
WebDriver is well documented in the code with JavaDoc which it is possible to see in IntelliJ by pressing the ctrl+Q shortcut key.
Also ctrl+click on any method wil take you to the WebDriver source code, and IntelliJ will prompt you to download sources. This will allow you to easily explore the API.