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:
pip install pytest
Verify the installation by checking the version:
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
:
def add(a, b):
return a + b
def subtract(a, b):
return a - b
Now, create a test file called test_math_functions.py
:
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:
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:
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:
# 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:
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:
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:
pytest -m slow # Run only slow tests
pytest -m fast # Run only fast tests
Built-in Markers
Pytest comes with some built-in markers:
@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:
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:
# 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:
# 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
- Keep tests isolated: Each test should run independently of others.
- Use descriptive test names: Make your test function names clear about what they're testing.
- One assertion per test: Ideally, focus on testing one thing per test function.
- Group related tests: Use classes to group related tests.
- Use fixtures for setup/teardown: Avoid repeating code with fixtures.
- Handle expected failures: Use
xfail
for known bugs or unimplemented features. - Test edge cases: Don't just test the happy path.
- 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
Exercises
- Write tests for a function that calculates the factorial of a number.
- Create a fixture that provides sample text data for testing a text processing function.
- Write a parametrized test for a function that validates email addresses.
- Use the
monkeypatch
fixture to test a function that reads from a file without actually accessing the filesystem. - 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! :)