Integrating JUnit 5 with Selenium WebDriver

9 min read

If you built your Selenium framework with TestNG, the mental model transfers almost entirely to JUnit 5. The WebDriver API is identical — driver.findElement, driver.get, sendKeys, click are unchanged. What changes is everything around the test: lifecycle annotations, parameterisation, suite configuration, and extension hooks. This lesson rebuilds a Selenium test class in JUnit 5 style, shows the extension-based driver management from Chapter 4 in full context, and covers the cross-browser parameterisation pattern.

The simplest setup — @BeforeEach and @AfterEach

Before using the extension approach, it is worth seeing the basic setup — the same pattern you used in TestNG with @BeforeMethod and @AfterMethod, just with JUnit 5 names:

import org.junit.jupiter.api.*;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import static org.junit.jupiter.api.Assertions.*;
 
class LoginTest {
 
    WebDriver driver;
 
    @BeforeEach
    void setup() {
        driver = new ChromeDriver();
        driver.manage().window().maximize();
        driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(5));
    }
 
    @Test
    @DisplayName("should log in with valid credentials")
    void shouldLoginSuccessfully() {
        driver.get("https://myapp.com/login");
        driver.findElement(By.id("email")).sendKeys("alice@test.com");
        driver.findElement(By.id("password")).sendKeys("password");
        driver.findElement(By.cssSelector("[data-testid='submit']")).click();
        assertEquals("Dashboard", driver.getTitle());
    }
 
    @Test
    @DisplayName("should show an error for wrong password")
    void shouldRejectWrongPassword() {
        driver.get("https://myapp.com/login");
        driver.findElement(By.id("email")).sendKeys("alice@test.com");
        driver.findElement(By.id("password")).sendKeys("wrong");
        driver.findElement(By.cssSelector("[data-testid='submit']")).click();
        assertNotNull(driver.findElement(By.className("error-message")));
    }
 
    @AfterEach
    void teardown() {
        if (driver != null) driver.quit();
    }
}

