The Cucumber + Selenium boilerplate problem
Every Cucumber + Selenium project in Java starts the same way. Before you write a single step, you write the infrastructure:
public class Hooks {
private WebDriver driver;
@Before
public void setUp() {
WebDriverManager.chromedriver().setup();
ChromeOptions options = new ChromeOptions();
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
driver = new ChromeDriver(options);
DriverHolder.set(driver);
}
@After
public void tearDown(Scenario scenario) {
if (scenario.isFailed()) {
byte[] screenshot = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
scenario.attach(screenshot, "image/png", "Failure screenshot");
}
if (driver != null) driver.quit();
DriverHolder.remove();
}
}
Then you wire a ThreadLocal for parallel safety. Then you figure out how to get it into step definitions. Then you add a static helper class. Then someone wants screenshots embedded in the Selenium Boot HTML report too, so you add that. Then CI needs headless mode...
By the time the first feature file runs, you've written 200 lines of plumbing that has nothing to do with your tests.
What the step definitions should look like
This is what a step definition file should be:
public class LoginSteps extends BaseCucumberSteps {
@Given("the user is on the login page")
public void onLoginPage() {
open();
}
@When("they login as {string} with password {string}")
public void login(String username, String password) {
new LoginPage(getDriver()).login(username, password);
}
@Then("the dashboard should be visible")
public void dashboardVisible() {
assertThat(By.id("dashboard")).isVisible();
}
}
Pure Gherkin logic. No driver setup. No cleanup. No thread-locals. No screenshot code. The infrastructure should live somewhere else — ideally somewhere you never have to touch.
How to get there
The right place for the infrastructure is a framework-level @Before/@After hook that runs automatically for every project. Cucumber discovers hooks by scanning the glue packages — so if you put your hooks in a published library and include that package in glue, they run without any code in the consumer project.
Here's the structure:
selenium-boot (library)
└── com.seleniumboot.cucumber
├── CucumberHooks.java ← @Before + @After per scenario
├── BaseCucumberSteps.java ← getDriver(), open(), assertThat()
├── BaseCucumberTest.java ← runner base class
└── CucumberStepLogger.java ← pipes step names into HTML report
your-test-project
└── com.yourcompany.bdd
├── CucumberRunner.java ← @CucumberOptions + extends BaseCucumberTest
└── steps/
└── LoginSteps.java ← extends BaseCucumberSteps, pure steps only
The framework hook
CucumberHooks is the core of the integration. It runs before and after every scenario — the consumer project never sees it:
public class CucumberHooks {
@Before(order = 1000)
public void beforeScenario(Scenario scenario) {
FrameworkBootstrap.initialize(); // idempotent — safe to call per-scenario
String testId = buildTestId(scenario);
CucumberContext.setScenario(scenario);
SeleniumBootContext.setCurrentTestId(testId);
ExecutionMetrics.markStart(testId);
DriverManager.createDriver(); // reads selenium-boot.yml, applies config
HookRegistry.onTestStart(testId);
}
@After(order = 20000)
public void afterScenario(Scenario scenario) {
String testId = SeleniumBootContext.getCurrentTestId();
if (scenario.isFailed()) {
// Screenshot into Selenium Boot HTML report
String path = ScreenshotManager.capture(sanitize(scenario.getName()));
ExecutionMetrics.recordScreenshot(testId, path);
// Screenshot bytes attached to Cucumber's own HTML report
String base64 = ScreenshotManager.captureAsBase64();
if (base64 != null) {
scenario.attach(Base64.getDecoder().decode(base64), "image/png", "Failure");
}
HookRegistry.onTestFailure(testId, new RuntimeException(scenario.getName()));
}
ExecutionMetrics.recordStatus(testId, resolveStatus(scenario));
ExecutionMetrics.markEnd(testId);
DriverManager.quitDriver();
CucumberContext.clear();
SeleniumBootContext.clearCurrentTestId();
}
}
The @After(order = 20000) runs before any user-defined @After(order = 10000) — so the screenshot is captured while the page is still loaded, before user cleanup hooks navigate away.
The step definition base class
BaseCucumberSteps gives step definitions access to the driver and the framework API without static helpers or thread-local lookups:
public abstract class BaseCucumberSteps {
protected WebDriver getDriver() {
return DriverManager.getDriver(); // ThreadLocal — safe for parallel
}
protected void open() {
getDriver().get(SeleniumBootContext.getConfig().getExecution().getBaseUrl());
}
protected void open(String path) {
String base = SeleniumBootContext.getConfig().getExecution().getBaseUrl();
getDriver().get(base + path);
}
protected Locator $(String css) { return Locator.ofCss(css); }
protected Locator $(By by) { return Locator.of(by); }
protected LocatorAssert assertThat(By locator) {
return SeleniumAssert.assertThat(locator); // auto-retrying assertion
}
protected Scenario getScenario() {
return CucumberContext.getScenario();
}
}
The runner
The runner is the one piece that belongs in the consumer project — it's how you specify where your features and step packages are:
@CucumberOptions(
features = "src/test/resources/features",
glue = {"com.yourcompany.bdd.steps", "com.seleniumboot.cucumber"},
plugin = {"pretty", "com.seleniumboot.cucumber.CucumberStepLogger"}
)
public class CucumberRunner extends BaseCucumberTest {}
com.seleniumboot.cucumber in glue is the key — it makes Cucumber discover CucumberHooks automatically.
CucumberStepLogger is a ConcurrentEventListener that intercepts TestStepFinished events and pipes step names and their pass/fail status into the Selenium Boot HTML report step timeline:
public final class CucumberStepLogger implements ConcurrentEventListener {
private static final ThreadLocal<String> CURRENT_STEP = new ThreadLocal<>();
@Override
public void setEventPublisher(EventPublisher publisher) {
publisher.registerHandlerFor(TestStepStarted.class, this::onStepStarted);
publisher.registerHandlerFor(TestStepFinished.class, this::onStepFinished);
}
private void onStepStarted(TestStepStarted event) {
if (event.getTestStep() instanceof PickleStepTestStep step) {
CURRENT_STEP.set(step.getStepText());
}
}
private void onStepFinished(TestStepFinished event) {
String name = CURRENT_STEP.get();
CURRENT_STEP.remove();
if (name == null) return;
StepLogger.step(name, mapStatus(event.getResult().getStatus()),
event.getResult().getStatus() == Status.FAILED);
}
}
IDE single-scenario execution
When you right-click a scenario in IntelliJ and click Run, the IDE uses its own Cucumber runner — not your CucumberRunner class — so @CucumberOptions is ignored and CucumberHooks is never discovered.
The fix: a cucumber.properties file. Cucumber reads it regardless of which runner launches the scenario:
# src/test/resources/cucumber.properties
cucumber.glue=com.yourcompany.bdd.steps,com.seleniumboot.cucumber
cucumber.plugin=pretty,com.seleniumboot.cucumber.CucumberStepLogger
cucumber.monochrome=true
One file. No IDE configuration needed.
Parallel execution
Parallel safety comes for free. The driver is stored in a ThreadLocal via DriverManager, CucumberContext stores the Scenario in a ThreadLocal, and ExecutionMetrics uses a ConcurrentHashMap keyed by testId. Each scenario thread is fully isolated.
Enable parallel execution in selenium-boot.yml:
execution:
parallel: methods
threadCount: 4
maxActiveSessions: 4
What you get without writing it
By having com.seleniumboot.cucumber in your glue path, every scenario automatically gets:
- Chrome or Firefox based on
selenium-boot.yml— no WebDriverManager calls - Headless in CI — detected automatically
- Screenshot on failure embedded in both the Selenium Boot HTML report and Cucumber's HTML report
- Step names in the HTML report step timeline
- Metrics tracked — duration, pass/fail, retry count
- Parallel-safe ThreadLocal isolation
- Self-healing locators if enabled
- AI failure analysis if configured
The step definitions contain nothing but business logic.
Using it in Selenium Boot
All of this is built into Selenium Boot. Add the dependency and the two Cucumber JARs:
<dependency>
<groupId>io.github.seleniumboot</groupId>
<artifactId>selenium-boot</artifactId>
<version>1.10.0</version>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<version>7.20.1</version>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-testng</artifactId>
<version>7.20.1</version>
</dependency>
Full docs at seleniumboot.github.io/selenium-boot/docs/cucumber.
How does your team currently handle WebDriver setup in Cucumber step definitions? Drop it in the comments.