Skip to main content

Python Pytest Framework

Introduction

Testing is a critical part of software development that ensures your code works as expected. In the Python ecosystem, pytest has emerged as one of the most popular testing frameworks due to its simplicity, power, and extensibility.

Pytest makes it easy to write small, readable tests and scales well to support complex functional testing. It's more intuitive than Python's built-in unittest module and requires less boilerplate code, making it perfect for beginners and experienced developers alike.

In this guide, we'll explore how to use pytest to write effective tests for your Python applications.

Getting Started with Pytest

Installation

First, let's install pytest using pip:

bash
pip install pytest

Verify the installation by checking the version:

bash
pytest --version

Output:

pytest 7.4.0

Writing Your First Test

Let's create a simple function to test. Create a file called math_functions.py:

python
def add(a, b):
return a + b

def subtract(a, b):
return a - b

Now, create a test file called test_math_functions.py:

python
from math_functions import add, subtract

def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(-1, -1) == -2

def test_subtract():
assert subtract(5, 2) == 3
assert subtract(2, 5) == -3
assert subtract(0, 0) == 0

Notice a few important things:

  • Test files should start with test_ or end with _test.py
  • Test functions should also start with test_
  • We use the simple assert statement to check conditions

Running Tests

To run your tests, simply execute the pytest command in your terminal:

bash
pytest

Output:

=================== test session starts ===================
platform linux -- Python 3.9.7, pytest-7.4.0
collected 2 items

test_math_functions.py .. [100%]

==================== 2 passed in 0.01s ====================

The dots represent passing tests. If a test fails, you'll see an F instead and a detailed error message.

Test Organization

Test Discovery

Pytest automatically discovers tests by looking for:

  • Files named test_*.py or *_test.py
  • Functions prefixed with test_ inside these files
  • Classes prefixed with Test inside these files

Test Fixtures

Fixtures are a powerful feature in pytest that allow you to set up preconditions for your tests:

python
import pytest

@pytest.fixture
def sample_data():
return [1, 2, 3, 4, 5]

def test_sum(sample_data):
assert sum(sample_data) == 15

def test_average(sample_data):
assert sum(sample_data) / len(sample_data) == 3

The sample_data fixture is automatically passed to any test function that includes it as a parameter.

Shared Fixtures

For fixtures you want to share across multiple test files, create a conftest.py file:

python
# conftest.py
import pytest

@pytest.fixture
def db_connection():
# Set up a database connection
connection = create_connection()
yield connection
# Clean up after the test
connection.close()

Any test file in the same directory or subdirectory can use this fixture.

Advanced Pytest Features

Parametrized Tests

Instead of writing multiple test functions or multiple assertions, you can parametrize your tests:

python
import pytest
from math_functions import add

@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(5, 5, 10),
(-1, -1, -2),
(0, 0, 0),
])
def test_add_parametrized(a, b, expected):
assert add(a, b) == expected

This runs the test for each set of parameters, giving you better test isolation and clearer failure messages.

Marking Tests

You can categorize tests using markers:

python
import pytest

@pytest.mark.slow
def test_slow_operation():
# Some time-consuming test
pass

@pytest.mark.fast
def test_fast_operation():
# Quick test
pass

Now you can run specific tests by their markers:

bash
pytest -m slow  # Run only slow tests
pytest -m fast # Run only fast tests

Built-in Markers

Pytest comes with some built-in markers:

python
@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
pass

@pytest.mark.skipif(sys.version_info < (3, 8), reason="Requires Python 3.8+")
def test_new_feature():
pass

@pytest.mark.xfail(reason="Known bug")
def test_known_bug():
pass

Testing Exceptions

To test that a function raises an exception:

python
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b

def test_divide_by_zero():
with pytest.raises(ValueError) as excinfo:
divide(10, 0)
assert "Cannot divide by zero" in str(excinfo.value)

Real-World Example: Testing a Simple API Client

Let's build and test a simple weather API client as a practical example:

python
# weather_client.py
import requests

class WeatherClient:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://api.example.com/weather"

def get_current_temperature(self, city):
if not city:
raise ValueError("City cannot be empty")

params = {"city": city, "api_key": self.api_key}
response = requests.get(self.base_url, params=params)

if response.status_code == 200:
data = response.json()
return data["temperature"]
elif response.status_code == 404:
return None
else:
response.raise_for_status()

Now, let's write tests for this client. We'll use the monkeypatch fixture to avoid making real API calls:

python
# test_weather_client.py
import pytest
from weather_client import WeatherClient

class MockResponse:
def __init__(self, status_code, json_data):
self.status_code = status_code
self._json_data = json_data

def json(self):
return self._json_data

def raise_for_status(self):
if self.status_code >= 400:
raise requests.HTTPError(f"HTTP Error: {self.status_code}")

@pytest.fixture
def weather_client():
return WeatherClient("fake_api_key")

def test_get_current_temperature(monkeypatch, weather_client):
# Create a mock response for our API call
def mock_get(*args, **kwargs):
return MockResponse(200, {"temperature": 25.5})

# Apply the monkeypatch to replace requests.get with our mock function
monkeypatch.setattr("requests.get", mock_get)

# Now we can test our client without making real API calls
temperature = weather_client.get_current_temperature("London")
assert temperature == 25.5

def test_city_not_found(monkeypatch, weather_client):
def mock_get(*args, **kwargs):
return MockResponse(404, {})

monkeypatch.setattr("requests.get", mock_get)

temperature = weather_client.get_current_temperature("NonExistentCity")
assert temperature is None

def test_empty_city(weather_client):
with pytest.raises(ValueError) as excinfo:
weather_client.get_current_temperature("")
assert "City cannot be empty" in str(excinfo.value)

This example demonstrates several pytest features:

  • Custom fixtures
  • Mocking external services
  • Testing normal behavior and exceptions
  • Parametrization techniques

Best Practices for Pytest

  1. Keep tests isolated: Each test should run independently of others.
  2. Use descriptive test names: Make your test function names clear about what they're testing.
  3. One assertion per test: Ideally, focus on testing one thing per test function.
  4. Group related tests: Use classes to group related tests.
  5. Use fixtures for setup/teardown: Avoid repeating code with fixtures.
  6. Handle expected failures: Use xfail for known bugs or unimplemented features.
  7. Test edge cases: Don't just test the happy path.
  8. Use CI integration: Run tests automatically on code changes.

Summary

In this guide, we've covered:

  • Setting up and running pytest
  • Writing simple test functions
  • Organizing tests with fixtures
  • Advanced features like parametrization and markers
  • Testing exceptions and dealing with external dependencies
  • A real-world testing example

Pytest is a powerful yet simple testing framework that can help you ensure your Python code works correctly. Its minimal syntax, powerful fixtures, and extensive plugin ecosystem make it a great choice for projects of any size.

Additional Resources

  1. Pytest Official Documentation
  2. Pytest Cheat Sheet
  3. Python Testing with pytest by Brian Okken (book)

Exercises

  1. Write tests for a function that calculates the factorial of a number.
  2. Create a fixture that provides sample text data for testing a text processing function.
  3. Write a parametrized test for a function that validates email addresses.
  4. Use the monkeypatch fixture to test a function that reads from a file without actually accessing the filesystem.
  5. Implement a more complex test suite for a class that manages a to-do list, with fixtures for different starting states.


If you spot any mistakes on this website, please let me know at feedback@compilenrun.com. I’d greatly appreciate your feedback! :)