Conftest.py & It’s Role in Automation
Automation Testing, Bug, 2025
Conftest.py: The Secret to Scalable Test Architecture in Pytest
If you’ve been writing pytest tests for a while, you’ve probably faced this frustrating moment: you have the same fixture copied across multiple test files, and when you need to change something, you’re editing the same code in 10 different places. Or maybe you’re importing fixtures from random test files just to reuse them, making your test suite look like a tangled mess.
I’ve been there. After years of writing Selenium tests and building test frameworks, I can tell you that conftest.py
is one of those game-changing features that separates beginner test suites from professional, maintainable ones.
In this article, I’ll walk you through everything you need to know about conftest.py
- from the basics to advanced patterns that’ll make your test architecture rock solid. Whether you’re just starting with pytest or you’re looking to refactor your existing test suite, this guide has something for you.
Let’s dive in.
What is conftest.py and Why Should You Care?
At its core, conftest.py
is a special Python file that pytest automatically discovers and loads. Think of it as a central place where you can define fixtures, hooks, and configurations that multiple test files can use without any imports.
Here’s the magic: any fixture you define in conftest.py
becomes automatically available to all test files in that directory and its subdirectories. No imports needed.
# conftest.py
import pytest
@pytest.fixture
def sample_data():
return {"name": "Alice", "age": 30}
# test_user.py (same directory)
def test_user_data(sample_data): # No import needed!
assert sample_data["name"] == "Alice"
But here’s where it gets interesting - conftest.py
isn’t just about fixtures. It’s your control center for:
- Shared fixtures across multiple test files
- Test configuration and setup
- Custom pytest hooks
- Plugin integration
- Environment-specific settings
The Problem conftest.py Solves
Let me paint a picture of what testing looks like without proper use of conftest.py
:
# test_login.py
@pytest.fixture
def webdriver():
driver = webdriver.Chrome()
yield driver
driver.quit()
# test_dashboard.py
@pytest.fixture
def webdriver(): # Same fixture copied!
driver = webdriver.Chrome()
yield driver
driver.quit()
# test_profile.py
@pytest.fixture
def webdriver(): # And again...
driver = webdriver.Chrome()
yield driver
driver.quit()
This is maintenance nightmare. Change the browser configuration? Edit multiple files. Add a new setup step? Copy-paste everywhere.
With conftest.py
, this becomes:
# conftest.py
@pytest.fixture
def webdriver():
driver = webdriver.Chrome()
yield driver
driver.quit()
# All test files can now use 'webdriver' fixture without any setup!
Clean, maintainable, and follows the DRY principle.
Understanding conftest.py Discovery and Scope
Pytest’s discovery mechanism for conftest.py
files is both powerful and intuitive. Here’s how it works:
The key principle: fixtures are available to test files in the same directory and all subdirectories.
This hierarchical approach lets you:
- Put global fixtures at the project root
- Add directory-specific fixtures as you go deeper
- Override fixtures at more specific levels
Basic Usage Patterns
Simple Shared Fixtures
Let’s start with the most common use case - sharing fixtures across test files:
# conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
@pytest.fixture(scope="session")
def browser():
"""Shared browser instance for all tests"""
options = Options()
options.add_argument("--headless")
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
@pytest.fixture
def test_data():
"""Sample test data for multiple test files"""
return {
"valid_user": {"username": "testuser", "password": "password123"},
"invalid_user": {"username": "baduser", "password": "wrongpass"}
}
Now any test file in your project can use these fixtures:
# test_login.py
def test_valid_login(browser, test_data):
# Use browser and test_data fixtures
pass
# test_registration.py
def test_user_registration(browser, test_data):
# Same fixtures available here too
pass
Configuration and Environment Setup
conftest.py
is perfect for environment-specific configurations:
# conftest.py
import pytest
import os
@pytest.fixture(scope="session")
def base_url():
"""Environment-specific base URL"""
env = os.getenv("TEST_ENV", "staging")
urls = {
"staging": "https://staging.example.com",
"production": "https://example.com",
"local": "http://localhost:3000"
}
return urls.get(env, urls["staging"])
@pytest.fixture(scope="session")
def api_credentials():
"""Load API credentials from environment"""
return {
"api_key": os.getenv("API_KEY"),
"secret": os.getenv("API_SECRET")
}
Hierarchical conftest.py Architecture
Here’s where things get really powerful. You can have multiple conftest.py
files in different directories, each serving different scopes:
project/
├── conftest.py # Global fixtures
├── tests/
│ ├── conftest.py # Test-wide fixtures
│ ├── unit/
│ │ ├── conftest.py # Unit test specific
│ │ └── test_models.py
│ ├── integration/
│ │ ├── conftest.py # Integration test specific
│ │ └── test_api.py
│ └── e2e/
│ ├── conftest.py # E2E test specific
│ └── test_workflows.py
Real-World Example
# project/conftest.py (Global level)
import pytest
@pytest.fixture(scope="session")
def database_url():
return "postgresql://test:test@localhost/testdb"
# tests/conftest.py (Test level)
import pytest
from database import Database
@pytest.fixture(scope="session")
def db_connection(database_url):
db = Database(database_url)
db.connect()
yield db
db.disconnect()
# tests/unit/conftest.py (Unit test level)
import pytest
@pytest.fixture
def mock_external_api():
"""Mock external API calls for unit tests"""
with patch('requests.get') as mock_get:
mock_get.return_value.json.return_value = {"status": "success"}
yield mock_get
# tests/e2e/conftest.py (E2E test level)
import pytest
from selenium import webdriver
@pytest.fixture
def authenticated_browser(browser, base_url):
"""Browser with user already logged in"""
browser.get(f"{base_url}/login")
# Perform login steps
return browser
This structure gives you:
- Global fixtures available everywhere
- Specialized fixtures for specific test types
- Clean separation of concerns
- Easy maintenance - change unit test setup without affecting E2E tests
Advanced Patterns and Best Practices
Fixture Factories in conftest.py
Sometimes you need more flexibility than static fixtures provide. Factory fixtures are perfect for this:
# conftest.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
@pytest.fixture
def page_object_factory(browser):
"""Factory for creating page objects with common functionality"""
class BasePage:
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, 10)
def find_element(self, locator):
return self.wait.until(EC.presence_of_element_located(locator))
def click_element(self, locator):
element = self.find_element(locator)
element.click()
def _create_page(page_class):
return page_class(browser)
return _create_page
# Usage in tests
def test_login_page(page_object_factory):
login_page = page_object_factory(LoginPage)
login_page.enter_credentials("user", "pass")
Conditional Fixtures
You can create fixtures that behave differently based on conditions:
# conftest.py
import pytest
@pytest.fixture
def browser(request):
"""Browser fixture that adapts based on markers"""
browser_name = "chrome" # default
# Check for custom markers
if request.node.get_closest_marker("firefox"):
browser_name = "firefox"
elif request.node.get_closest_marker("safari"):
browser_name = "safari"
if browser_name == "firefox":
driver = webdriver.Firefox()
elif browser_name == "safari":
driver = webdriver.Safari()
else:
driver = webdriver.Chrome()
yield driver
driver.quit()
# Usage in tests
@pytest.mark.firefox
def test_firefox_specific_feature(browser):
# This test will use Firefox
pass
Data-Driven Testing with conftest.py
Perfect for maintaining test data centrally:
# conftest.py
import pytest
import json
import os
@pytest.fixture(scope="session")
def test_data_loader():
"""Centralized test data loader"""
def load_data(filename):
data_path = os.path.join("test_data", filename)
with open(data_path, 'r') as f:
return json.load(f)
return load_data
@pytest.fixture(params=["valid_users.json", "invalid_users.json"])
def user_test_data(request, test_data_loader):
"""Parameterized fixture for different user data sets"""
return test_data_loader(request.param)
# Tests automatically run with both data sets
def test_user_validation(user_test_data):
for user in user_test_data:
# Test logic here
pass
Selenium-Specific Patterns
As a Selenium tester, here are some patterns I’ve found incredibly useful:
Browser Management
# conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.firefox.options import Options as FirefoxOptions
@pytest.fixture(scope="session")
def browser_config():
"""Centralized browser configuration"""
return {
"chrome_options": ["--headless", "--no-sandbox", "--disable-dev-shm-usage"],
"firefox_options": ["--headless"],
"implicit_wait": 10,
"page_load_timeout": 30
}
@pytest.fixture
def chrome_browser(browser_config):
options = Options()
for option in browser_config["chrome_options"]:
options.add_argument(option)
driver = webdriver.Chrome(options=options)
driver.implicitly_wait(browser_config["implicit_wait"])
driver.set_page_load_timeout(browser_config["page_load_timeout"])
yield driver
driver.quit()
@pytest.fixture
def firefox_browser(browser_config):
options = FirefoxOptions()
for option in browser_config["firefox_options"]:
options.add_argument(option)
driver = webdriver.Firefox(options=options)
driver.implicitly_wait(browser_config["implicit_wait"])
driver.set_page_load_timeout(browser_config["page_load_timeout"])
yield driver
driver.quit()
Page Object Integration
# conftest.py
import pytest
from pages.login_page import LoginPage
from pages.dashboard_page import DashboardPage
@pytest.fixture
def login_page(browser, base_url):
"""Pre-configured login page object"""
browser.get(f"{base_url}/login")
return LoginPage(browser)
@pytest.fixture
def authenticated_session(login_page, valid_credentials):
"""Fixture that provides an authenticated browser session"""
login_page.login(
valid_credentials["username"],
valid_credentials["password"]
)
return DashboardPage(login_page.driver)
Testing conftest.py and Debugging
How to Test Your Fixtures
Yes, you should test your fixtures too! Here’s how:
# test_conftest.py
def test_browser_fixture_creates_driver(browser):
"""Test that browser fixture works correctly"""
assert browser is not None
assert hasattr(browser, 'get')
assert hasattr(browser, 'find_element')
def test_test_data_fixture_loads_correctly(test_data):
"""Test that test data fixture provides expected structure"""
assert "valid_user" in test_data
assert "username" in test_data["valid_user"]
assert "password" in test_data["valid_user"]
Debugging Fixture Issues
Common debugging techniques:
# conftest.py
import pytest
import logging
# Enable logging for fixture debugging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@pytest.fixture
def debug_browser():
logger.info("Creating browser instance")
driver = webdriver.Chrome()
logger.info(f"Browser created: {driver}")
yield driver
logger.info("Cleaning up browser")
driver.quit()
logger.info("Browser cleanup complete")
Using pytest –fixtures
Pytest provides a built-in way to see all available fixtures:
# See all fixtures available in current directory
pytest --fixtures
# See fixtures with their source locations
pytest --fixtures-per-test test_file.py::test_function
Common Pitfalls and How to Avoid Them
1. Fixture Name Conflicts
Problem: Multiple conftest.py
files define fixtures with the same name.
# tests/conftest.py
@pytest.fixture
def user_data():
return {"type": "admin"}
# tests/unit/conftest.py
@pytest.fixture
def user_data(): # This overrides the parent fixture!
return {"type": "regular"}
Solution: Use descriptive, specific names and be intentional about overrides.
2. Scope Mismanagement
Problem: Using wrong fixture scope leading to performance issues or test isolation problems.
# Bad: Database fixture with function scope - creates DB for every test
@pytest.fixture(scope="function")
def database():
return create_expensive_database()
# Good: Use appropriate scope
@pytest.fixture(scope="module")
def database():
return create_expensive_database()
3. Over-Engineering
Problem: Creating too many layers of conftest.py
files.
Rule of thumb: If you have more than 3-4 levels of conftest.py
files, you might be over-engineering. Keep it simple.
4. Import Issues
Problem: Trying to import fixtures from conftest.py
.
# Wrong
from conftest import my_fixture # Don't do this!
# Right
# Just use the fixture name as a parameter
def test_something(my_fixture):
pass
Performance Considerations
Smart Scope Usage
# conftest.py
@pytest.fixture(scope="session")
def expensive_setup():
"""Run once per test session - perfect for expensive operations"""
database = create_test_database()
load_test_data(database)
return database
@pytest.fixture(scope="module")
def module_specific_setup(expensive_setup):
"""Build on session fixture for module-specific needs"""
setup_module_data(expensive_setup)
return expensive_setup
@pytest.fixture # function scope - default
def clean_state(module_specific_setup):
"""Reset state for each test"""
reset_data(module_specific_setup)
return module_specific_setup
Lazy Loading
# conftest.py
@pytest.fixture
def heavy_resource():
"""Only create the resource when actually used"""
resource = None
def get_resource():
nonlocal resource
if resource is None:
resource = create_expensive_resource()
return resource
return get_resource
Real-World Project Structure
Here’s how I typically structure a real Selenium project:
selenium_project/
├── conftest.py # Global fixtures (browser, base_url)
├── pytest.ini # Pytest configuration
├── requirements.txt
├── pages/ # Page Object Models
│ ├── __init__.py
│ ├── base_page.py
│ └── login_page.py
├── test_data/ # Test data files
│ ├── users.json
│ └── products.json
├── tests/
│ ├── conftest.py # Test-wide fixtures (auth, data loaders)
│ ├── smoke/
│ │ ├── conftest.py # Smoke test fixtures
│ │ └── test_critical_paths.py
│ ├── regression/
│ │ ├── conftest.py # Regression-specific fixtures
│ │ └── test_full_workflows.py
│ └── api/
│ ├── conftest.py # API testing fixtures
│ └── test_endpoints.py
├── utils/ # Helper utilities
│ ├── __init__.py
│ └── database_helper.py
└── reports/ # Test reports output
# Root conftest.py
import pytest
from selenium import webdriver
@pytest.fixture(scope="session")
def base_url():
return os.getenv("BASE_URL", "https://staging.example.com")
@pytest.fixture
def browser():
driver = webdriver.Chrome()
yield driver
driver.quit()
# tests/conftest.py
import pytest
from utils.database_helper import DatabaseHelper
@pytest.fixture(scope="session")
def db_helper():
helper = DatabaseHelper()
helper.connect()
yield helper
helper.disconnect()
@pytest.fixture
def clean_database(db_helper):
yield db_helper
db_helper.clean_test_data()
# tests/smoke/conftest.py
import pytest
@pytest.fixture
def quick_browser():
"""Headless browser for smoke tests"""
options = webdriver.ChromeOptions()
options.add_argument("--headless")
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
Integration with CI/CD
conftest.py
shines in CI/CD environments:
# conftest.py
import pytest
import os
@pytest.fixture(scope="session")
def ci_browser():
"""Browser configuration optimized for CI environment"""
options = webdriver.ChromeOptions()
if os.getenv("CI"):
# CI-specific options
options.add_argument("--headless")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-gpu")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
return driver
@pytest.fixture(scope="session")
def test_environment():
"""Determine test environment from CI variables"""
if os.getenv("GITHUB_ACTIONS"):
return "github_ci"
elif os.getenv("JENKINS_URL"):
return "jenkins"
else:
return "local"
Migration Strategy: From Scattered Fixtures to conftest.py
If you have an existing test suite with scattered fixtures, here’s how to migrate:
Step 1: Audit Existing Fixtures
# Find all fixture definitions
grep -r "@pytest.fixture" tests/ --include="*.py"
Step 2: Identify Common Patterns
Group fixtures by:
- Usage frequency (how many test files use them)
- Scope (session, module, function)
- Domain (browser-related, data-related, etc.)
Step 3: Create Hierarchical Structure
Start with the most commonly used fixtures at the root level:
# conftest.py (root level)
@pytest.fixture(scope="session")
def browser(): # Used by 80% of tests
# Move most common browser fixture here
@pytest.fixture
def test_user(): # Used across multiple domains
# Common user fixture
Step 4: Migrate Gradually
Don’t try to move everything at once. Migrate one domain at a time:
- Browser/WebDriver fixtures first
- Common test data second
- Domain-specific fixtures last
Step 5: Clean Up
Remove duplicate fixtures and unused imports as you go.
Conclusion
conftest.py
is more than just a place to put shared fixtures - it’s the foundation of a scalable, maintainable test architecture. When used properly, it:
- Eliminates code duplication across your test suite
- Provides clear separation between different test domains
- Makes test maintenance significantly easier
- Enables powerful patterns like fixture factories and conditional fixtures
- Scales beautifully from small projects to enterprise test suites
The key is to start simple and evolve your conftest.py
structure as your test suite grows. Don’t over-engineer from the beginning, but don’t ignore it either.
Here are the main takeaways:
- Use hierarchical structure - Global fixtures at the root, specific ones deeper in the tree
- Choose appropriate scopes - Session for expensive setup, function for test isolation
- Keep fixtures focused - Each fixture should have a single, clear responsibility
- Name fixtures clearly - Future you (and your team) will thank you
- Test your fixtures - They’re code too, they can have bugs
Start by moving your most commonly used fixtures to conftest.py
, and gradually build out your structure. You’ll be amazed at how much cleaner and more maintainable your test suite becomes.
Remember, good test architecture is not about following rigid rules - it’s about making your life easier when you’re debugging that failing test at 2 AM. And trust me, conftest.py
will definitely make that easier.