Selenium × pytest Practical Guide | fixture · parametrize · conftest.py · mark

test-automation

This guide covers the four core features of Selenium × pytest——fixture, parametrize, conftest.py, and mark——with production-ready sample code.

Once you master pytest fixtures, parametrize, and marks, you can evolve your Selenium tests from one-off scripts into a maintainable test suite.

Every pattern in this article is taken directly from production projects and ready to copy-paste.

What this article covers

  • pytest fixture: centralize WebDriver setup and teardown
  • pytest parametrize: run one test against multiple data sets (data-driven testing)
  • conftest.py: share fixtures across all test files automatically
  • pytest mark: tag tests as smoke/regression and run only what you need

👉 Prerequisites: This article assumes Selenium × Python is already installed. If not, start with the setup guide first.

💡 What makes this article different

Most tutorials stop at “tests are passing.” This article goes further — covering production design patterns like fixture separation, data-driven testing with parametrize, and CI/CD-ready test classification with marks.

There’s a gap between “tests that run” and “tests a team can maintain and run in CI.” Closing that gap is exactly what pytest fixtures, parametrize, and marks are for.

📌 Who this is for / What you’ll learn

  • QA engineers who have Selenium set up and are ready to write structured tests
  • Anyone who wants a systematic understanding of fixture, parametrize, conftest.py, and mark
  • Engineers who want tests that work in CI/CD pipelines
  • How to use pytest fixture, parametrize, and mark in practice
  • How to centralize WebDriver setup with conftest.py
  • How to implement login form tests for both happy and error paths

👤 About the author

Written by Yoshi, a QA and test automation engineer with 15+ years of production experience. The fixture/parametrize/mark patterns in this article are the team standards used daily on real client projects. Code is publicly available on GitHub: github.com/YOSHITSUGU728/automated-testing-portfolio

Selenium × pytest Project Structure

Folder Layout

Start by understanding the folder structure and the role of each file.

selenium-project/
├── venv/
├── tests/
│   ├── conftest.py          # WebDriver fixture & shared config
│   ├── test_login.py        # Login tests
│   ├── test_search.py       # Search tests
│   └── test_register.py     # Registration tests
├── pages/
│   └── login_page.py        # Page Object (covered in a separate article)
├── requirements.txt
└── pytest.ini

Role of Each File

📋 File Roles

conftest.pyDefines WebDriver launch/quit fixtures — auto-loaded by all test files
test_*.pyTests split by feature — ideally 1 file per use case
pytest.iniTest path, options, and custom mark registration
pages/Page Object pattern — centralizes element selectors in classes

STEP 1: What Is a pytest fixture? Centralizing Selenium WebDriver with conftest.py

Defining the WebDriver fixture in conftest.py makes it available to every test file automatically.

# tests/conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager

@pytest.fixture(scope="function")
def driver():
    """Fixture that launches and quits Chrome for each test"""
    options = webdriver.ChromeOptions()
    # options.add_argument("--headless")  # Turn ON for CI/CD
    options.add_argument("--no-sandbox")            # For Linux/CI environments
    options.add_argument("--disable-dev-shm-usage") # For Linux/CI environments
    options.add_argument("--window-size=1920,1080")

    service = Service(ChromeDriverManager().install())
    driver = webdriver.Chrome(service=service, options=options)
    driver.implicitly_wait(10)  # Basic wait — use WebDriverWait as the primary approach

    yield driver

    try:
        driver.quit()
    except Exception:
        # Protects teardown even if session was already disconnected
        pass


@pytest.fixture(scope="function")
def logged_in_driver(driver):
    """Fixture for tests that require a logged-in session.
    For tests that don't need login, use the driver fixture directly."""
    # ※ example-app.com is a sample URL — replace with your actual URL
    driver.get("https://example-app.com/login")
    driver.find_element(By.ID, "username").send_keys("test_user")
    driver.find_element(By.ID, "password").send_keys("test_pass")
    driver.find_element(By.ID, "login-btn").click()

    # Wait for login to complete (without this, CI runs become flaky)
    WebDriverWait(driver, 10).until(
        EC.url_contains("/dashboard")
    )

    yield driver
    # driver.quit() is handled by the driver fixture — no teardown needed here

