Stage 7 · Master
Python for Operators
Testing with pytest
Unit tests, fixtures, parametrize, mocking, and CI integration.
pytest Over unittest
pytest is the standard testing framework for Python. It is simpler, more powerful, and more readable than unittest. Use pytest for everything — unit tests, integration tests, and end-to-end tests.
pytest finds tests by looking for files named test_*.py or *_test.py, then looks for functions and methods starting with test_. No test classes or runners required.
Writing Tests
# tests/test_utils.py
from myapp.utils import parse_server_list, format_bytes
def test_parse_server_list():
servers = parse_server_list("web-01,web-02,web-03")
assert servers == ["web-01", "web-02", "web-03"]
def test_parse_server_list_empty():
assert parse_server_list("") == []
def test_format_bytes():
assert format_bytes(1024) == "1.0 KB"
assert format_bytes(1_048_576) == "1.0 MB"
assert format_bytes(1_073_741_824) == "1.0 GB"pytest assertions give detailed failure output. If assert format_bytes(1024) == '1 KB' fails, pytest shows the actual value and the expected value.
Fixtures
Fixtures are pytest's dependency injection system. They set up test data, database connections, or mock services. Fixtures are defined with @pytest.fixture and can be reused across tests.
# tests/conftest.py
import pytest
from pathlib import Path
@pytest.fixture
def sample_config(tmp_path):
"""Create a temporary config file for testing."""
config = tmp_path / "config.yaml"
config.write_text("""
host: 0.0.0.0
port: 8080
workers: 4
""".strip(), encoding="utf-8")
return config
@pytest.fixture
def mock_server(monkeypatch):
"""Mock a server connection."""
def fake_connect(host, port):
return {"status": "connected", "host": host}
monkeypatch.setattr("myapp.server.connect", fake_connect)
return fake_connect
# tests/test_config.py
def test_load_config(sample_config):
from myapp.config import load
config = load(sample_config)
assert config["host"] == "0.0.0.0"
assert config["port"] == 8080Fixtures are yielded by name — just add the fixture name as a parameter to your test function. pytest handles setup and teardown automatically.
Parametrize
import pytest
@pytest.mark.parametrize("input_val,expected", [
("192.168.1.1", True),
("10.0.0.1", True),
("256.1.1.1", False),
("not-an-ip", False),
("", False),
])
def test_is_valid_ip(input_val, expected):
from myapp.network import is_valid_ip
assert is_valid_ip(input_val) == expected
# Run the same test with different environments
@pytest.mark.parametrize("env,expected_port", [
("production", 443),
("staging", 8080),
("development", 5000),
])
def test_default_port(env, expected_port):
from myapp.config import get_default_port
assert get_default_port(env) == expected_portparametrize creates a separate test case for each set of inputs. pytest shows which specific input failed in the test output.
Mocking
Mocking replaces real dependencies — network calls, APIs, databases — with controlled fakes. This makes tests fast, deterministic, and free of external dependencies.
from unittest.mock import patch, MagicMock
import pytest
def test_fetch_server_status():
"""Test status check without making real HTTP calls."""
with patch("myapp.server.requests.get") as mock_get:
mock_get.return_value = MagicMock(
status_code=200,
json=lambda: {"status": "healthy", "uptime": 99.9},
)
from myapp.server import fetch_status
result = fetch_status("https://api.example.com")
assert result["status"] == "healthy"
mock_get.assert_called_once_with(
"https://api.example.com",
timeout=5,
)
@pytest.fixture
def mock_subprocess(monkeypatch):
"""Replace subprocess.run with a mock."""
mock_run = MagicMock()
mock_run.return_value = MagicMock(
returncode=0,
stdout="pod/nginx-abc123 running",
stderr="",
)
monkeypatch.setattr("subprocess.run", mock_run)
return mock_runUse patch as a context manager or as a decorator. monkeypatch is pytest's built-in fixture for replacing attributes and modules.
CI Integration
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: pytest --cov=myapp --cov-report=xml -v
- uses: codecov/codecov-action@v4
with:
file: coverage.xmlpytest-cov generates coverage reports. --cov=myapp measures coverage for your package. --cov-report=xml produces a file that Codecov can parse.
# Run all tests
pytest
# Run with verbose output
pytest -v
# Run a specific test file
pytest tests/test_config.py
# Run tests matching a pattern
pytest -k "test_parse"
# Run with coverage
pytest --cov=myapp --cov-report=term-missing
# Stop on first failure
pytest -xUse pytest -x during development to stop on the first failure. Use --cov to measure coverage. Use -k to run a subset of tests.
@pytest.mark.slow marks tests that take a long time. Run them separately with pytest -m slow. This keeps your fast CI pipeline fast.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.