ScreenshotNeo

BlogGuides

Selenium BDD Testing with Python Behave: A Tutorial

Build behavior-focused browser tests with Python Behave and Selenium WebDriver, from setup and feature files to waits, cleanup, and troubleshooting.

By the ScreenshotNeo team4 October 202613 min read

Behave organizes readable behavior scenarios and dispatches their steps to matching Python functions. Selenium WebDriver performs browser interactions from those functions or from page objects they call. Together, they let you express a user-relevant outcome in a feature file, automate representative browser behavior, and check the result.

BDD, or behavior-driven development, is a collaborative software development practice; it is not another name for browser automation. Use Behave and Selenium when a behavior needs browser-level verification, and keep the scenario wording focused on what the application should do.

This tutorial follows the Behave stable tutorial labeled 1.3.3 and the Selenium Python API labeled 4.50.0, which lists Python 3.10+ support. Behave’s latest documentation page is labeled 1.4.0.dev0; that development documentation and the stable tutorial are different documentation tracks. Check the linked documentation for current release details before pinning project dependencies. Behave stable tutorial, Behave latest documentation, and Selenium Python API.

1. Install Behave and Selenium

Create an isolated Python environment, install both packages, and record the exact resolved versions in your project dependency file when you need reproducible environments. The documentation does not establish a specific compatible version pair, so this tutorial does not prescribe one.

python -m venv .venv
# Linux or macOS:
source .venv/bin/activate
# Windows PowerShell:
# .venv\\Scripts\\Activate.ps1
python -m pip install --upgrade pip
python -m pip install behave selenium
python -m pip freeze > requirements.txt

Selenium’s current Python API lists Python 3.10 and later. Modern Selenium generally uses Selenium Manager to manage a browser driver when you instantiate WebDriver. You still need the browser installed. Selenium Manager reduces manual driver setup, but browser availability, permissions, network restrictions, and CI configuration can still affect startup. Selenium documents Chrome, Edge, Firefox, Safari, WebKitGTK, and WPEWebKit among its browser and protocol targets.

For a team project, keep a reviewed lock file or pinned requirements file under version control rather than regenerating it without review. Upgrade Behave, Selenium, and browser versions deliberately, then run the relevant suite against the environments you support.

2. Create the Behave project structure

Behave looks for a features/ directory. Put Gherkin feature files there and Python step implementations in features/steps/. As the example grows, add an environment hook for browser lifecycle and a page module to keep selectors and browser operations out of scenario text.

project/
  requirements.txt
  features/
    login.feature
    environment.py
    steps/
      login_steps.py
    pages/
      login_page.py

Run Behave from the project root with behave. It discovers feature files and step modules in the conventional directories. Decorators such as @given, @when, and @then connect feature step text to Python functions.

3. Write a behavior-focused feature

This scenario describes the outcome a user cares about, without putting selectors or click sequences into the Gherkin:

Feature: Account sign in

  Scenario: A registered user reaches their account
    Given a registered user is ready to sign in
    When they submit valid credentials
    Then their account page is displayed

A useful scenario makes its starting state, action, and observable outcome clear. The Python code must arrange known test data; “registered user” is not a setup mechanism by itself. For this tutorial, the example expects a test application at BASE_URL, with a login form containing #email, #password, and a submit button, and a successful sign-in that navigates to /account. Change those values and selectors to match your test application. Use a dedicated test account and non-production environment.

4. Start and close the browser reliably

Use Behave’s environment hooks to create a driver and put it on context, which Behave makes available to step functions. The example creates one browser for the run and always calls quit() after it. A shared browser is faster to start across many scenarios but can carry cookies and other state between them. If scenarios need strong isolation, create and close a browser per scenario instead.

# features/environment.py
import os

from selenium import webdriver


def before_all(context):
    browser_name = os.getenv("BROWSER", "chrome").lower()

    if browser_name == "chrome":
        options = webdriver.ChromeOptions()
        if os.getenv("HEADLESS", "0") == "1":
            options.add_argument("--headless=new")
        context.driver = webdriver.Chrome(options=options)
    elif browser_name == "firefox":
        options = webdriver.FirefoxOptions()
        if os.getenv("HEADLESS", "0") == "1":
            options.add_argument("--headless")
        context.driver = webdriver.Firefox(options=options)
    else:
        raise ValueError("BROWSER must be chrome or firefox in this example")

    context.base_url = os.getenv("BASE_URL", "http://localhost:8000").rstrip("/")


def after_all(context):
    driver = getattr(context, "driver", None)
    if driver is not None:
        driver.quit()

For scenario-level isolation, use before_scenario and after_scenario to create and quit the driver for each scenario, and put driver construction in a helper so both hooks use the same browser choice. Keep teardown guarded as above so a failed setup does not cause a second error during cleanup. Behave’s Selenium examples describe browser fixtures and cleanup hooks; their page-object example also quits the driver during teardown. Behave Page Objects guide.

5. Put browser mechanics in a page object