💡 logged_in_driver usage example

# Use logged_in_driver only when login is required
def test_dashboard_title(logged_in_driver):
    assert "Dashboard" in logged_in_driver.title

# Use driver directly for tests that don't need login
def test_top_page_title(driver):
    driver.get("https://example-app.com")
    assert "Home" in driver.title

💡 fixture key points

  • scope="function" (default): browser starts and stops per test — safe, but slower
  • scope="session": one browser for the whole suite — faster, but watch for state leaks between tests
  • implicitly_wait(10): global wait for all element lookups. Using it alongside WebDriverWait isn’t forbidden, but combined waits can be hard to predict — keep implicitly_wait minimal and use WebDriverWait as your primary tool

⚠️ CI environment notes

  • Combining scope="session" with pytest-xdist (parallel runs) requires additional configuration for session sharing
  • ChromeDriverManager().install() checks for updates on every CI startup — this can slow things down. Large CI setups often use Docker with a pinned ChromeDriver version instead
📖 Want a deeper dive into WebDriverWait?Selenium × Python Setup Guide

STEP 2: Writing Selenium Tests — Happy Path and Error Cases

Accepting base_url as a fixture argument keeps URLs in one place — no hardcoding, easy to swap per environment.

# tests/test_login.py
# ※ The same imports are used throughout the code below
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

# ── Happy path ──────────────────────────────────────
def test_login_success(driver, base_url):
    """Happy path: correct credentials log the user in"""
    driver.get(f"{base_url}/login")
    driver.find_element(By.ID, "username").send_keys("valid_user")
    driver.find_element(By.ID, "password").send_keys("valid_pass")
    driver.find_element(By.ID, "login-btn").click()

    WebDriverWait(driver, 10).until(EC.url_contains("/dashboard"))
    assert "/dashboard" in driver.current_url

# ── Error cases ─────────────────────────────────────
def test_login_failure_wrong_password(driver, base_url):
    """Error case: wrong password shows an error message"""
    driver.get(f"{base_url}/login")
    driver.find_element(By.ID, "username").send_keys("valid_user")
    driver.find_element(By.ID, "password").send_keys("wrong_pass")
    driver.find_element(By.ID, "login-btn").click()

    error_msg = WebDriverWait(driver, 10).until(
        EC.visibility_of_element_located(
            (By.CSS_SELECTOR, "[data-testid='login-error']")  # data-testid preferred
        )
    )
    assert "Invalid password" in error_msg.text

def test_login_failure_empty_fields(driver, base_url):
    """Error case: submitting empty fields shows a validation error"""
    driver.get(f"{base_url}/login")
    driver.find_element(By.ID, "login-btn").click()

    error = WebDriverWait(driver, 10).until(
        EC.visibility_of_element_located((By.CLASS_NAME, "validation-error"))
    )
    assert error.is_displayed()

STEP 3: pytest parametrize — Data-Driven Testing

@pytest.mark.parametrize runs the same test logic against multiple inputs — the go-to tool when you have many error case patterns to cover.

# tests/test_login.py (continued)
import pytest

INVALID_LOGIN_CASES = [
    ("", "valid_pass", "Please enter your username"),
    ("valid_user", "", "Please enter your password"),
    ("wrong_user", "valid_pass", "Authentication failed"),
    ("valid_user", "wrong_pass", "Invalid password"),
    ("a" * 256, "valid_pass", "Input is too long"),
]

