pytest Tutorial: Complete Guide
pytest is the most widely used testing framework in the Python ecosystem. It is simple enough to get started in minutes, but powerful enough to handle complex test suites with thousands of tests. This tutorial covers everything you need to know — from writing your first test to fixtures, parametrize, plugins, and CI integration.
What Is pytest and Why Use It?
pytest is an open-source testing framework for Python. It lets you write tests as plain functions (no classes required), uses Python's built-in assert statement for assertions, and provides detailed failure output that makes debugging fast.
pytest vs unittest
Python ships with unittest in the standard library. It works, but it shows its age. Here is what the same test looks like in each framework:
unittest:
import unittest
class TestMath(unittest.TestCase):
def test_addition(self):
self.assertEqual(1 + 1, 2)
if __name__ == "__main__":
unittest.main()pytest:
def test_addition():
assert 1 + 1 == 2The pytest version is shorter, cleaner, and easier to read. When the assertion fails, pytest introspects the values and prints a detailed diff — no need for assertEqual, assertIn, assertRaises, and the rest of the unittest assertion zoo.
Other reasons to choose pytest:
- Rich plugin ecosystem — over 1,000 plugins on PyPI
- Fixtures — a composable dependency injection system for test setup and teardown
- Parametrize — run one test function with many input combinations
- Automatic test discovery — no boilerplate registration
- Compatible with unittest and nose — migrate incrementally
Installation
Install pytest with pip:
pip install pytestVerify the installation:
pytest --version
# pytest 8.x.xFor projects using a requirements.txt, add it there:
pytest>=8.0Or add it to pyproject.toml under [tool.pytest.ini_options]:
[tool.pytest.ini_options]
testpaths = ["tests"]Writing Your First Test
Naming Conventions
pytest discovers tests by following these naming rules:
- Files must be named
test_*.pyor*_test.py - Functions must start with
test_ - Classes must start with
Test(no__init__method) - Methods inside test classes must start with
test_
Create a file called test_math.py:
def add(a, b):
return a + b
def test_add_two_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_number():
assert add(10, -4) == 6
def test_add_zeros():
assert add(0, 0) == 0Using assert
pytest uses Python's native assert statement. There is no need for special assertion methods:
def test_string_operations():
name = "pytest"
assert name.upper() == "PYTEST"
assert "test" in name
assert len(name) == 6
def test_list_contains():
fruits = ["apple", "banana", "cherry"]
assert "banana" in fruits
assert len(fruits) == 3
def test_raises_exception():
import pytest
with pytest.raises(ZeroDivisionError):
result = 1 / 0When an assertion fails, pytest shows the exact values involved:
AssertionError: assert 4 == 5
where 4 = add(2, 2)Running Tests
Run all discovered tests in the current directory:
pytestRun with verbose output (shows each test name):
pytest -vRun a specific file:
pytest test_math.pyRun a specific test function:
pytest test_math.py::test_add_two_positive_numbersRun tests matching a keyword:
pytest -k "add"Stop after the first failure:
pytest -xShow local variable values in tracebacks:
pytest -lTest Discovery Rules
When you run pytest without arguments, it searches for tests starting from the current directory (or the paths in testpaths). The discovery process:
- Recurses into directories (skipping those matching
norecursedirs— defaults include.git,node_modules,venv, etc.) - Collects files matching
python_files(default:test_*.pyand*_test.py) - Inside those files, collects functions matching
python_functions(default:test_*) and classes matchingpython_classes(default:Test*)
You can override these in pyproject.toml:
[tool.pytest.ini_options]
python_files = ["test_*.py", "check_*.py"]
python_functions = ["test_*", "check_*"]
testpaths = ["tests", "integration_tests"]A typical project layout that works well with pytest:
myproject/
├── src/
│ └── myapp/
│ └── calculator.py
├── tests/
│ ├── test_calculator.py
│ └── test_utils.py
└── pyproject.tomlFixtures with @pytest.fixture
Fixtures are reusable pieces of setup and teardown logic. Instead of repeating setup code in every test, you define it once as a fixture and inject it where needed.
import pytest
@pytest.fixture
def sample_user():
return {"id": 1, "name": "Alice", "email": "alice@example.com"}
def test_user_has_name(sample_user):
assert sample_user["name"] == "Alice"
def test_user_has_email(sample_user):
assert "@" in sample_user["email"]pytest passes the fixture by matching the function argument name to the fixture name. No imports needed — it just works.
Fixtures with Teardown
Use yield to add cleanup code that runs after the test:
import pytest
import sqlite3
@pytest.fixture
def db_connection():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER, name TEXT)")
yield conn
conn.close()
def test_insert_user(db_connection):
db_connection.execute("INSERT INTO users VALUES (1, 'Alice')")
cursor = db_connection.execute("SELECT name FROM users WHERE id = 1")
row = cursor.fetchone()
assert row[0] == "Alice"Fixture Scopes
By default, fixtures run once per test. You can change the scope:
@pytest.fixture(scope="module") # once per file
def expensive_resource():
...
@pytest.fixture(scope="session") # once per test run
def database_url():
return "postgresql://localhost/testdb"Available scopes: function (default), class, module, package, session.
conftest.py
Place shared fixtures in a conftest.py file. pytest automatically loads it — no import needed:
# tests/conftest.py
import pytest
@pytest.fixture
def api_client():
from myapp.client import Client
return Client(base_url="http://localhost:8000")Parametrize with @pytest.mark.parametrize
Running the same test logic with different inputs is one of the most common patterns in testing. @pytest.mark.parametrize handles this without duplicating code:
import pytest
def is_palindrome(s):
return s == s[::-1]
@pytest.mark.parametrize("word,expected", [
("racecar", True),
("hello", False),
("level", True),
("python", False),
("madam", True),
])
def test_palindrome(word, expected):
assert is_palindrome(word) == expectedThis generates five separate test cases, each with its own pass/fail result. When one fails, the others still run.
You can also combine multiple parametrize decorators to create a cartesian product of inputs:
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
assert x * y == y * x # commutative propertyThis produces six tests: every combination of x and y.
Useful Plugins
pytest-cov — Coverage Reports
Install:
pip install pytest-covRun tests with coverage:
pytest --cov=myapp --cov-report=term-missingThis prints a table showing which lines are covered and which are not. For HTML output:
pytest --cov=myapp --cov-report=htmlOpen htmlcov/index.html in a browser for a line-by-line coverage view.
Add a minimum coverage threshold to fail the build if coverage drops:
pytest --cov=myapp --cov-fail-under=80pytest-mock — Mocking Made Simple
Install:
pip install pytest-mockpytest-mock provides a mocker fixture that wraps unittest.mock in a cleaner API:
def get_user_from_api(user_id):
import requests
response = requests.get(f"https://api.example.com/users/{user_id}")
return response.json()
def test_get_user(mocker):
mock_get = mocker.patch("requests.get")
mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}
user = get_user_from_api(1)
assert user["name"] == "Alice"
mock_get.assert_called_once_with("https://api.example.com/users/1")The mock is automatically cleaned up after the test — no need to manually stop patches.
Exit Codes and CI Integration
pytest returns different exit codes depending on the outcome:
| Code | Meaning |
|---|---|
| 0 | All tests passed |
| 1 | Some tests failed |
| 2 | Test execution interrupted |
| 3 | Internal error |
| 4 | Command-line usage error |
| 5 | No tests were collected |
CI systems (GitHub Actions, GitLab CI, CircleCI) treat any non-zero exit code as a failed step. pytest's exit codes integrate automatically.
GitHub Actions Example
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest -v --cov=myapp --cov-report=xml
- uses: codecov/codecov-action@v4GitLab CI Example
test:
image: python:3.12
script:
- pip install -r requirements.txt
- pytest -v --junitxml=report.xml
artifacts:
reports:
junit: report.xmlThe --junitxml flag produces an XML report that most CI platforms can parse to display individual test results inline in the UI.
pytest rewards a few minutes of upfront learning with a testing workflow that stays fast and readable as your codebase grows. Start with plain test functions and assert, reach for fixtures when setup gets repetitive, and add parametrize when you find yourself writing nearly identical tests. The plugin ecosystem handles the rest.
Automate Testing Beyond Unit Tests
pytest handles unit and integration tests well. For end-to-end browser testing and 24/7 monitoring, HelpMeTest adds AI-powered test generation, self-healing selectors, and uptime monitoring — starting free.