Selenium with Java
Selenium was originally written in Java, and the Java bindings remain the most feature-complete and widely documented of all Selenium language bindings. Enterprise organizations with existing Java codebases benefit from consistent tooling, IDE support from IntelliJ IDEA and Eclipse, and a massive ecosystem of supporting libraries for reporting, data management, and CI/CD integration.
Step 1: Set Up a Maven Project with Selenium
Maven is the standard build tool for Java Selenium projects. It handles dependency management, compilation, test execution, and packaging. Start by creating a new Maven project in your IDE or from the command line with mvn archetype:generate.
Add the Selenium Java dependency to your pom.xml file within the <dependencies> section. The group ID is org.seleniumhq.selenium, the artifact ID is selenium-java, and you should use the latest Selenium 4 version. This single dependency pulls in WebDriver, all browser-specific modules, and Selenium Manager for automatic driver management.
Add your test framework dependency alongside Selenium. For JUnit 5 (Jupiter), use group ID org.junit.jupiter and artifact ID junit-jupiter. For TestNG, use org.testng and testng. Both frameworks are fully compatible with Selenium and provide annotations, assertions, and test lifecycle management. JUnit 5 is the modern choice for new projects, while TestNG remains popular in organizations with existing TestNG infrastructure.
Configure the Maven Surefire plugin to recognize your test classes. By default, Surefire looks for classes ending with "Test" or "Tests" in the src/test/java directory. This convention-based approach means most projects work without additional configuration.
Gradle is an alternative to Maven that uses a Groovy or Kotlin DSL instead of XML. The dependency declarations are equivalent: testImplementation 'org.seleniumhq.selenium:selenium-java:4.x.x'. Choose whichever build tool your team is already using. Both work equally well with Selenium.
Verify your setup by creating a simple test class in src/test/java that instantiates a ChromeDriver, navigates to a URL, and asserts the page title. Run it with mvn test from the command line or use your IDE's built-in test runner. If Chrome opens and the test passes, your project is correctly configured.
Step 2: Configure WebDriver and Browser Options
In Selenium 4 Java, creating a WebDriver instance with default settings is a single line: WebDriver driver = new ChromeDriver();. Selenium Manager detects your Chrome version, downloads the matching ChromeDriver binary, and starts the browser automatically. No manual driver management is needed.
Browser customization uses the Options classes. Create a ChromeOptions object and call addArguments() to set command-line switches. Essential arguments include --headless=new for headless execution in CI environments, --window-size=1920,1080 to set a consistent viewport, --disable-extensions for faster startup, and --no-sandbox for running in Docker containers. Pass the options to the ChromeDriver constructor: new ChromeDriver(options).
Firefox configuration follows the same pattern with FirefoxOptions. Headless mode is enabled with options.addArguments("-headless"). Firefox profiles control browser preferences like download directories, proxy settings, and security configurations. Create a FirefoxProfile, set preferences with setPreference(), and attach it to the options object.
Edge uses EdgeOptions, which inherits from ChromeOptions since Edge is Chromium-based. Safari uses SafariOptions with fewer customization options but reliable behavior on macOS. The WebDriver interface is the same across all browsers, so your test code does not need to know which browser is running.
For remote execution with Selenium Grid, pass a URL and capabilities to RemoteWebDriver instead of using a browser-specific driver. The constructor takes the Grid Hub URL and an Options object that specifies the desired browser. This switch from local to remote execution requires changing only the driver initialization, not the test code itself.
Resource cleanup is critical in Java Selenium. Always call driver.quit() in an @AfterEach (JUnit 5) or @AfterMethod (TestNG) annotated method to close the browser and terminate the driver process after each test. Using try-with-resources is not possible since WebDriver does not implement AutoCloseable, so lifecycle annotations in your test framework are the cleanest approach.
Step 3: Find Elements and Perform Actions
Element location in Selenium Java uses driver.findElement(By.strategy(value)). The By class provides factory methods: By.id("elementId"), By.name("fieldName"), By.className("css-class"), By.tagName("div"), By.cssSelector("div.container > p"), By.xpath("//button[@type='submit']"), By.linkText("Full Link Text"), and By.partialLinkText("Partial").
The findElement() method returns a WebElement object. If no matching element exists, it throws a NoSuchElementException. The findElements() method (plural) returns a List<WebElement>, which is empty (not an exception) if no matches are found. Use findElements when you expect multiple matches or want to check existence without catching exceptions.
WebElement interaction methods include click() for mouse clicks, sendKeys("text") for typing into input fields, clear() to empty an input, getText() to read the visible text, getAttribute("name") to read HTML attributes, isDisplayed() to check visibility, isEnabled() to check if the element is interactive, and isSelected() to check the state of checkboxes and radio buttons.
Selenium 4 introduced relative locators through the RelativeLocator.with() method. You can find elements based on their position relative to another element: driver.findElement(with(By.tagName("input")).above(submitButton)) finds the input field located above the submit button. Other relative methods include below(), toLeftOf(), toRightOf(), and near(). These locators are useful when elements lack unique identifiers but have a consistent spatial relationship to other elements.
Complex interactions use the Actions class. Create an Actions object with new Actions(driver), chain interaction methods, and call .perform(). Common action chains include hover (moveToElement), drag and drop (dragAndDrop), double-click (doubleClick), right-click (contextClick), and keyboard combinations (keyDown(Keys.CONTROL).click(element).keyUp(Keys.CONTROL) for Ctrl+Click).
JavaScript execution through ((JavascriptExecutor) driver).executeScript("return document.title") runs arbitrary JavaScript in the browser context. The cast to JavascriptExecutor is necessary because the WebDriver interface does not include this method directly. JavaScript execution is essential for scrolling, interacting with shadow DOM elements, reading computed styles, and working around elements that are technically visible but obscured by overlapping layers.
Step 4: Implement Waits and Synchronization
Java's WebDriverWait class provides explicit waits with strong type safety. Create a wait with WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10)) and call wait.until() with an expected condition. The Duration class replaced the old integer seconds parameter in Selenium 4, providing clearer intent and consistency with Java's time API.
The ExpectedConditions class provides dozens of pre-built conditions. The most commonly used are presenceOfElementLocated(By locator) for checking DOM presence, visibilityOfElementLocated(By locator) for visible elements, elementToBeClickable(By locator) for elements ready for interaction, textToBePresentInElement(WebElement element, String text) for content verification, and invisibilityOfElementLocated(By locator) for waiting until loaders or overlays disappear.
The wait returns the WebElement when the condition is satisfied, allowing you to chain the wait directly into your interaction: wait.until(ExpectedConditions.elementToBeClickable(By.id("submit"))).click(). This pattern is concise and expressive, clearly communicating that you are waiting for the button to become clickable before clicking it.
Custom expected conditions are implemented by providing a Function<WebDriver, T> lambda. For example, waiting for a specific number of search results: wait.until(d -> d.findElements(By.cssSelector(".result")).size() >= 10). The lambda receives the WebDriver instance and returns a truthy value when the condition is met, or null/false to keep waiting.
FluentWait extends WebDriverWait with additional configuration. You can set the polling interval (how often the condition is checked) with pollingEvery(Duration.ofMillis(500)) and specify exceptions to ignore during polling with ignoring(NoSuchElementException.class). FluentWait is useful for slow-loading elements where you want to reduce polling overhead, or for conditions that throw exceptions before stabilizing.
Avoid implicit waits in Java projects. While driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10)) is easy to set, it applies globally and interacts unpredictably with explicit waits. The Selenium documentation explicitly recommends not mixing implicit and explicit waits. Use explicit waits exclusively for predictable, maintainable timing control.
Step 5: Apply the Page Object Model
The Page Object Model (POM) is the most important design pattern for Selenium Java projects. Each page or significant component of the application is represented by a Java class that encapsulates its elements and behaviors. Tests interact with page objects, not with raw WebDriver calls.
A page object class typically has three sections. First, WebElement fields annotated with @FindBy that define how to locate elements. Second, a constructor that accepts a WebDriver instance and initializes the page with PageFactory.initElements(driver, this). Third, methods that represent user actions on the page, like login(String user, String pass) or searchFor(String query).
The @FindBy annotation supports all locator strategies: @FindBy(id = "username"), @FindBy(css = ".submit-btn"), @FindBy(xpath = "//h1"), and so on. PageFactory uses these annotations to lazily initialize WebElement fields, meaning the element is located on the page each time it is accessed rather than at construction time. This lazy evaluation helps with dynamic pages where elements may not exist immediately.
Page methods should return meaningful values. A login method should return the next page object (for example, a DashboardPage), making test flows read as a chain: DashboardPage dashboard = loginPage.login("user", "pass"). A search method that stays on the same page returns this. This fluent interface approach creates self-documenting test code.
Base page classes reduce duplication by holding common functionality shared across all pages. A BasePage class typically holds the WebDriver instance, common header and footer interactions, and utility methods like waiting for page load or scrolling. All page objects extend this base class, inheriting the shared functionality.
For larger applications, consider organizing page objects into packages that mirror the application's navigation structure. Group related pages together and create component objects for reusable UI components like navigation bars, data tables, and modal dialogs that appear across multiple pages.
Step 6: Run Tests with JUnit or TestNG
JUnit 5 organizes tests with annotations. @Test marks a test method. @BeforeEach runs setup before each test (typically creating the WebDriver). @AfterEach runs cleanup after each test (calling driver.quit()). @DisplayName provides human-readable test names. @Tag categorizes tests for selective execution.
JUnit 5 assertions verify expected behavior: assertEquals("Expected Title", driver.getTitle()), assertTrue(element.isDisplayed()), assertNotNull(element). For more readable assertions, consider adding AssertJ as a dependency, which provides a fluent assertion API: assertThat(driver.getTitle()).contains("Dashboard").
TestNG offers features particularly suited to Selenium: data-driven testing with @DataProvider that feeds different data sets into the same test method, test grouping with groups parameter, parallel execution configuration in testng.xml, and built-in reporting. The @DataProvider feature is especially useful for Selenium tests that need to verify the same workflow with different inputs, like testing login with valid credentials, invalid credentials, and locked accounts.
Parallel test execution reduces the total time for large test suites. In JUnit 5, configure parallel execution in junit-platform.properties by setting junit.jupiter.execution.parallel.enabled=true. In TestNG, set thread counts and parallel modes in testng.xml. Each parallel thread needs its own WebDriver instance, so use ThreadLocal storage or test framework lifecycle methods to isolate browser sessions.
CI/CD integration with Maven is straightforward. Jenkins, GitHub Actions, GitLab CI, and Azure DevOps all support mvn test as a build step. The Maven Surefire plugin generates XML reports that CI systems parse for test results. For GitHub Actions, add a workflow YAML that installs Java, caches Maven dependencies, and runs the test suite. Include ChromeDriver installation or use headless Chrome in Docker for consistent CI behavior.
Test reporting beyond the default Surefire output can be enhanced with libraries like Allure, ExtentReports, or ReportNG. These generate HTML reports with test timelines, screenshots on failure, log attachments, and historical trends. Capturing screenshots on test failure is a common pattern: use the TakesScreenshot interface in your @AfterEach method to save a screenshot whenever a test fails, then attach it to the report for easy debugging.
Java's strong typing, mature tooling, and enterprise ecosystem make it the best-supported language for large-scale Selenium automation. Use Maven for dependency management, explicit waits for reliability, the Page Object Model with PageFactory for maintainability, and JUnit 5 or TestNG for structured test execution.