The if (driver != null) guard in @AfterEach is important: if @BeforeEach throws (for example, ChromeDriver isn't installed), driver stays null. Without the guard, @AfterEach would throw a NullPointerException on top of the original setup failure, obscuring the real error.

The extension approach — clean test methods

The WebDriverExtension built in Chapter 4 removes all lifecycle boilerplate from the test class. Tests receive the driver as a parameter:

import org.junit.jupiter.api.extension.ExtendWith;
import org.openqa.selenium.WebDriver;
 
@ExtendWith(WebDriverExtension.class)
class LoginTest {
 
    @Test
    @DisplayName("should log in with valid credentials")
    void shouldLoginSuccessfully(WebDriver driver) {
        driver.get("https://myapp.com/login");
        driver.findElement(By.id("email")).sendKeys("alice@test.com");
        driver.findElement(By.id("password")).sendKeys("password");
        driver.findElement(By.cssSelector("[data-testid='submit']")).click();
        assertEquals("Dashboard", driver.getTitle());
    }
 
    @Test
    @DisplayName("should show error for wrong password")
    void shouldRejectWrongPassword(WebDriver driver) {
        driver.get("https://myapp.com/login");
        driver.findElement(By.id("email")).sendKeys("alice@test.com");
        driver.findElement(By.id("password")).sendKeys("wrong");
        driver.findElement(By.cssSelector("[data-testid='submit']")).click();
        assertNotNull(driver.findElement(By.className("error-message")));
    }
}

No @BeforeEach, no @AfterEach, no null check. The test reads purely as behaviour. The extension handles creation, lifecycle, and cleanup — including driver.quit() even if the test throws.

Page Object Model — unchanged

The POM pattern is identical between TestNG and JUnit 5. Page objects have no test-framework dependency — they are plain Java classes:

public class LoginPage {
 
    private final WebDriver driver;
 
    public LoginPage(WebDriver driver) {
        this.driver = driver;
    }
 
    public void navigateTo() {
        driver.get("https://myapp.com/login");
    }
 
    public DashboardPage loginAs(String email, String password) {
        driver.findElement(By.id("email")).sendKeys(email);
        driver.findElement(By.id("password")).sendKeys(password);
        driver.findElement(By.cssSelector("[data-testid='submit']")).click();
        return new DashboardPage(driver);
    }
 
    public String getErrorMessage() {
        return driver.findElement(By.className("error-message")).getText();
    }
}

Using it with JUnit 5:

@ExtendWith(WebDriverExtension.class)
class LoginPageTest {
 
    @Test
    @DisplayName("successful login navigates to dashboard")
    void successfulLogin(WebDriver driver) {
        LoginPage loginPage = new LoginPage(driver);
        loginPage.navigateTo();
        DashboardPage dashboard = loginPage.loginAs("alice@test.com", "password");
        assertEquals("Dashboard", dashboard.getTitle());
    }
}

Cross-browser testing with @ParameterizedTest

TestNG handles cross-browser testing through testng.xml parameters: you define <parameter name="browser" value="chrome"/> and inject it into @BeforeMethod. JUnit 5 uses @ParameterizedTest instead:

@ParameterizedTest(name = "Login on {0}")
@ValueSource(strings = {"chrome", "firefox"})
void testLoginAcrossBrowsers(String browser) {
    WebDriver driver = createDriver(browser);
    try {
        LoginPage loginPage = new LoginPage(driver);
        loginPage.navigateTo();
        DashboardPage dashboard = loginPage.loginAs("alice@test.com", "password");
        assertEquals("Dashboard", dashboard.getTitle());
    } finally {
        driver.quit();
    }
}
 
private WebDriver createDriver(String browser) {
    return switch (browser) {
        case "firefox" -> new FirefoxDriver();
        default        -> new ChromeDriver();
    };
}

The report shows two entries: Login on chrome and Login on firefox. For three browsers, add "edge" to the @ValueSource. No XML file needed.

For larger cross-browser matrices with login credentials, use @MethodSource:

@ParameterizedTest(name = "[{index}] {0} — {1}")
@MethodSource("browserAndUserCombinations")
void testLoginMatrix(String browser, String email, String password) { ... }
 
static Stream<Arguments> browserAndUserCombinations() {
    return Stream.of(
        Arguments.of("chrome",  "admin@test.com", "AdminPass"),
        Arguments.of("chrome",  "user@test.com",  "UserPass"),
        Arguments.of("firefox", "admin@test.com", "AdminPass")
    );
}

TestNG vs JUnit 5 for Selenium — the trade-offs

TestNG vs JUnit 5 for Selenium

TestNG

  • @BeforeMethod / @AfterMethod

    Lifecycle in the test class — simple and familiar for most Selenium developers.

  • testng.xml for browsers & groups

    Central suite file controls which tests run, with what parameters, in how many threads.

  • ITestListener for screenshots

    Register once in testng.xml, fires automatically on every failure across the suite.

  • Larger installed base

    Most Selenium tutorials, job descriptions, and open-source frameworks assume TestNG.

JUnit 5

  • @ExtendWith for lifecycle

    Extension injects the driver as a parameter — test methods contain only intent, no setup.

  • @ParameterizedTest for browsers

    Cross-browser via @ValueSource or @MethodSource — no XML file.

  • TestWatcher for screenshots

    Compose with WebDriverExtension in @ExtendWith — same result, pure Java configuration.

  • Growing momentum

    New projects, Spring Boot integration, and REST API test suites increasingly choose JUnit 5.

⚠️ Common mistakes

  • Not quitting the driver when a parameterised test throws. In the @ParameterizedTest cross-browser example above, a bare driver.quit() after the assertion will not run if the assertion fails. Always wrap in try/finally, or better, use the WebDriverExtension with @BeforeEach/@AfterEach so the extension always quits, regardless of test outcome.
  • Using @BeforeAll static to create a shared WebDriver. One driver shared across all test methods runs into state accumulation: cookies, localStorage, navigation history. Unless you need the performance gain of a single browser session, create a fresh driver per test with @BeforeEach or the extension.
  • Mixing JUnit 5 and TestNG annotations. Adding testng to the classpath alongside junit-jupiter means both @Test annotations are on the classpath. IntelliJ may suggest the wrong one. Check imports — org.junit.jupiter.api.Test for JUnit 5, org.testng.annotations.Test for TestNG. A method with the wrong @Test import will silently not run.

🎯 Practice task

Build a JUnit 5 Selenium test class. 30–40 minutes.

  1. Set up a Maven project with junit-jupiter, selenium-java, and webdrivermanager dependencies.
  2. Implement the full WebDriverExtension from Chapter 4 (or copy it from your Chapter 4 project). Register it with @ExtendWith.
  3. Write a LoginPageTest with at least three @Test methods (valid login, wrong password, empty fields). Each receives WebDriver driver as a parameter.
  4. Create a LoginPage page object. Move findElement calls into the page object. The test methods should only call high-level methods like loginPage.loginAs(email, password).
  5. Cross-browser test. Write one @ParameterizedTest @ValueSource(strings = {"chrome", "firefox"}) test. Confirm it runs twice and both entries appear in the report.
  6. Stretch — screenshot on failure. Add ScreenshotExtension to @ExtendWith. Deliberately fail one test. Confirm a .png appears in target/screenshots/.

Next lesson: Maven Surefire plugin configuration — running tagged subsets, passing system properties, and wiring up Failsafe for integration tests.

// tip to track lessons you complete and pick up where you left off across devices.