@pytest.mark.parametrize("username, password, expected_error", INVALID_LOGIN_CASES)
def test_login_validation(driver, base_url, username, password, expected_error):
    """parametrize: runs all error cases with a single test function"""
    driver.get(f"{base_url}/login")
    driver.find_element(By.ID, "username").send_keys(username)
    driver.find_element(By.ID, "password").send_keys(password)
    driver.find_element(By.ID, "login-btn").click()

    error_msg = WebDriverWait(driver, 10).until(
        EC.visibility_of_element_located((By.CLASS_NAME, "error-message"))
    )
    assert expected_error in error_msg.text

Sample output

pytest tests/test_login.py -v

collected 8 items

test_login.py::test_login_success                                           PASSED
test_login.py::test_login_failure_wrong_password                            PASSED
test_login.py::test_login_failure_empty_fields                              PASSED
test_login.py::test_login_validation[-valid_pass-Please enter your username] PASSED
test_login.py::test_login_validation[valid_user--Please enter your password] PASSED
test_login.py::test_login_validation[wrong_user-valid_pass-Auth failed]     PASSED
test_login.py::test_login_validation[valid_user-wrong_pass-Invalid password] PASSED
test_login.py::test_login_validation[aaaa…-valid_pass-Input is too long]    PASSED

========================== 8 passed in 18.34s ==========================
💡 Pro tip: Extracting test data into a named list separates data from logic. When specs change, only the list updates — the test function stays untouched.
📖 Go deeper on pytest fixture and parametrizePython pytest Complete Guide

STEP 4: pytest mark — Classifying and Selecting Tests

@pytest.mark tags tests so you can run only a specific group — essential for CI/CD pipelines.

Register custom marks in pytest.ini

[pytest]
testpaths = tests
addopts = -v
markers =
    smoke: smoke tests (highest priority — run every time)
    regression: regression tests (run before releases)
    slow: tests that take a long time

Tag and run by mark

@pytest.mark.smoke
def test_login_success(driver, base_url): ...

@pytest.mark.regression
def test_login_failure_wrong_password(driver, base_url): ...

@pytest.mark.slow
@pytest.mark.regression
def test_login_session_timeout(driver, base_url): ...

# Run only smoke (post-deploy)
pytest -m smoke

# Skip slow tests
pytest -m "not slow"

# Only tests tagged with BOTH smoke and regression
pytest -m "smoke and regression"
📖 Using marks in GitHub Actions CI/CDAutomate E2E Tests with GitHub Actions × Playwright

STEP 5: Advanced fixture Patterns (Setup and Teardown)

# tests/conftest.py (additions)
import pytest

@pytest.fixture(scope="function")
def go_to_login(driver):
    driver.get("https://example-app.com/login")
    yield driver
    import os, re, time
    os.makedirs("screenshots", exist_ok=True)
    # ※ In production use pytest_runtest_makereport to save on failure only
    driver.save_screenshot(f"screenshots/{int(time.time())}.png")


@pytest.fixture(scope="session")
def base_url():
    """Base URL as a fixture — replaces BASE_URL constants entirely.
    ※ Sample URL — replace with your actual URL."""
    return "https://example-app.com"

💡 Save screenshots on failure only (CI-recommended)

pytest hooks — extensions that inject custom logic at specific points in pytest’s execution — let you save screenshots only when tests fail via pytest_runtest_makereport.

import os, re, pytest

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    report = outcome.get_result()
    if report.when == "call" and report.failed:
        driver = item.funcargs.get("driver")
        if driver:
            os.makedirs("screenshots", exist_ok=True)
            # Strip Windows-forbidden characters (\ / * ? : " < > |)
            safe_name = re.sub(r'[\\/*?:"<>|]', "_", item.name)
            driver.save_screenshot(f"screenshots/{safe_name}.png")
📖 Page Object Model — the next step for organizing test codePage Object Model with Playwright × pytest

STEP 6: Complete Example — Registration Form Tests

# tests/test_register.py
import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

