Testing is where good code becomes reliable code. You can write clean functions and clever algorithms, but without proper tests, you’re shipping code with a blindfold on. Bugs slip through, regressions multiply, and maintenance becomes a headache.
Python developers have adopted pytest as the testing framework of choice for good reasons. Unlike unittest’s verbose class-based approach, pytest lets you write tests that feel like regular Python code. Installation takes seconds, writing tests takes minutes, and the time you save on debugging pays for itself within days.
This tutorial walks you through pytest from installation to continuous integration. You’ll write your first test, master fixtures and parametrization, learn when and how to mock dependencies, and set up automated testing that catches bugs before your users do.
Prerequisites
Before writing any tests, you need pytest installed. If you’re working on a team project or planning to share your code, create a virtual environment first.
# Create and activate a virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install pytest
pip install pytest
# Install pytest with common plugins
pip install pytest pytest-cov pytest-mock
The pytest-cov plugin measures code coverage, showing which lines your tests execute. The pytest-mock plugin simplifies mocking, which you’ll need when testing code that talks to databases or APIs.
Check your installation by running:
pytest --version
You should see something like pytest 8.0.0 or newer. If the command fails, your virtual environment might not be activated or pip installed pytest to a different Python version than the one in your PATH.
Create a simple project structure to follow along:
myproject/
├── src/
│ └── calculator.py
└── tests/
└── test_calculator.py
Keep your tests separate from your source code. Some developers prefer a tests/ directory at the project root, others mirror the source structure inside tests/. Pick one and stick with it across your projects.
Step 1: Writing Your First Tests
Tests in pytest are just functions that start with test_. No classes required, no inheritance, just plain functions with assertions.
Create a simple calculator module 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 / b
Now write tests for these functions:
# tests/test_calculator.py
from src.calculator import add, subtract, divide
import pytest
def test_add_positive_numbers():
result = add(3, 5)
assert result == 8
def test_add_negative_numbers():
result = add(-2, -7)
assert result == -9
def test_subtract():
assert subtract(10, 4) == 6
assert subtract(0, 5) == -5
def test_divide():
assert divide(10, 2) == 5
assert divide(9, 3) == 3
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
Run your tests:
pytest tests/
You’ll see output like this:
============================= test session starts ==============================
collected 5 items
tests/test_calculator.py ..... [100%]
============================== 5 passed in 0.02s ===============================
Each dot represents a passing test. If a test fails, pytest shows you the exact line where the assertion failed, the expected value, and the actual value.
The pytest.raises() context manager verifies that code raises the expected exception. The match parameter accepts a regex pattern to check the error message. This catches cases where your code raises the right exception type but with the wrong message.
Notice how readable these tests are. You don’t need to remember special assertion methods like assertEqual() or assertRaises(). Just use Python’s assert keyword. When an assertion fails, pytest’s introspection shows you exactly what went wrong.
Step 2: Using Fixtures for Test Setup
Fixtures handle the boring parts of testing: creating objects, opening files, connecting to databases, cleaning up afterward. Instead of copying setup code across multiple tests, you write it once in a fixture.
Suppose you’re testing a shopping cart:
# src/cart.py
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, item, price):
self.items.append({"item": item, "price": price})
def get_total(self):
return sum(item["price"] for item in self.items)
def apply_discount(self, percentage):
if not 0 <= percentage <= 100:
raise ValueError("Discount must be between 0 and 100")
total = self.get_total()
return total * (1 - percentage / 100)
Without fixtures, every test would start with cart = ShoppingCart(). That gets old fast. Fixtures solve this:
# tests/test_cart.py
import pytest
from src.cart import ShoppingCart
@pytest.fixture
def empty_cart():
return ShoppingCart()
@pytest.fixture
def cart_with_items():
cart = ShoppingCart()
cart.add_item("Python Book", 29.99)
cart.add_item("Laptop Stand", 45.50)
return cart
def test_empty_cart_total(empty_cart):
assert empty_cart.get_total() == 0
def test_add_item_to_cart(empty_cart):
empty_cart.add_item("Mouse", 15.99)
assert empty_cart.get_total() == 15.99
def test_cart_total_calculation(cart_with_items):
assert cart_with_items.get_total() == 75.49
def test_apply_discount(cart_with_items):
discounted = cart_with_items.apply_discount(10)
assert discounted == 67.941
def test_invalid_discount(cart_with_items):
with pytest.raises(ValueError, match="Discount must be between 0 and 100"):
cart_with_items.apply_discount(150)
Each test that needs a cart just adds it as a parameter. Pytest sees the parameter name, finds the matching fixture, runs the fixture function, and passes the return value to your test.
Fixtures run once per test by default. If test_add_item_to_cart() modifies the cart, that change doesn’t affect test_cart_total_calculation(). Each test gets a fresh cart. This isolation prevents tests from affecting each other.
Fixture scope controls when fixtures run and how long they live:
@pytest.fixture(scope="function") # Default: runs once per test
def database_connection():
conn = create_connection()
yield conn
conn.close()
@pytest.fixture(scope="module") # Runs once per test file
def expensive_resource():
resource = load_large_dataset()
return resource
@pytest.fixture(scope="session") # Runs once per test session
def api_client():
client = APIClient()
client.authenticate()
yield client
client.logout()
Use scope="function" when tests need isolation. Use scope="module" for expensive setup that multiple tests can share safely. Use scope="session" sparingly, only for global resources like test database schemas.
The yield keyword in fixtures runs cleanup code after the test finishes. Code before yield is setup, code after yield is teardown. If your fixture doesn’t need cleanup, just return the value.
Step 3: Parametrized Tests
Parametrization runs the same test with different inputs. Instead of writing ten tests that differ only in their input values, write one test and parametrize it.
Testing edge cases becomes easier:
# tests/test_calculator_parametrized.py
import pytest
from src.calculator import add, divide
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(-5, -3, -8),
(100, 200, 300),
])
def test_add_parametrized(a, b, expected):
assert add(a, b) == expected
@pytest.mark.parametrize("dividend,divisor,expected", [
(10, 2, 5),
(9, 3, 3),
(7, 2, 3.5),
(0, 5, 0),
])
def test_divide_parametrized(dividend, divisor, expected):
assert divide(dividend, divisor) == expected
Run this and pytest executes test_add_parametrized five times, once for each parameter set:
tests/test_calculator_parametrized.py::test_add_parametrized[2-3-5] PASSED
tests/test_calculator_parametrized.py::test_add_parametrized[0-0-0] PASSED
tests/test_calculator_parametrized.py::test_add_parametrized[-1-1-0] PASSED
tests/test_calculator_parametrized.py::test_add_parametrized[-5--3--8] PASSED
tests/test_calculator_parametrized.py::test_add_parametrized[100-200-300] PASSED
Each parameter combination gets its own test ID. If one fails, you know exactly which input caused the problem.
Combine parametrization with fixtures:
@pytest.fixture
def cart():
return ShoppingCart()
@pytest.mark.parametrize("discount,expected_valid", [
(0, True),
(50, True),
(100, True),
(-10, False),
(150, False),
])
def test_discount_validation(cart, discount, expected_valid):
cart.add_item("Item", 100)
if expected_valid:
result = cart.apply_discount(discount)
assert result >= 0
else:
with pytest.raises(ValueError):
cart.apply_discount(discount)
You can parametrize multiple arguments and pytest generates the cartesian product:
@pytest.mark.parametrize("item", ["Book", "Laptop", "Mouse"])
@pytest.mark.parametrize("price", [10.00, 50.00, 100.00])
def test_all_combinations(empty_cart, item, price):
empty_cart.add_item(item, price)
assert empty_cart.get_total() == price
This creates nine tests, one for each item and price combination. Use this sparingly as combinations can explode quickly.
For complex scenarios, use pytest.param() to add IDs and marks:
@pytest.mark.parametrize("input,expected", [
pytest.param("valid@email.com", True, id="valid_email"),
pytest.param("invalid", False, id="no_at_sign"),
pytest.param("@domain.com", False, id="missing_username"),
pytest.param("user@", False, id="missing_domain"),
])
def test_email_validation(input, expected):
result = validate_email(input)
assert result == expected
Test IDs show up in test output instead of parameter values, making failures easier to understand.
Step 4: Mocking External Dependencies
Real applications depend on external services: databases, APIs, file systems, third-party libraries. You don’t want your tests to call these services. Tests should run fast, work offline, and never modify production data.
Mocking replaces real dependencies with fake objects you control. Pytest’s built-in mock support comes from unittest.mock, but the pytest-mock plugin makes it cleaner.
Testing code that calls an API:
# src/weather.py
import requests
def get_temperature(city):
response = requests.get(f"https://api.weather.com/v1/city/{city}")
response.raise_for_status()
data = response.json()
return data["temperature"]
def should_bring_umbrella(city):
response = requests.get(f"https://api.weather.com/v1/city/{city}")
response.raise_for_status()
data = response.json()
return data["precipitation_chance"] > 50
Testing this without mocking would make real HTTP requests. That’s slow, requires internet, and breaks when the API is down. Mock it instead:
# tests/test_weather.py
import pytest
from src.weather import get_temperature, should_bring_umbrella
def test_get_temperature(mocker):
mock_response = mocker.Mock()
mock_response.json.return_value = {"temperature": 72}
mock_response.raise_for_status.return_value = None
mocker.patch("src.weather.requests.get", return_value=mock_response)
temp = get_temperature("Seattle")
assert temp == 72
def test_should_bring_umbrella_yes(mocker):
mock_response = mocker.Mock()
mock_response.json.return_value = {"precipitation_chance": 80}
mock_response.raise_for_status.return_value = None
mocker.patch("src.weather.requests.get", return_value=mock_response)
result = should_bring_umbrella("Portland")
assert result == True
def test_should_bring_umbrella_no(mocker):
mock_response = mocker.Mock()
mock_response.json.return_value = {"precipitation_chance": 20}
mock_response.raise_for_status.return_value = None
mocker.patch("src.weather.requests.get", return_value=mock_response)
result = should_bring_umbrella("Phoenix")
assert result == False
The mocker fixture comes from pytest-mock. It patches requests.get() to return your mock response instead of making a real HTTP request. Your test controls exactly what data the function receives.
You can verify that your code called the mocked function with the right arguments:
def test_api_called_with_correct_city(mocker):
mock_get = mocker.patch("src.weather.requests.get")
mock_response = mocker.Mock()
mock_response.json.return_value = {"temperature": 65}
mock_get.return_value = mock_response
get_temperature("Boston")
mock_get.assert_called_once_with("https://api.weather.com/v1/city/Boston")
This verifies behavior, not just return values. If someone refactors the URL format, this test catches it.
Mock exceptions to test error handling:
def test_api_failure_handling(mocker):
mock_get = mocker.patch("src.weather.requests.get")
mock_get.side_effect = requests.RequestException("Network error")
with pytest.raises(requests.RequestException):
get_temperature("Denver")
Create reusable mock fixtures for common scenarios:
@pytest.fixture
def mock_weather_api(mocker):
mock_response = mocker.Mock()
mock_get = mocker.patch("src.weather.requests.get", return_value=mock_response)
return mock_get, mock_response
def test_with_fixture(mock_weather_api):
mock_get, mock_response = mock_weather_api
mock_response.json.return_value = {"temperature": 68}
temp = get_temperature("Austin")
assert temp == 68
When mocking, patch where the function is used, not where it’s defined. If weather.py imports requests, patch src.weather.requests.get, not requests.get. The import creates a local reference, and you need to patch that reference.
Step 5: Test Coverage and CI Integration
Writing tests is one piece. Knowing which code your tests cover is the other. The pytest-cov plugin measures coverage and identifies untested code.
Run tests with coverage:
pytest --cov=src tests/
Output shows coverage percentages per file:
---------- coverage: platform darwin, python 3.11.4 -----------
Name Stmts Miss Cover
-------------------------------------------
src/calculator.py 10 0 100%
src/cart.py 15 2 87%
src/weather.py 12 3 75%
-------------------------------------------
TOTAL 37 5 86%
This tells you cart.py has two uncovered statements and weather.py has three. Generate an HTML report to see exactly which lines are missing:
pytest --cov=src --cov-report=html tests/
Open htmlcov/index.html in a browser. Red highlighting shows uncovered lines, green shows covered lines. This makes finding gaps obvious.
Coverage below 80% suggests missing tests. Coverage above 95% is good. Aiming for 100% coverage often wastes time on trivial code, but for critical modules like authentication or payment processing, 100% coverage is worth the effort.
Add coverage requirements to catch regressions:
pytest --cov=src --cov-fail-under=80 tests/
This command fails if coverage drops below 80%. Use it in CI to enforce minimum coverage standards.
Configure coverage in pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=src --cov-report=term-missing --cov-report=html"
[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*", "*/__init__.py"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:",
]
Now running pytest automatically includes coverage without typing extra flags.
Integrate tests into GitHub Actions:
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install pytest pytest-cov pytest-mock
pip install -r requirements.txt
- name: Run tests
run: pytest --cov=src --cov-fail-under=80
- name: Upload coverage reports
uses: codecov/codecov-action@v3
This workflow runs on every push and pull request. Tests run automatically, and if they fail, the commit is marked as failing. Pull requests with failing tests stand out, making code review easier.
For GitLab, use .gitlab-ci.yml:
test:
image: python:3.11
script:
- pip install pytest pytest-cov pytest-mock
- pip install -r requirements.txt
- pytest --cov=src --cov-fail-under=80
coverage: '/TOTAL.*\s+(\d+%)$/'
Running tests in CI catches issues before they reach production. Developers push code, CI runs tests, failures get caught within minutes. This feedback loop is the difference between shipping bugs and shipping confidence.
Common Pitfalls
Tests that depend on execution order are fragile. Pytest runs tests in any order, and parallel test runners shuffle execution. If test_b only passes after test_a runs, you’re testing the wrong thing.
Avoid shared mutable state between tests. If two tests modify the same global variable or database record, one test’s side effects break the other. Use fixtures to create fresh state for each test.
Don’t test implementation details. If you refactor a function to use a different algorithm but keep the same interface, tests shouldn’t break. Test what the function does, not how it does it.
# Bad: testing implementation
def test_uses_bubble_sort():
result = sort([3, 1, 2])
assert bubble_sort_was_called() # Breaks when switching to quicksort
# Good: testing behavior
def test_sorts_numbers():
result = sort([3, 1, 2])
assert result == [1, 2, 3] # Still passes after refactoring
Slow tests kill productivity. If your test suite takes 10 minutes to run, developers stop running it. Keep unit tests fast by mocking I/O. Save slow integration tests for CI.
Missing error cases leaves your code vulnerable. Happy path tests are easy to write, but errors happen in production. Test invalid inputs, network failures, missing files, and boundary conditions.
Hard-coded test data makes tests brittle. If your test expects exactly 5 items in a response, it breaks when the API returns 6. Test properties instead of exact values where possible.
# Brittle
def test_api_response():
data = fetch_data()
assert len(data) == 5 # Breaks when API changes
# Flexible
def test_api_response():
data = fetch_data()
assert len(data) > 0
assert all("id" in item for item in data)
Summary
Testing is not optional. Tests catch bugs, document behavior, and give you confidence to refactor code without fear. Pytest makes testing approachable by removing boilerplate and letting you write tests that look like regular Python.
Start with simple tests. Verify that functions return expected values and raise appropriate errors. Use fixtures to handle setup and teardown without copy-pasting code across tests. Parametrize tests to cover multiple inputs without writing redundant test functions.
Mock external dependencies so tests run fast and don’t require network access or live databases. Measure coverage to find untested code, then write tests to close the gaps. Integrate testing into CI so every commit gets validated automatically.
The patterns in this tutorial work for projects of any size. A small script benefits from a few basic tests. A production application needs fixtures, parametrization, mocking, and continuous integration. Start small, add complexity as needed, and keep your tests simple enough that writing them feels natural.
Good tests save more time than they cost. The hour you spend writing tests today saves the hours you’d spend debugging production issues next month. Make testing a habit, and your code will thank you for it.
Discussion
Leave a comment
No comments yet
Be the first to start the conversation.