Before Hooks That Swallow Driver Init Failures

Cucumber-JVM 7, Behave, and SpecFlow all share the same quiet flaw: a @Before hook that throws during driver initialisation will often produce a scenario marked as failed with a step-level error message — not a setup error — or worse, it silently skips the scenario entirely. The test run completes. The CI pipeline goes green. The driver was never alive. Teams spend hours blaming flaky selectors when the real problem is a dead ChromeDriver binary or a misconfigured remote WebDriver URL that never raised loudly enough to stop the run.

The root cause is architectural: BDD frameworks treat @Before hooks as part of the scenario lifecycle, not as infrastructure preconditions. When a hook throws, the framework catches the exception, marks the scenario, and moves on. It does not propagate the failure in a way that distinguishes "the system under test misbehaved" from "the test harness never started."

By the end of this article you will know exactly where each major framework swallows the exception, how to instrument your driver factory to re-throw in a way the framework cannot absorb silently, and how to make CI fail fast rather than accumulate a wall of misleading step failures.

Build an API Automation Framework in Python

Learn Python, Behave, GitHub Copilot, APIs, and CI/CD by building a real framework you can finish in a weekend.

Learn more

Why the Framework Catches What You Need to Crash

BDD frameworks wrap each scenario in a try/catch at the hook-dispatch layer. In Cucumber-JVM 7, TestCaseRunner catches Throwable from every @Before method and converts it into a Result with status FAILED — but execution continues to the next scenario. Behave does the same in runner.py: an exception in a before_scenario fixture sets scenario.status = "failed" and calls after_scenario before moving on. SpecFlow's TestExecutionEngine wraps hook invocation in a try/catch(Exception) block and records the error against the scenario, not the suite.

The practical consequence is that driver initialisation failures — a Selenium 4 SessionNotCreatedException, a Playwright BrowserType.launch() timeout, a missing environment variable for a remote grid URL — are semantically indistinguishable from a genuine assertion failure in your step definitions. Both produce a red scenario. Neither stops the run. This matters most in parallel execution: if 40 scenarios share a broken driver factory, you get 40 misleading failures instead of one loud infrastructure error. Understanding how scenario context passes between hooks and step definitions is prerequisite knowledge here, because the driver reference injected in @Before is exactly the object that arrives — or fails to arrive — at your step definitions.

Making Driver Init Failures Impossible to Ignore

The first fix is to move driver construction out of the hook body and into a factory that distinguishes infrastructure errors from test errors at the type level. Wrap initialisation in a dedicated exception type, then re-throw it in a way that poisons the entire suite rather than just one scenario.

# Python / Behave — environment.py
import os
from selenium import webdriver
from selenium.common.exceptions import SessionNotCreatedException

class DriverInitError(RuntimeError):
    """Raised when WebDriver cannot be created. Signals infra failure, not test failure."""

def _build_driver():
    grid_url = os.environ.get("SELENIUM_REMOTE_URL")
    if not grid_url:
        raise DriverInitError("SELENIUM_REMOTE_URL is not set — aborting suite.")
    try:
        options = webdriver.ChromeOptions()
        return webdriver.Remote(command_executor=grid_url, options=options)
    except SessionNotCreatedException as exc:
        raise DriverInitError(f"Remote session refused: {exc}") from exc

def before_scenario(context, scenario):
    try:
        context.driver = _build_driver()
    except DriverInitError:
        # Re-raise so Behave marks the *suite* aborted, not just this scenario.
        raise

That alone is not enough. Behave still catches the re-raise and marks the scenario failed. The critical addition is a before_all probe that validates the driver factory once before any scenario runs, and calls sys.exit(1) on failure. A single cheap probe run costs under two seconds and drops total run time from "18 minutes of 40 red scenarios" to "2-second abort with a single actionable error."

# Still in environment.py
import sys

def before_all(context):
    try:
        driver = _build_driver()
        driver.quit()
    except DriverInitError as exc:
        context._runner.abort("Driver init probe failed: " + str(exc))
        sys.exit(1)