# ※ In real projects, reusing the same email causes duplicate errors
# Use f"test_{int(time.time())}@example.com" for unique emails per run
VALID_USER = {
    "name": "Test User",
    "email": "test@example.com",
    "password": "SecurePass123!",
}

VALIDATION_CASES = [
    ("name", "", "Please enter your name"),
    ("email", "not-an-email", "Please enter a valid email address"),
    ("email", "", "Please enter your email address"),
    ("password", "short", "Password must be at least 8 characters"),
]

@pytest.fixture
def register_page(driver, base_url):
    """Navigate to registration page (URL unified via base_url fixture)"""
    driver.get(f"{base_url}/register")
    yield driver


@pytest.mark.smoke
def test_register_success(register_page):
    d = register_page
    d.find_element(By.ID, "name").send_keys(VALID_USER["name"])
    d.find_element(By.ID, "email").send_keys(VALID_USER["email"])
    d.find_element(By.ID, "password").send_keys(VALID_USER["password"])
    d.find_element(By.ID, "submit-btn").click()

    success = WebDriverWait(d, 10).until(
        EC.visibility_of_element_located((By.CLASS_NAME, "success-message"))
    )
    assert "Registration complete" in success.text


@pytest.mark.regression
@pytest.mark.parametrize("field, value, expected_error", VALIDATION_CASES)
def test_register_validation(register_page, field, value, expected_error):
    d = register_page
    d.find_element(By.ID, "name").send_keys(VALID_USER["name"])
    d.find_element(By.ID, "email").send_keys(VALID_USER["email"])
    d.find_element(By.ID, "password").send_keys(VALID_USER["password"])

    target = d.find_element(By.ID, field)
    target.clear()
    target.send_keys(value)
    d.find_element(By.ID, "submit-btn").click()

    error = WebDriverWait(d, 10).until(
        # By.ID targets the specific field's error — more precise than By.CLASS_NAME
        EC.visibility_of_element_located((By.ID, f"{field}-error"))
    )
    assert expected_error in error.text

📋 Summary so far

  • conftest.py: WebDriver init/teardown in a fixture → shared across all test files
  • parametrize: test data in a list → efficiently covers all error cases
  • mark: smoke / regression → run only what CI needs at each stage
  • fixture chaining: driver → go_to_login → test — flow made explicit in code

⚠️ 7 Common Pitfalls in Selenium × pytest

① Forgetting the test_ prefix

pytest won’t collect functions that don’t start with test_. If you see collected 0 items, check this first.

② conftest.py in the wrong directory

conftest.py must be in the same directory as the test files, or a parent directory.

tests/
├── conftest.py   ← correct (directly inside tests/)
└── test_login.py

③ State leaking with scope=”session”

With session scope, cookies and login state carry over between tests — causing order-dependent bugs. Start with scope="function".

④ Custom marks not registered in pytest.ini

Using @pytest.mark.smoke without registering it causes PytestUnknownMarkWarning.

[pytest]
markers =
    smoke: smoke tests
    regression: regression tests

⑤ print() output hidden without -s

Output is captured by default. Run pytest -s while debugging, or add addopts = -v -s to pytest.ini.

⑥ Exception before assert gives ERROR not FAILED

If an exception (like element not found) happens before the assert, the result is ERROR. Always use WebDriverWait to confirm the element exists first.

⑦ Running pytest outside the project root causes ModuleNotFoundError

# NG
cd tests && pytest

# OK — always run from the project root
cd selenium-project
pytest

FAQ

Q. Which fixture scope should I use?

Start with scope="function" (the default). Each test gets a fresh browser — no state leaks. If speed becomes an issue, consider scope="session", but be aware it carries cookies and login state across tests, and requires extra setup with pytest-xdist.

Q. How do I choose between implicitly_wait and WebDriverWait?

implicitly_wait applies globally to every element lookup. WebDriverWait waits for a specific condition. Using both isn’t forbidden, but it can produce unpredictable total wait times. Keep implicitly_wait minimal and use WebDriverWait as your primary approach.

