pytest is the most widely used testing framework in Python — its simple syntax and powerful fixture system dramatically improve the efficiency of QA engineers’ test automation workflows.
📌 Who This Article Is For
- QA engineers and developers who want to start test automation with Python
- Anyone who wants to systematically learn pytest from basics to fixture and parametrize
- Engineers considering migrating from unittest to pytest
- Those who want to manage tests in combination with Playwright or Selenium
✅ What You’ll Learn
- How to go from installing pytest to running your first test without getting stuck
- How to use fixture, parametrize, and conftest.py effectively
- End-to-end understanding of professional test structure and HTML report output
👤 About the Author
Working as a QA engineer using Selenium, Playwright, and pytest for real-world test automation. All explanations are based on code written in actual projects, focusing only on practical knowledge that works in production. Code is publicly available on GitHub: github.com/YOSHITSUGU728/automated-testing-portfolio
📌 Key Takeaways: 3 Things to Know About pytest
- Simple syntax: Write tests with just
assert— minimal learning curve - Fixtures for smart test management: Declare setup, teardown, and shared data in one place
- parametrize for better coverage: Run multiple test patterns from a single function
In the test automation learning roadmap, pytest is one of the first frameworks to learn alongside Python itself. It’s almost always used in combination with tools like Playwright and Selenium, handling test execution, management, and report output all in one place. This article walks you through everything step by step — from installation to production-ready test structure.
- What Is pytest? How It Differs from unittest
- ① Installation & Setup
- ② Writing Your First Tests
- ③ How to Use Fixtures
- ④ parametrize (Parameterized Tests)
- ⑤ Using conftest.py
- ⑥ Using Marks
- ⑦ pytest.ini Configuration
- ⑧ HTML Report Output
- ⑨ Playwright × pytest Integration Example
- ⑩ Useful Command Reference
- FAQ
- ⚠️ 7 Common pytest Pitfalls
- 📋 Summary
What Is pytest? How It Differs from unittest
pytest is an open-source testing framework for Python. Compared to the standard library’s unittest, it requires less code, produces more readable error messages, and has a rich plugin ecosystem.
| Feature | unittest | pytest |
|---|---|---|
| Writing style | Requires class inheritance | Function-based (no class needed) |
| Assertions | assertEqual, assertTrue, etc. | Plain assert statement |
| Error messages | Basic | Detailed and easy to read |
| Fixtures | setUp / tearDown | @pytest.fixture (flexible) |
| Plugins | Limited | Rich ecosystem: pytest-html, allure, etc. |
| Tool compatibility | Average | Excellent with Playwright & Selenium |
① Installation & Setup
Start by installing pytest. Python 3.8 or higher is required.
# Install pytest
pip install pytest
# Check version
pytest --version
# Install commonly used plugins all at once
pip install pytest pytest-html pytest-xdistExample project structure:
my_project/
├── src/
│ └── calculator.py # Source code under test
├── tests/
│ ├── conftest.py # Shared fixtures
│ ├── test_calculator.py # Test file
│ └── test_api.py
└── pytest.ini # pytest configurationtest_ (or end with _test.py). pytest auto-discovers tests following this naming convention.② Writing Your First Tests
Start with a simple function to test.
# src/calculator.py
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / bNow write the corresponding tests:
# tests/test_calculator.py
import pytest
from src.calculator import add, subtract, divide
# ✅ Basic test functions (must start with test_)
def test_add_normal():
result = add(3, 5)
assert result == 8
def test_subtract_normal():
result = subtract(10, 4)
assert result == 6
def test_divide_normal():
result = divide(10, 2)
assert result == 5.0
# ✅ Testing that an exception is raised
def test_divide_by_zero():
with pytest.raises(ValueError) as exc_info:
divide(10, 0)
assert "Cannot divide by zero" in str(exc_info.value)Running Tests
# Run all tests
pytest
# Run a specific file
pytest tests/test_calculator.py
# Verbose output (-v)
pytest -v
# Filter by test name (-k)
pytest -k "divide"
# Stop at first failure (-x)
pytest -xSample Output
========================= test session starts ==========================
platform win32 -- Python 3.11.0, pytest-7.4.0
collected 4 items
tests/test_calculator.py::test_add_normal PASSED [ 25%]
tests/test_calculator.py::test_subtract_normal PASSED [ 50%]
tests/test_calculator.py::test_divide_normal PASSED [ 75%]
tests/test_calculator.py::test_divide_by_zero PASSED [100%]
========================== 4 passed in 0.12s ===========================③ How to Use Fixtures
Fixtures are the core feature of pytest for managing test setup, teardown, and shared data. They eliminate repetitive setup code and keep your tests clean.
# tests/test_user.py
import pytest
# --- Fixture definition ---
@pytest.fixture
def sample_user():
"""Fixture that returns test user data"""
return {
"id": 1,
"name": "Test User",
"email": "test@example.com",
"role": "admin"
}
@pytest.fixture
def empty_list():
return []
# --- Tests using fixtures ---
def test_user_name(sample_user):
assert sample_user["name"] == "Test User"
def test_user_role(sample_user):
assert sample_user["role"] == "admin"
def test_empty_list_is_empty(empty_list):
assert len(empty_list) == 0Setup / Teardown with yield
Using yield, you can combine before- and after-test logic in a single fixture:
@pytest.fixture
def db_connection():
# --- Setup (runs before test) ---
print("\n[SETUP] Connecting to DB...")
connection = {"status": "connected", "db": "test_db"}
yield connection # ← passed to the test here
# --- Teardown (runs after test) ---
print("\n[TEARDOWN] Closing DB connection")
connection["status"] = "disconnected"
def test_db_is_connected(db_connection):
assert db_connection["status"] == "connected"Fixture Scope
| scope | When it runs | Best use |
|---|---|---|
function (default) | Per test function | Lightweight data setup |
class | Per test class | Shared state within a class |
module | Per file | Reuse within a single file |
session | Once per full test run | Heavy setup like DB connections or browser launch |
# Example: launch browser only once per session (Playwright integration)
@pytest.fixture(scope="session")
def browser():
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
yield browser
browser.close()④ parametrize (Parameterized Tests)
@pytest.mark.parametrize lets you run the same test logic against multiple input patterns. It’s extremely useful for boundary value testing and covering both normal and error cases efficiently.
import pytest
from src.calculator import add, divide
# ✅ Basic parametrize
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3), # positive integers
(-1, -2, -3), # negative integers
(0, 5, 5), # includes zero
(100, 200, 300) # large numbers
])
def test_add_parametrized(a, b, expected):
assert add(a, b) == expected
# ✅ Multiple error cases in one test
@pytest.mark.parametrize("a, b", [
(10, 0),
(0, 0),
(-5, 0),
])
def test_divide_by_zero_parametrized(a, b):
with pytest.raises(ValueError):
divide(a, b)Each parameter set appears as an independent test in the output:
tests/test_calculator.py::test_add_parametrized[1-2-3] PASSED
tests/test_calculator.py::test_add_parametrized[-1--2--3] PASSED
tests/test_calculator.py::test_add_parametrized[0-5-5] PASSED
tests/test_calculator.py::test_add_parametrized[100-200-300] PASSED⑤ Using conftest.py
conftest.py is a special file for sharing fixtures across multiple test files. Fixtures defined here are available to all tests without any import statement.
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def base_url():
"""Base URL shared across all tests"""
# Note: add http:// prefix in actual code (e.g. "http://localhost:8080")
return "localhost:8080"
@pytest.fixture
def test_user_credentials():
"""Test login credentials"""
return {
"username": "standard_user",
"password": "secret_sauce"
}
@pytest.fixture(scope="session")
def api_headers():
"""Common headers for API tests"""
return {
"Content-Type": "application/json",
"Accept": "application/json"
}# tests/test_login.py
# ← No import needed! conftest.py fixtures are available automatically
def test_login_with_valid_credentials(test_user_credentials, base_url):
username = test_user_credentials["username"]
password = test_user_credentials["password"]
# ... test logic⑥ Using Marks
pytest’s mark feature makes it easy to skip tests or run them conditionally.
import pytest
import sys
# ✅ Skip a test
@pytest.mark.skip(reason="Not yet implemented")
def test_future_feature():
assert some_future_function() == True
# ✅ Skip conditionally (e.g. skip on Windows)
@pytest.mark.skipif(sys.platform == "win32", reason="Not supported on Windows")
def test_linux_only():
assert True
# ✅ Known failure (expected to fail)
@pytest.mark.xfail(reason="Waiting for bug fix: issue #123")
def test_known_bug():
assert 1 + 1 == 3 # intentionally fails
# ✅ Custom marks (group tests for targeted runs)
@pytest.mark.smoke
def test_login_smoke():
assert True
@pytest.mark.regression
def test_checkout_flow_regression():
assert True# Run only smoke tests
pytest -m smoke
# Run only regression tests
pytest -m regression
# Custom marks must be registered in pytest.ini
# pytest.ini:
# [pytest]
# markers =
# smoke: Smoke tests
# regression: Regression tests⑦ pytest.ini Configuration
Place a pytest.ini file at the project root to customize pytest behavior globally:
# pytest.ini
[pytest]
# Test discovery path
testpaths = tests
# Default options (same as always passing -v --tb=short --html=... each time)
addopts = -v --tb=short --html=reports/report.html --self-contained-html
# Register custom marks (required to suppress warnings)
markers =
smoke: Smoke tests (minimal sanity checks)
regression: Regression tests
slow: Tests that take a long time to run
# Log output settings
log_cli = true
log_cli_level = INFO⑧ HTML Report Output
The pytest-html plugin outputs test results as an HTML file — very handy for sharing results in CI/CD or QA reports.
# Install
pip install pytest-html
# Generate HTML report
pytest --html=reports/report.html --self-contained-html
# Add to pytest.ini to generate automatically every time
# addopts = -v --html=reports/report.html --self-contained-htmlOpen reports/report.html in a browser to view test names, pass/fail status, execution time, and error details all in one place.
--self-contained-html option embeds CSS and JS into a single file, making it easy to share via email or Slack.⑨ Playwright × pytest Integration Example
Combining pytest with Playwright lets you manage E2E tests in a clean, organized way. The easiest approach is to use the pytest-playwright plugin.
# Install
pip install pytest-playwright
playwright install chromium# tests/test_saucedemo.py
import pytest
from playwright.sync_api import Page
# Just accept the page fixture provided by pytest-playwright
# Note: add http:// prefix in actual code (e.g. http://localhost:8080/)
def test_login_success(page: Page):
page.goto("localhost:8080/")
page.fill("#user-name", "standard_user")
page.fill("#password", "secret_sauce")
page.click("#login-button")
assert page.url.endswith("/inventory.html")
def test_login_wrong_password(page: Page):
page.goto("localhost:8080/")
page.fill("#user-name", "standard_user")
page.fill("#password", "wrong_password")
page.click("#login-button")
error_msg = page.locator(".error-message-container").text_content()
assert "Epic sadface" in error_msg# Run in headless mode (default)
pytest tests/test_saucedemo.py -v
# Run with visible browser (useful for debugging)
pytest tests/test_saucedemo.py --headed
# Specify browser
pytest tests/test_saucedemo.py --browser firefox⑩ Useful Command Reference
| Command | Description |
|---|---|
pytest | Run all tests |
pytest -v | Verbose output |
pytest -x | Stop at first failure |
pytest -k "keyword" | Filter tests by name |
pytest -m smoke | Run by mark |
pytest -s | Show print() output |
pytest --lf | Re-run only last failed tests |
pytest -n 4 | Run 4 tests in parallel (pytest-xdist) |
pytest --html=report.html | Generate HTML report (pytest-html) |
FAQ
Q. Should I use pytest or unittest?
For new projects or learning purposes, pytest is strongly recommended. It requires less code, produces clearer error messages, and integrates beautifully with Playwright and Selenium. Existing unittest code can still be run with pytest, so migration can be done gradually.
Q. Where should I put conftest.py?
For most projects, placing it at tests/conftest.py is fine. You can also place it in subdirectories for scope-limited fixtures. In large projects, a hierarchical layout is best practice.
Q. What should I do when tests are slow?
Using pytest-xdist for parallel execution is the most effective solution (pytest -n auto). Also, moving heavy initialization into a scope="session" fixture so it runs only once per test session can drastically cut run time.
Q. Can pytest be used in CI/CD?
Yes — pytest integrates seamlessly with GitHub Actions, GitLab CI, Jenkins, and other major CI/CD tools. Since it runs from the command line, a single run: pytest line in a YAML file is all you need. Saving HTML reports as artifacts is also straightforward.
⚠️ 7 Common pytest Pitfalls
These are the most common stumbling blocks when starting out with pytest. Knowing them in advance can save you a lot of frustrating debugging time.
① Test file or function name doesn’t start with test_
pytest only auto-discovers files matching test_*.py and functions starting with test_. Writing check_login() or validate_form() will not be recognized as tests — running pytest will show collected 0 items.
② Wrong location for conftest.py causes “fixture not found”
conftest.py is auto-loaded by pytest, but it must be in the same directory as your test files or a parent directory. The most common mistake is placing it outside the tests/ folder.
project/
├── src/
└── tests/
├── conftest.py ← correct location (directly under tests/)
└── test_api.py③ scope="session" fixture state leaks between tests
Session-scoped fixtures are initialized only once for the entire test run. If one test mutates the fixture’s state, it affects all subsequent tests. Be especially careful with mutable types like lists and dicts. In API tests, saving response data into a session fixture and then modifying it is a common cause of unexpected failures in later tests.
④ Custom marks not registered in pytest.ini produce warnings
Even if you use @pytest.mark.smoke, pytest will emit a PytestUnknownMarkWarning unless the mark is registered under [pytest] markers = in pytest.ini. In some CI environments, warnings are treated as errors — so always register your custom marks.
⑤ print() output doesn’t appear in the terminal
pytest captures stdout by default, so print() inside tests won’t be visible. Add the -s flag when running to see output. You can also add -s to addopts in pytest.ini to always show it — but be aware this can produce very noisy logs in CI. Note: when a test fails, pytest automatically shows any print() output for that test.
⑥ An exception before assert causes “ERROR” instead of “FAILED”
When a test shows ERROR rather than FAILED, it means an unexpected exception occurred before reaching the assert statement. Common causes are fixture initialization failures, import errors, or type errors. Use pytest -v --tb=long to get a detailed stack trace.
⑦ Running pytest from the wrong directory causes ModuleNotFoundError
This is a pitfall that almost every beginner hits at least once. If you cd into the tests/ folder and run pytest from there, Python can’t find the src module and throws ModuleNotFoundError: No module named 'src'.
# ❌ Wrong: running from inside the tests folder
cd tests
pytest
# ✅ Correct: always run from the project root
cd my_project
pytestAlways run pytest from the project root — the directory that contains pytest.ini.
📖 Related Articles
📋 Summary
- pytest is Python’s most powerful testing framework — write tests with nothing but plain
assertstatements - fixtures manage setup, teardown, and shared data declaratively, eliminating code duplication
- parametrize efficiently covers boundary values, normal cases, and error cases from a single function
- conftest.py centralizes shared fixtures so they can be reused across multiple test files
- marks let you group tests and flexibly control smoke, regression, and skip behavior
- pytest-html auto-generates reports for easy sharing in CI/CD or with your team
Once you master pytest, you can build a solid test automation foundation in combination with Playwright and Selenium. Start simple with basic function tests, then level up step by step through fixtures, parametrize, and conftest.py.