A page object groups the login page’s locators and interactions. Explicit waits synchronize against observable browser conditions. Avoid using fixed sleeps as your default wait strategy; sleeps add time even when a page is ready and still do not prove that the needed condition occurred.

# features/pages/login_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait


class LoginPage:
    EMAIL = (By.ID, "email")
    PASSWORD = (By.ID, "password")
    SUBMIT = (By.CSS_SELECTOR, "button[type='submit']")

    def __init__(self, driver, base_url, timeout=10):
        self.driver = driver
        self.base_url = base_url
        self.wait = WebDriverWait(driver, timeout)

    def open(self):
        self.driver.get(f"{self.base_url}/login")
        self.wait.until(EC.visibility_of_element_located(self.EMAIL))

    def sign_in(self, email, password):
        email_input = self.wait.until(EC.visibility_of_element_located(self.EMAIL))
        password_input = self.wait.until(EC.visibility_of_element_located(self.PASSWORD))
        email_input.clear()
        email_input.send_keys(email)
        password_input.clear()
        password_input.send_keys(password)
        self.wait.until(EC.element_to_be_clickable(self.SUBMIT)).click()

    def account_is_displayed(self):
        return self.wait.until(EC.url_contains("/account"))

The wait condition for the successful result is the URL containing /account. If your application renders the account page without changing the URL, wait for a stable, user-visible element that uniquely indicates success instead. Page-object methods should expose actions and return values or observable results; keep scenario-specific assertions in the step layer.

6. Connect the feature steps to Selenium

The example reads credentials from environment variables, so secrets do not have to live in the feature file or source code. Supply test-only credentials through your local shell or CI secret configuration.

# features/steps/login_steps.py
import os

from behave import given, when, then

from features.pages.login_page import LoginPage


@given("a registered user is ready to sign in")
def registered_user_ready(context):
    context.email = os.environ["TEST_USER_EMAIL"]
    context.password = os.environ["TEST_USER_PASSWORD"]
    context.login_page = LoginPage(context.driver, context.base_url)
    context.login_page.open()


@when("they submit valid credentials")
def submit_valid_credentials(context):
    context.login_page.sign_in(context.email, context.password)


@then("their account page is displayed")
def account_page_is_displayed(context):
    assert context.login_page.account_is_displayed(), (
        f"Expected account page, got {context.driver.current_url}"
    )

Depending on your Python package layout, imports may need adjustment. You can instead make features a Python package by adding __init__.py files and running Behave from the project root, or use a project-level page package. Keep the feature steps discoverable by Behave under features/steps/.

Set configuration and run the scenario:

export BASE_URL=http://localhost:8000
export TEST_USER_EMAIL=tester@example.invalid
export TEST_USER_PASSWORD='replace-with-test-secret'
export BROWSER=chrome
export HEADLESS=1
behave

Use the equivalent environment variable syntax for your shell or CI system. The reserved .invalid domain above is illustrative; replace it with the test account provisioned by your application.

7. Make scenarios reusable without hiding intent

Behave supports step parameters, tables, text blocks, and Scenario Outlines with example rows. Use these to express meaningful variations of one behavior, not to turn a scenario into a generic UI scripting language.

Feature: Account sign in

  Scenario Outline: Invalid credentials do not open an account
    Given a sign-in page is open
    When the user signs in with email "<email>" and password "<password>"
    Then a sign-in error is displayed

    Examples:
      | email                 | password          |
      | unknown@example.invalid | wrong-password  |
      |                         | password          |

For credential handling, avoid placing real secrets in examples because feature files are often committed and appear in test reports. Use controlled test data and a safe way to pass sensitive values. For larger sets of records, a table can make inputs easier to maintain, while a doc string can carry multiline text such as a message body.

8. Choose the right testing layer

A Selenium scenario is appropriate when the browser experience itself matters: for example, a representative sign-in flow, a critical navigation path, or a browser-visible validation result. Keep the feature text technology-agnostic so it describes behavior rather than Selenium commands, selectors, or page layout.

Do not make every behavior scenario a browser test by default. Behave’s practical guidance notes that testing a model or business-logic layer, such as through a REST API, can be preferable to driving the front end. Choose the layer that verifies the behavior with the necessary scope and isolation. Browser tests cover the real UI path but involve browser startup and UI state; lower-layer tests avoid that browser interaction but do not verify the rendered browser experience. The documentation does not publish comparative benchmarks, so measure your own suite rather than assuming a speed ratio. Behave practical tips.

9. Browser waits, state, and configuration

Use one wait strategy consistently

The example uses WebDriverWait with expected conditions such as visibility and clickability. Do not combine Selenium’s implicit wait with explicit waits in the same test flow: the waits can stack and produce unpredictable total timeouts. Set a timeout based on the application and environment, and wait for the condition that proves the next action or assertion is ready.

Isolate scenario data

  • Use a test environment and test accounts, not live user accounts.
  • Ensure each scenario starts from known state. Reset test records or create uniquely identified data when scenarios can interfere.
  • Decide whether to share a browser for runtime or create one per scenario for isolation; do not accidentally rely on cookies left by a previous scenario.
  • Keep credentials in environment variables or your CI secret store. Avoid logging them in failure output.
  • Use stable selectors owned by the application where possible. Keep locator changes in page objects rather than duplicating selectors across steps.