Q. Can I load parametrize data from a file?

Yes. Read from CSV or JSON with json.load() or csv.reader(), build a list, and pass it to @pytest.mark.parametrize. The pytest-datafiles plugin is another option.

Q. What does pytest -m "smoke and regression" actually run?

Only tests tagged with both marks. To run tests tagged with either one, use pytest -m "smoke or regression". A common source of confusion for beginners.

Q. Can locating elements by class name be unreliable?

Yes — when multiple elements share the same class name. This article uses By.ID, f"{field}-error" for field-specific targeting. For maximum stability, data-testid attributes are the production recommendation: By.CSS_SELECTOR, "[data-testid='name-error']".

Q. Will reusing the same email cause duplicate registration errors?

Yes. The fixed email in VALID_USER is for readability, but real projects will error on the second run. Use f"test_{int(time.time())}@example.com" for a unique address per test run.

Q. What’s the difference between a fixture and setUp/tearDown?

setUp/tearDown are class-based and built into unittest. pytest fixtures are function-based, injected as arguments, and composable. In pytest projects, fixtures are the standard — and far more flexible.

Q. Can I have multiple conftest.py files?

Yes. pytest loads conftest.py files up the directory tree automatically. Put project-wide fixtures in the root conftest.py and subdirectory-specific fixtures in nested conftest.py files.

Q. Should Selenium tests use Page Object Model?

Once you have 10–20 tests, it’s worth considering. POM moves selectors and interaction logic into page classes — one class to update when a selector changes. You don’t need it from day one; introduce it when duplication starts appearing.

Q. Can I run tests in parallel with pytest-xdist?

Yes. pip install pytest-xdist then pytest -n auto. Note that scope="session" fixtures can’t share a browser across workers — each worker needs its own browser instance.

Q. Is headless mode required in CI/CD?

Yes, for display-less CI environments (GitHub Actions, Jenkins, etc.). Locally, keeping it off lets you watch the browser and debug visually. A clean pattern: comment out --headless in conftest.py and toggle it via environment variable for CI.

Q. What is fixture autouse?

@pytest.fixture(autouse=True) applies the fixture to every test automatically — no need to list it as a function argument. Useful for cross-cutting concerns like maximizing the window. Use carefully: it applies to all tests in scope.

Q. What’s the difference between yield and return in a fixture?

return only covers setup. yield splits the fixture into setup (before yield) and teardown (after yield). For WebDriver — yield is the only option. Use return for simple value fixtures like base_url.

Q. Should I learn Selenium or Playwright?

Start with Selenium. It dominates job postings and Upwork projects. Once you have the fundamentals down, Playwright is easy to add. Knowing both significantly increases your market value.

Q. What’s the difference between pytest.ini and pyproject.toml?

pytest.ini is pytest-only and simple. pyproject.toml is a modern Python project config covering dependencies, build, linters, and more — with pytest settings under [tool.pytest.ini_options]. New projects: pyproject.toml. Existing projects: pytest.ini is fine.

In production, the fixture/parametrize/mark patterns in this article are defined as team standards so new members can write tests in the same structure from day one. Extracting test data into a list and passing it to parametrize means spec changes only update the data — the test function stays intact. This has significantly reduced maintenance overhead across multiple client projects.

Work through this article’s structure and you’ll have a test suite where design and implementation are clearly separated and easy to maintain.

📋 Summary

  • Define the WebDriver fixture in conftest.py to share it across all test files
  • Use parametrize to separate test data from logic — cover all error cases efficiently
  • Use marks to classify tests and run only what CI/CD needs at each stage
  • Fixture chaining makes setup/teardown sequences explicit and reusable
  • Know the pitfalls upfront: missing test_ prefix, wrong conftest.py location, session scope state leaks

Start by extracting the data from one existing test into a parametrize list. That single change separates “test design” from “test implementation” — and makes the whole test suite easier to read and maintain.

Copied title and URL