The fastest way to start testing Python with pytest is: pip install pytest, create a file named test_something.py, write a function def test_...(): containing a plain assert, and run pytest in the project directory. Pytest automatically discovers any file matching test_*.py or *_test.py and any function prefixed with test_, runs each one, and reports the exact assertion, the expected value, and the actual value for every failure — no configuration file, no test-runner class, and no boilerplate required. That single convention-over-configuration loop is what separates pytest from Python's built-in unittest.
That is the summary an AI overview gives you. Here is what it cannot show you: the actual shape of the write-run-fix loop as a workflow, a side-by-side of when each pytest feature earns its place, and a copy-paste starter suite you can drop into a real project today. The animated diagram below traces a single test from red to green, and the tables that follow tell you which feature to reach for instead of listing all of them.
The pytest workflow, red to green
Testing is a loop, not a one-time step. You write a failing test, make it pass, then refactor with the safety net in place. This is the rhythm every pytest suite follows:
The failure report at stage 3 is pytest's real value. Because it rewrites your assert statements at import time (assertion introspection), you get assert 7 == 5 expanded into the actual and expected values automatically — no assertEqual calls, no manual print debugging.
Why Unit Testing Matters
Unit testing transforms software development from a manual, error-prone process into an automated, reliable workflow. When you manually test by running functions and checking outputs, you're already doing unit testing—pytest simply automates and scales this process.
Benefits of Automated Testing
-
Early Bug Detection: Catch issues before they reach production
-
Regression Prevention: Ensure new changes don't break existing functionality
-
Code Confidence: Make changes with assurance that everything still works
-
Documentation: Tests serve as living documentation of how code should behave
-
Faster Development: Spend less time manual testing, more time building features
Key Insight: As projects grow, changes in one function can break seemingly unrelated parts of your application. Automated testing catches these hidden dependencies that manual testing would miss.
Installing and Setting Up Pytest
Getting started with pytest is straightforward. The framework handles most of the testing infrastructure, allowing you to focus on writing meaningful tests rather than boilerplate code.
Installation
# Install pytest using pip
pip install pytest
# Verify installation
pytest --version
# Install with additional plugins (optional)
pip install pytest pytest-cov pytest-html
Project Structure Best Practices
# Recommended project structure
my_project/
├── src/
│ ├── __init__.py
│ └── mymath.py
├── tests/
│ ├── __init__.py
│ └── test_mymath.py
├── requirements.txt
└── pytest.ini
Writing Your First Unit Tests
Let's create a practical example with mathematical functions to demonstrate pytest fundamentals. We'll build functions and their corresponding tests to show how automated testing works.
Creating the Source Code
First, create a file called mymath.py with basic mathematical functions:
# mymath.py
def add_numbers(x, y):
"""Add two numbers together."""
answer = x + y
return answer
def subtract_numbers(x, y):
"""Subtract y from x."""
answer = x - y
return answer
def multiply_numbers(x, y):
"""Multiply two numbers."""
answer = x * y
return answer
def divide_numbers(x, y):
"""Divide x by y."""
if y == 0:
raise ValueError("Cannot divide by zero")
return x / y
def calculate_average(numbers):
"""Calculate the average of a list of numbers."""
if not numbers:
raise ValueError("Cannot calculate average of empty list")
return sum(numbers) / len(numbers)
Writing the Tests
Now create test_mymath.py with comprehensive tests for each function:
# test_mymath.py
import pytest
import mymath
def test_add_numbers():
"""Test addition function with positive numbers."""
assert mymath.add_numbers(2, 3) == 5
assert mymath.add_numbers(0, 0) == 0
assert mymath.add_numbers(-1, 1) == 0
def test_subtract_numbers():
"""Test subtraction function."""
assert mymath.subtract_numbers(5, 3) == 2
assert mymath.subtract_numbers(0, 0) == 0
assert mymath.subtract_numbers(-1, -1) == 0
def test_multiply_numbers():
"""Test multiplication function."""
assert mymath.multiply_numbers(2, 3) == 6
assert mymath.multiply_numbers(0, 5) == 0
assert mymath.multiply_numbers(-2, 3) == -6
def test_divide_numbers():
"""Test division function including edge cases."""
assert mymath.divide_numbers(6, 2) == 3
assert mymath.divide_numbers(5, 2) == 2.5
# Test division by zero raises exception
with pytest.raises(ValueError, match="Cannot divide by zero"):
mymath.divide_numbers(5, 0)
def test_calculate_average():
"""Test average calculation function."""
assert mymath.calculate_average([1, 2, 3, 4, 5]) == 3
assert mymath.calculate_average([10]) == 10
assert mymath.calculate_average([2, 4]) == 3
# Test empty list raises exception
with pytest.raises(ValueError, match="Cannot calculate average of empty list"):
mymath.calculate_average([])
Running Your Tests
# Run all tests
pytest
# Run specific test file
pytest test_mymath.py
# Run with verbose output
pytest -v
# Run specific test function
pytest test_mymath.py::test_add_numbers
# Run tests with coverage report
pytest --cov=mymath
Understanding Pytest Behavior
Pytest uses intelligent discovery rules to automatically find and run your tests. Understanding these conventions helps you organize your test code effectively.
Test Discovery Rules
-
File naming: Files prefixed with
test_or suffixed with_test.py -
Function naming: Functions prefixed with
test_ -
Class naming: Classes prefixed with
Test(no constructor) -
Directory scanning: Recursively searches current directory and subdirectories
Demonstrating Test Failures
Let's intentionally introduce a bug to see how pytest catches failures. Modify the add_numbers function:
# Modified function with intentional bug
def add_numbers(x, y):
"""Add two numbers together."""
answer = x + y + x # Bug: extra +x added
return answer
When you run pytest now, you'll see a detailed failure report showing:
-
Expected result: 5
-
Actual result: 7
-
Exact line where the assertion failed
-
Clear error message indicating the problem
This demonstrates the power of automated testing: pytest immediately caught the bug that could have been missed in manual testing, especially in larger codebases.
Advanced Pytest Features
As your testing needs grow, pytest offers powerful features for managing complex test scenarios, shared setup, and parameterized testing.
Fixtures for Setup and Teardown
# conftest.py - Shared fixtures
import pytest
@pytest.fixture
def sample_data():
"""Provide sample data for tests."""
return [1, 2, 3, 4, 5]
@pytest.fixture
def empty_list():
"""Provide empty list for testing edge cases."""
return []
@pytest.fixture
def calculator():
"""Provide a calculator instance for testing."""
return Calculator()
# Using fixtures in tests
def test_average_with_fixture(sample_data):
"""Test average calculation using fixture data."""
result = mymath.calculate_average(sample_data)
assert result == 3
def test_empty_average_with_fixture(empty_list):
"""Test average with empty list using fixture."""
with pytest.raises(ValueError):
mymath.calculate_average(empty_list)
Parameterized Testing
# Parameterized tests for comprehensive coverage
@pytest.mark.parametrize("x,y,expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(10, -5, 5),
(1.5, 2.5, 4.0),
])
def test_add_numbers_parameterized(x, y, expected):
"""Test addition with multiple parameter sets."""
assert mymath.add_numbers(x, y) == expected
@pytest.mark.parametrize("numbers,expected", [
([1, 2, 3], 2),
([10], 10),
([2, 4, 6], 4),
([1, 1, 1, 1], 1),
])
def test_average_parameterized(numbers, expected):
"""Test average calculation with various inputs."""
assert mymath.calculate_average(numbers) == expected
Test Organization with Classes
# Organizing related tests in classes
class TestBasicMath:
"""Test basic mathematical operations."""
def test_addition(self):
assert mymath.add_numbers(2, 3) == 5
def test_subtraction(self):
assert mymath.subtract_numbers(5, 3) == 2
def test_multiplication(self):
assert mymath.multiply_numbers(2, 3) == 6
class TestAdvancedMath:
"""Test advanced mathematical operations."""
def test_division_normal(self):
assert mymath.divide_numbers(6, 2) == 3
def test_division_by_zero(self):
with pytest.raises(ValueError):
mymath.divide_numbers(5, 0)
def test_average_calculation(self):
assert mymath.calculate_average([1, 2, 3]) == 2
Which feature should I reach for?
Pytest gives you five main tools. Reaching for the wrong one is how test suites become slow, brittle, or unreadable. Use this table to pick:
| Feature | What it does | Reach for it when | Skip it when |
|---|---|---|---|
Plain assert | Validates a single expected value | Testing a pure function with one clear output | You need setup/teardown or many input rows |
Fixtures (@pytest.fixture) | Reusable setup/teardown injected by name | Tests share a DB connection, temp file, or sample data | The setup is one trivial literal used in one test |
Parametrization (@pytest.mark.parametrize) | Runs one test across many input rows | Same logic, many input/output pairs to cover | Each case needs genuinely different assertions |
Test classes (class Test...) | Groups related tests under one namespace | A large suite you want to organize and filter | You only have a handful of loose functions |
pytest.raises | Asserts a call raises a specific exception | Verifying error handling and guard clauses | The function is not supposed to raise anything |
pytest-cov | Reports which lines/branches ran | Finding untested code paths before a release | You would chase 100% at the cost of real assertions |
Rule of thumb: start every test as a plain assert. Promote it to a fixture only when a second test needs the same setup, and to a parametrized test only when a second input needs the same logic. Do not reach for classes or plugins until the flat approach actually hurts.
Testing Best Practices
Effective testing requires more than just writing tests—it requires writing the right tests with clear, maintainable code. These practices will help you create a robust testing foundation.
-
Test one thing at a time: Each test should verify a single behavior
-
Use descriptive test names: Test names should explain what is being tested
-
Include edge cases: Test boundary conditions, empty inputs, and error cases
-
Keep tests independent: Tests should not depend on other tests
-
Use fixtures wisely: Share setup code but avoid overly complex fixtures
-
Test behavior, not implementation: Focus on what the function does, not how
-
Aim for good coverage: Test critical paths and error conditions
# Example of comprehensive testing approach
def test_divide_numbers_comprehensive():
"""Comprehensive test covering normal operation and edge cases."""
# Normal division
assert mymath.divide_numbers(10, 2) == 5
assert mymath.divide_numbers(7, 2) == 3.5
# Division resulting in negative
assert mymath.divide_numbers(-10, 2) == -5
assert mymath.divide_numbers(10, -2) == -5
# Division by one
assert mymath.divide_numbers(42, 1) == 42
# Division of zero
assert mymath.divide_numbers(0, 5) == 0
# Error case: division by zero
with pytest.raises(ValueError, match="Cannot divide by zero"):
mymath.divide_numbers(10, 0)
Remember: Good tests serve as documentation. A well-written test should clearly communicate the expected behavior of your code to other developers (including future you).
New-project pytest checklist
Use this as a green-field setup pass. Everything here takes under five minutes and pays for itself the first time a refactor breaks something silently.
- Create and activate a virtual environment:
python -m venv .venv && source .venv/bin/activate - Install the framework:
pip install pytest pytest-cov - Pin it in
requirements.txt(orpyproject.toml) so CI installs the same version - Create a
tests/directory with an__init__.pyand at least onetest_*.pyfile - Write one failing test first and confirm pytest reports it red — this proves discovery works
- Add a
conftest.pyfor any fixture used by more than one test - Cover the happy path and at least one error case per public function (
pytest.raises) - Run
pytest --cov=your_packageand look for untested branches, not just a percentage - Wire
pytestinto CI so every push runs the suite (GitHub Actions example) - Add
-xlocally (pytest -x) so the run stops at the first failure while you fix it
Once this loop is in place, testing stops being a chore you do at the end and becomes the feedback signal that lets you change code without fear.