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
@ParameterizedTestcross-browser example above, a baredriver.quit()after the assertion will not run if the assertion fails. Always wrap intry/finally, or better, use theWebDriverExtensionwith@BeforeEach/@AfterEachso the extension always quits, regardless of test outcome. - Using
@BeforeAllstatic 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@BeforeEachor the extension. - Mixing JUnit 5 and TestNG annotations. Adding
testngto the classpath alongsidejunit-jupitermeans both@Testannotations are on the classpath. IntelliJ may suggest the wrong one. Check imports —org.junit.jupiter.api.Testfor JUnit 5,org.testng.annotations.Testfor TestNG. A method with the wrong@Testimport will silently not run.
🎯 Practice task
Build a JUnit 5 Selenium test class. 30–40 minutes.
- Set up a Maven project with
junit-jupiter,selenium-java, andwebdrivermanagerdependencies. - Implement the full
WebDriverExtensionfrom Chapter 4 (or copy it from your Chapter 4 project). Register it with@ExtendWith. - Write a
LoginPageTestwith at least three@Testmethods (valid login, wrong password, empty fields). Each receivesWebDriver driveras a parameter. - Create a
LoginPagepage object. MovefindElementcalls into the page object. The test methods should only call high-level methods likeloginPage.loginAs(email, password). - Cross-browser test. Write one
@ParameterizedTest @ValueSource(strings = {"chrome", "firefox"})test. Confirm it runs twice and both entries appear in the report. - Stretch — screenshot on failure. Add
ScreenshotExtensionto@ExtendWith. Deliberately fail one test. Confirm a.pngappears intarget/screenshots/.
Next lesson: Maven Surefire plugin configuration — running tagged subsets, passing system properties, and wiring up Failsafe for integration tests.