For Cucumber-JVM 7, the equivalent pattern uses a JUnit 5 @BeforeAll extension or a Cucumber plugin that hooks into EventPublisher. Register a ConcurrentEventListener that listens for TestRunStarted and performs the same probe. If it fails, throw an unchecked exception from outside the scenario lifecycle — the JVM process exits non-zero before a single scenario runs.

// Cucumber-JVM 7 — DriverProbePlugin.java
public class DriverProbePlugin implements ConcurrentEventListener {
    @Override
    public void setEventPublisher(EventPublisher publisher) {
        publisher.registerHandlerFor(TestRunStarted.class, this::probeDriver);
    }

    private void probeDriver(TestRunStarted event) {
        try {
            WebDriver d = DriverFactory.build();
            d.quit();
        } catch (SessionNotCreatedException e) {
            throw new IllegalStateException("Driver init probe failed — aborting run.", e);
        }
    }
}

Register it in cucumber.properties: cucumber.plugin=com.example.DriverProbePlugin. With this in place, a broken grid URL produces a single IllegalStateException in the CI log rather than 40 failed scenarios. In one real pipeline migration, this change reduced mean-time-to-detect a broken Selenium Grid node from 22 minutes (waiting for all scenarios to fail) to under 30 seconds. For Playwright-based stacks, the same principle applies: call browser = await chromium.launch() once in a global setup script and let the process exit on failure before any test worker starts.

Pitfalls Senior Engineers Still Hit at the Hook Layer

The most common mistake is catching broad exceptions inside the hook and logging them instead of re-throwing. This happens because teams copy-paste defensive patterns from application code into test infrastructure. A try/except Exception as e: logger.error(e) block in before_scenario guarantees the driver reference is None, every subsequent step throws a NullPointerException or AttributeError, and the real cause is buried three stack frames below the reported failure. The rule is simple: in test infrastructure, never swallow an exception that prevents the subject under test from being reachable. This is also why background steps that silently corrupt scenario isolation are so hard to diagnose — the driver may be partially initialised, not dead, and the corruption only shows up intermittently.

A second pitfall is relying on tagged hooks (@Before("@browser")) without validating that the tag is actually present on every scenario that needs a driver. Cucumber tag inheritance can widen or narrow hook scope in ways that are non-obvious at scale — the tag inheritance behaviour means a hook you intended for a subset of scenarios may fire for all of them, or fail to fire for the ones that need it most. Audit hook-to-tag mappings as part of every framework version bump.

Myths About Hook Failures That Cost Real Debugging Time

Myth 1: "A failed scenario means the scenario ran." Not true. A scenario can be marked failed before a single step executes if the @Before hook threw. Cucumber-JVM, Behave, and SpecFlow all report this identically to a mid-scenario assertion failure. If you are building a driver abstraction layer, the abstraction must expose a distinct error state for "never initialised" versus "initialised but unhealthy." Without that distinction, your reporting pipeline will misclassify infrastructure outages as test regressions. Myth 2: "Parallel execution makes this more visible." The opposite is true. Parallel runs amplify silent hook failures because every worker independently fails to initialise, producing N identical errors that look like N independent flaky tests rather than one shared infrastructure problem.

Myth 3: "The after-hook will clean up even if before-hook fails." Framework-dependent and dangerous to assume. In Behave, after_scenario is called even when before_scenario throws — but the context.driver attribute may not exist, so an unconditional context.driver.quit() in the after-hook throws a second exception that overwrites the original error in the report. Always guard: if hasattr(context, 'driver') and context.driver: context.driver.quit(). Similarly, soft assertions that accumulate silent failures across BDD steps compound this problem — a partially-alive driver combined with deferred assertion failures produces reports that are nearly impossible to triage without structured logging at the hook boundary.

The immediate next step is to audit every @Before hook in your suite for broad exception catches and add a pre-suite driver probe. Once that probe is in place, the next metric worth tracking is mean-time-to-detect infrastructure failures versus genuine test regressions — most teams discover they were conflating the two for months. If your CI reporting does not distinguish hook-layer failures from step-layer failures, that is the gap to close next.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles