Table of Contents
- What is Unit Testing?
- Why Choose pytest?
- Getting Started with pytest
- Writing Effective Test Cases
- Advanced pytest Features
- Organizing Test Code
- Integrating with Other Tools
- Best Practices for pytest
- Conclusion
- References
1. What is Unit Testing?
Unit testing is a software testing technique where individual “units” of code—such as functions, methods, or classes—are tested in isolation. The goal is to validate that each unit works as expected under various conditions, including edge cases and invalid inputs.
Benefits of Unit Testing:
- Early Bug Detection: Catches issues before they propagate to larger parts of the codebase.
- Documentation: Tests serve as live documentation, showing how code is intended to be used.
- Refactoring Safety: Ensures code changes (e.g., optimizing, rewriting) don’t break existing functionality.
- Confidence: Reduces fear of modifying code, enabling faster iteration.
2. Why Choose pytest?
Python’s standard library includes unittest, a framework inspired by Java’s JUnit. While functional, unittest requires boilerplate (e.g., subclassing unittest.TestCase, using self.assert* methods like self.assertEqual(a, b)).
pytest simplifies this with:
- Minimal Boilerplate: Write tests as plain Python functions with
assertstatements (no need for classes orself). - Rich Assertions: pytest enhances
assertmessages to show detailed failures (e.g., why5 == 3failed), unlikeunittest’s crypticAssertionError. - Powerful Plugins: Extend functionality with plugins like
pytest-cov(coverage reports),pytest-mock(mocking), andpytest-django(Django integration). - Advanced Features: Built-in support for parametrized testing, fixtures (reusable setup/teardown), and test selection.
3. Getting Started with pytest
Installation
pytest is available on PyPI. Install it via pip:
pip install pytest
Verify the installation:
pytest --version
# Output: pytest 7.4.0 (or similar)
Your First Test Case
Let’s start with a simple example. Suppose we have a function add in math_utils.py:
# math_utils.py
def add(a: int, b: int) -> int:
return a + b
To test add, create a test file (conventionally named test_math_utils.py) with a test function:
# test_math_utils.py
from math_utils import add
def test_add_positive_numbers():
result = add(2, 3)
assert result == 5 # Plain Python assertion
def test_add_negative_numbers():
result = add(-1, -1)
assert result == -2
Running Tests
Run tests from the command line with:
pytest
pytest automatically discovers and runs tests. Here’s sample output:
============================= test session starts ==============================
collected 2 items
test_math_utils.py .. [100%]
============================== 2 passed in 0.01s ===============================
.indicates a passed test.Findicates a failed test (we’ll see this later).Eindicates an error (e.g., import failure).
4. Writing Effective Test Cases
Test Discovery Rules
pytest finds tests using these conventions:
- Files:
test_*.pyor*_test.py(e.g.,test_math_utils.py). - Functions/Methods: Named
test_*(e.g.,test_add_positive_numbers). - Classes: Named
Test*(no__init__method) withtest_*methods (e.g.,class TestAdd: def test_positive(self): ...).
This ensures pytest can automatically discover your tests without extra configuration.
Basic Assertions
pytest uses standard Python assert statements, but enhances them with detailed error messages. For example:
def test_add_invalid_input():
result = add("2", 3) # Passing a string instead of int
assert result == 5
Running this test will fail with:
E TypeError: unsupported operand type(s) for +: 'str' and 'int'
pytest also supports more complex assertions:
def test_string_contains():
assert "world" in "hello world" # Check membership
def test_list_length():
assert len([1, 2, 3]) == 3 # Check length
def test_dict_key():
assert "name" in {"name": "Alice", "age": 30} # Check dict key
Test Naming Conventions
Write descriptive test names to clarify intent. A good test name answers:
- What is being tested?
- What input is used?
- What is the expected outcome?
Bad: test_add(), test1().
Good: test_add_returns_sum_of_two_positive_integers(), test_add_raises_type_error_for_non_numeric_input().
5. Advanced pytest Features
Parametrized Testing
Test multiple inputs with a single test function using @pytest.mark.parametrize. This avoids repetitive code.
Example: Test add with different input pairs:
import pytest
from math_utils import add
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5), # Positive numbers
(-1, -1, -2), # Negative numbers
(0, 0, 0), # Zero
(10, -5, 5), # Mixed signs
])
def test_add_parametrized(a, b, expected):
assert add(a, b) == expected
Running this test will execute 4 sub-tests, one for each input tuple.
Fixtures: Reusable Setup/Teardown
Fixtures are reusable resources (e.g., database connections, test data) that set up/tear down state for tests. Use @pytest.fixture to define them.
Example: Test Data Fixture
import pytest
@pytest.fixture
def sample_numbers():
# Setup: Return test data
return [1, 2, 3, 4, 5]
def test_sum_sample_numbers(sample_numbers):
# Fixture is injected as an argument
assert sum(sample_numbers) == 15
Fixture Scope
Control how often a fixture is created with the scope parameter:
function(default): Created once per test function.class: Created once per test class.module: Created once per module (file).session: Created once per test session (all files).
Example: Database Fixture
@pytest.fixture(scope="module")
def db_connection():
# Setup: Connect to test database
conn = create_db_connection("test_db")
yield conn # Provide connection to tests
# Teardown: Close connection after module tests
conn.close()
def test_query_users(db_connection):
users = db_connection.query("SELECT * FROM users")
assert len(users) > 0
Markers: Tagging and Selecting Tests
Markers tag tests (e.g., slow, integration) to run subsets of tests.
Step 1: Define Markers
Register markers in pytest.ini (or pyproject.toml) to avoid warnings:
# pytest.ini
[pytest]
markers =
slow: Marks slow-running tests
integration: Marks integration tests (vs unit tests)
Step 2: Tag Tests
@pytest.mark.slow
def test_large_dataset_processing():
# Simulate slow test (e.g., processing 1M rows)
assert process_large_dataset() is True
@pytest.mark.integration
def test_api_call():
# Test external API
assert call_api() == "success"
Step 3: Run Tagged Tests
- Run only slow tests:
pytest -m slow - Run all tests except slow:
pytest -m "not slow"
Testing Exceptions
Use pytest.raises to verify that functions raise expected exceptions.
Example: Test that add raises TypeError for non-numeric inputs:
def test_add_raises_type_error():
with pytest.raises(TypeError) as exc_info:
add("2", 3) # Pass string and int
# Optional: Verify exception message
assert "unsupported operand type(s) for +: 'str' and 'int'" in str(exc_info.value)
6. Organizing Test Code
For larger projects, organize tests in a dedicated tests directory mirroring your source code structure. This makes it easy to locate tests for specific modules.
Recommended Project Structure:
my_project/
├── src/ # Source code
│ ├── math_utils.py
│ └── user_service.py
├── tests/ # Tests
│ ├── test_math_utils.py
│ └── test_user_service.py
├── pytest.ini # pytest configuration
└── requirements.txt # Dependencies
- Place source code in
src/(or a package likemy_project/). - Mirror
src/structure intests/(e.g.,tests/test_math_utils.pytestssrc/math_utils.py).
7. Integrating with Other Tools
Coverage Reports with pytest-cov
Track which lines of code are tested using pytest-cov, a plugin for pytest.
Installation:
pip install pytest-cov
Usage:
Run tests and generate a coverage report for your package (e.g., src/):
pytest --cov=src tests/
Sample output:
---------- coverage: platform linux, python 3.9.7 ----------
Name Stmts Miss Cover
----------------------------------------
src/math_utils.py 4 0 100%
src/user_service.py 10 2 80%
----------------------------------------
TOTAL 14 2 86%
Generate an HTML report for detailed line-by-line coverage:
pytest --cov=src --cov-report=html tests/
CI/CD Integration
Automate testing with CI/CD tools like GitHub Actions, GitLab CI, or Jenkins.
Example: GitHub Actions
Create .github/workflows/pytest.yml to run tests on every push:
name: Run Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.9"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pytest-cov
pip install -r requirements.txt
- name: Run tests with coverage
run: pytest --cov=src tests/
8. Best Practices for pytest
- Keep Tests Fast: Avoid slow operations (e.g., external API calls) in unit tests. Use mocks or fixtures for speed.
- Test One Behavior Per Test: A test should verify a single logic path (e.g., don’t test both success and failure in one test).
- Use Fixtures for Reusability: Extract重复的 setup/teardown code into fixtures (e.g., test data, database connections).
- Avoid Test Dependencies: Tests should run independently (order shouldn’t matter).
- Run Tests Frequently: Integrate pytest into your workflow (e.g., pre-commit hooks, CI/CD) to catch issues early.
9. Conclusion
pytest simplifies unit testing in Python with its minimal boilerplate, rich assertions, and advanced features like parametrization and fixtures. By adopting pytest, you’ll write more maintainable tests, catch bugs earlier, and build more reliable software.
Start small: Write tests for critical functions, then expand to use fixtures and parametrization. As your project grows, leverage plugins like pytest-cov and integrate with CI/CD to automate testing.