Testing with pytest
pytest is the de facto standard testing framework for Python, favored over the built-in unittest for its simpler syntax and powerful features.
Installation and Basic Test
pip install pytest# test_math_utils.py
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0pytest # discovers and runs all test_*.py / *_test.py files automatically
pytest test_math_utils.py # run a specific file
pytest -v # verbose output, shows each test name
pytest -k "add" # run only tests matching a keywordTest Discovery Rules
pytest automatically finds:
- Files named
test_*.pyor*_test.py - Functions named
test_*inside those files - Classes named
Test*(without an__init__), with methods namedtest_*
Assertions
pytest uses plain assert statements, no special assertion methods needed like in unittest.
def test_examples():
assert 1 + 1 == 2
assert "hello".upper() == "HELLO"
assert [1, 2, 3] == [1, 2, 3]
assert isinstance(5, int)
assert 5 in [1, 2, 5]Testing for Exceptions
import pytest
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_divide_by_zero_raises():
with pytest.raises(ValueError):
divide(10, 0)
def test_divide_by_zero_message():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)Fixtures: Reusable Setup
import pytest
@pytest.fixture
def sample_data():
return {"name": "Bob", "age": 25}
def test_name(sample_data): # fixture injected automatically by NAME matching
assert sample_data["name"] == "Bob"
def test_age(sample_data):
assert sample_data["age"] == 25Fixtures with Setup/Teardown
@pytest.fixture
def db_connection():
conn = create_connection() # setup
yield conn # test runs here
conn.close() # teardown, runs after the test regardless of pass/fail
def test_query(db_connection):
result = db_connection.query("SELECT 1")
assert result is not NoneFixture Scope
@pytest.fixture(scope="function") # default: fresh instance for EVERY test
@pytest.fixture(scope="module") # shared across all tests in one file
@pytest.fixture(scope="session") # shared across the ENTIRE test run
def expensive_resource():
return create_expensive_thing()Parametrized Tests: Running One Test with Many Inputs
import pytest
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(100, 200, 300),
])
def test_add(a, b, expected):
assert add(a, b) == expectedParametrize instead of copy-pasting near-identical tests
This produces 4 separate, individually reported test cases from one function definition, and makes it trivial to add new edge cases without duplicating test logic.
Mocking External Dependencies
from unittest.mock import patch, MagicMock
def get_weather(api_client):
return api_client.fetch("weather")
def test_get_weather():
mock_client = MagicMock()
mock_client.fetch.return_value = {"temp": 25}
result = get_weather(mock_client)
assert result == {"temp": 25}
mock_client.fetch.assert_called_once_with("weather")
@patch("my_module.requests.get") # patches requests.get wherever it's used in my_module
def test_api_call(mock_get):
mock_get.return_value.json.return_value = {"status": "ok"}
...Organizing Tests
project/
src/
my_package/
calculator.py
tests/
test_calculator.py
conftest.py # shared fixtures available to ALL test files automatically
Test Coverage
pip install pytest-cov
pytest --cov=my_package --cov-report=term-missingCoverage percentage is a floor, not a target
100% coverage does not mean bug-free code, it only means every LINE ran at least once during tests, not that every meaningful CASE or edge condition was checked. Treat coverage reports as a tool for finding untested code paths, not a quality certificate.
AAA Pattern: Arrange, Act, Assert
def test_withdraw_reduces_balance():
# Arrange
account = BankAccount(balance=100)
# Act
account.withdraw(30)
# Assert
assert account.balance == 70Keep this structure visible in every test
Even without explicit comments, structuring tests as setup, action, then verification makes them dramatically easier to read and debug when they fail.