Use browser-specific setup deliberately

The hook example supports Chrome and Firefox and optionally runs headless. It does not claim that every browser-specific flag works across versions or operating systems. Use browser options supported by the browser and Selenium version in your environment. If you need a different browser, add a branch that constructs its documented WebDriver and options.

10. Troubleshooting common failures

Symptom Likely cause Fix
behave reports no features or no steps You ran it outside the project root, the directory is not named features, or step files are outside features/steps/. Run Behave from the project root and check the conventional directory layout. Confirm the step text matches the decorators.
A step is undefined Its text does not match a registered decorator, or Behave did not load the module. Compare the Gherkin step and decorator text, check Python syntax and imports, and keep the implementation in the steps directory.
WebDriver fails to start The browser is missing, Selenium Manager cannot resolve or obtain a driver in the current environment, or browser startup is blocked by permissions or system configuration. Install the browser, check environment/network access and permissions, and consult Selenium’s driver setup documentation. If needed, configure a driver explicitly for that environment.
NoSuchElementException The selector is wrong, the page is different than expected, or the element has not appeared yet. Verify the test URL and rendered DOM, prefer a stable locator, and wait for the relevant condition before interacting.
ElementNotInteractableException or click interception The element is hidden, disabled, covered, or not yet ready. Wait for visibility or clickability, inspect overlays and page state, and target the user-facing control rather than forcing a click through JavaScript.
A wait times out intermittently The condition is too broad or too early, the application is slow in that environment, or an implicit wait is also active. Wait for a specific observable result, remove implicit waits when using explicit waits, and adjust the timeout only after identifying the actual readiness condition.
Later scenarios fail while earlier ones pass Shared browser cookies, application state, or test data leaked between scenarios. Reset state or create a new browser per scenario. Make setup and cleanup explicit.
Browser remains running after failure Teardown did not execute or cleanup code did not guard partial setup. Use Behave teardown hooks and check that quit() runs for any driver that was created, including when setup or a scenario fails.
Tests work locally but not in CI The browser is absent, headless settings differ, credentials are unavailable, or the CI environment has different network or permissions. Install/provision the browser in CI, pass test configuration securely, and reproduce the CI browser mode locally where possible.

11. Performance, reliability, and cost

Browser startup and UI synchronization are part of the work performed by a browser test. Keep the browser suite focused on representative end-to-end paths, avoid unnecessary page reloads and fixed sleeps, and use lower-level tests for behaviors that do not require the browser. A shared browser can reduce repeated startup but increases the need to control state; per-scenario browsers offer isolation at the cost of additional setup. The right choice depends on your suite and environment.

For reliability, wait on specific conditions, make test data repeatable, keep selectors centralized, and always quit the driver. Do not label a test flaky based only on a timeout: inspect whether the wait condition, test state, application behavior, or environment is responsible.

The local workflow requires Python, Behave, Selenium, and a browser. Selenium Manager can handle much of driver setup, but it does not remove the browser or environment requirements. The cited documentation supplies no cost or performance benchmarks for a particular setup; account for your own CI machine and browser execution costs.

12. Capture browser evidence with ScreenshotNeo

When a failed scenario needs a page image for review, a screenshot API can capture a URL without adding screenshot plumbing to your test code. ScreenshotNeo is a website screenshot API and MCP server for developers from Yorker Media. It accepts a URL and returns a PNG, JPEG, WebP, or PDF. Its API supports full-page capture, element capture, device and viewport settings, custom CSS and JavaScript, waits, headers and cookies, and other capture controls; see the ScreenshotNeo API documentation.

For the actual BDD workflow, Selenium remains the tool that interacts with your authenticated browser session. A URL-based screenshot call is useful when you need a capture of a page reachable by the API; it is not a replacement for assertions on the live browser session or for the test steps above.

Or skip the browser setup

For a standalone capture of a public page, send one GET request. Replace the target URL with the page you want to capture and provide your API key:

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', image));

ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each of those steps can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers identify the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots. Every feature is available on every plan. Create a free ScreenshotNeo account.

13. Frequently asked questions

Can Behave test an API without Selenium?

Yes. Behave matches scenarios to Python step implementations; those steps can interact with an API or model layer instead of a browser. Choose Selenium when the browser experience itself is part of what you need to verify.

Do I need to write Gherkin for every automated test?

No. Use feature scenarios when their readable behavior description helps communicate and maintain the behavior under test. Keep other tests in the form that best fits their purpose.

Can I run the same Behave suite in more than one browser?

Yes, if your environment hooks construct the requested browser and the application supports it. This example includes Chrome and Firefox branches; browser availability and setup still need to be handled in each environment.

Should page objects contain assertions?

Prefer page objects that perform page operations and report observable results. Keep scenario-specific expectations in the step layer so the feature’s behavior remains clear.

References