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
- STEP 1: What Is a pytest fixture? Centralizing Selenium WebDriver with conftest.py
- STEP 2: Writing Selenium Tests — Happy Path and Error Cases
- STEP 3: pytest parametrize — Data-Driven Testing
- STEP 4: pytest mark — Classifying and Selecting Tests
- STEP 5: Advanced fixture Patterns (Setup and Teardown)
- STEP 6: Complete Example — Registration Form Tests
- ⚠️ 7 Common Pitfalls in Selenium × pytest
- FAQ
- 📋 Summary
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.iniRole of Each File
📋 File Roles
| conftest.py | Defines WebDriver launch/quit fixtures — auto-loaded by all test files |
| test_*.py | Tests split by feature — ideally 1 file per use case |
| pytest.ini | Test 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 slowerscope="session": one browser for the whole suite — faster, but watch for state leaks between testsimplicitly_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
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.textSample 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 ==========================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 timeTag 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"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")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
pytestFAQ
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.
📖 Related Articles
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.

