pytest Tutorial: Complete Guide

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 == 2

The 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 pytest

Verify the installation:

pytest --version
# pytest 8.x.x

For projects using a requirements.txt, add it there:

pytest>=8.0

Or 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_*.py or *_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) == 0

Using 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 / 0

When 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:

pytest

Run with verbose output (shows each test name):

pytest -v

Run a specific file:

pytest test_math.py

Run a specific test function:

pytest test_math.py::test_add_two_positive_numbers

Run tests matching a keyword:

pytest -k "add"

Stop after the first failure:

pytest -x

Show local variable values in tracebacks:

pytest -l

Test 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:

  1. Recurses into directories (skipping those matching norecursedirs — defaults include .git, node_modules, venv, etc.)
  2. Collects files matching python_files (default: test_*.py and *_test.py)
  3. Inside those files, collects functions matching python_functions (default: test_*) and classes matching python_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.toml

Fixtures 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) == expected

This 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 property

This produces six tests: every combination of x and y.

Useful Plugins

pytest-cov — Coverage Reports

Install:

pip install pytest-cov

Run tests with coverage:

pytest --cov=myapp --cov-report=term-missing

This prints a table showing which lines are covered and which are not. For HTML output:

pytest --cov=myapp --cov-report=html

Open 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=80

pytest-mock — Mocking Made Simple

Install:

pip install pytest-mock

pytest-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@v4

GitLab CI Example

test:
  image: python:3.12
  script:
    - pip install -r requirements.txt
    - pytest -v --junitxml=report.xml
  artifacts:
    reports:
      junit: report.xml

The --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.

Start testing free →

Read more

ScyllaDB Testing Guide: Cassandra Driver Compatibility, Shard-per-Core Testing & Performance Regression

ScyllaDB Testing Guide: Cassandra Driver Compatibility, Shard-per-Core Testing & Performance Regression

ScyllaDB delivers Cassandra-compatible APIs with a rewritten Seastar-based engine that achieves dramatically higher throughput. Testing ScyllaDB applications requires validating both Cassandra compatibility and ScyllaDB-specific behaviors like shard-per-core data distribution. This guide covers both angles. ScyllaDB Testing Landscape ScyllaDB is a drop-in replacement for Cassandra at the API level—which means

By HelpMeTest