
Advanced Python Unit Testing with PyTest and Fixtures
Setting Up a PyTest Project
To begin, install PyTest using pip:
pip install pytestCreate a test file, typically named test_*.py, and place it in a tests/ directory adjacent to your source code. For example, if you have a module calculator.py, create a file test_calculator.py.
Writing Basic Tests with PyTest
PyTest test functions are simple Python functions prefixed with test_. Here’s an example:
# test_calculator.py
def add(a, b):
return a + b
def test_add_integers():
assert add(2, 3) == 5
def test_add_strings():
assert add("Hello, ", "world!") == "Hello, world!"Run the tests with:
pytest test_calculator.pyThis shows the core of PyTest’s design: minimal boilerplate and readable test functions.
Using Fixtures for Test Setup and Teardown
Fixtures in PyTest are functions that provide a fixed baseline for tests. They are especially useful for setting up test data, initializing objects, or managing database connections. Fixtures are declared using the @pytest.fixture decorator.
Example: Using a Simple Fixture
import pytest
@pytest.fixture
def sample_data():
return [1, 2, 3, 4, 5]
def test_sum(sample_data):
assert sum(sample_data) == 15Here, sample_data is a reusable fixture that returns a list of integers used in multiple tests.
Scope and Lifetime of Fixtures
Fixtures can be scoped to function, class, module, or session levels. The default scope is function, but you can change it using the scope parameter:
@pytest.fixture(scope="module")
def expensive_resource():
print("Setting up expensive resource")
return "resource"
@pytest.fixture
def use_resource(expensive_resource):
return f"Using {expensive_resource}"In this case, expensive_resource is initialized once per module, reducing setup time.
Parametrizing Tests
PyTest supports test parametrization, allowing the same test function to run with multiple sets of input arguments. This is particularly useful for testing edge cases or multiple scenarios.
Example: Parametrized Tests
import pytest
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(1.5, 2.5, 4.0),
])
def test_add(a, b, expected):
assert add(a, b) == expectedThe @pytest.mark.parametrize decorator runs the test_add function with all provided input combinations.
Mocking with Fixtures
PyTest integrates well with the unittest.mock module to simulate external dependencies. This is essential for testing functions that interact with databases, APIs, or system calls without relying on them.
Example: Mocking a File Read
from unittest.mock import mock_open, patch
import pytest
def read_file(path):
with open(path, 'r') as f:
return f.read()
@patch("builtins.open", new_callable=mock_open, read_data="mocked content")
def test_read_file(mock_file):
content = read_file("dummy.txt")
assert content == "mocked content"Fixtures for Dependency Injection
Fixtures can be used as arguments in test functions to inject dependencies. This is powerful for dependency injection and test isolation.
Example: Using Multiple Fixtures
@pytest.fixture
def user_data():
return {"name": "Alice", "age": 30}
@pytest.fixture
def formatted_user(user_data):
return f"{user_data['name']} ({user_data['age']})"
def test_formatted_user(formatted_user):
assert formatted_user == "Alice (30)"Best Practices
| Practice | Description |
|---|---|
| Use descriptive fixture names | Avoid generic names like data or setup. Use user_profile or config_data instead. |
| Favor scoped fixtures | Use module or session scope when setup is expensive. |
| Parametrize edge cases | Don’t forget to test with 0, None, or negative values. |
| Avoid global state | Prefer fixtures over global variables for test isolation. |
| Keep tests small | Each test should focus on a single behavior. |
