Lesson 08 of 9 · Modules
Testing & packaging
Make Python tools trustworthy and installable: pytest with fixtures and parametrised cases, mocking subprocess, HTTP and AWS, linting with ruff, and packaging with pyproject.toml so your tool installs as a real command.
Tools that others rely on need tests
A tool that drains nodes or edits DNS must behave the same after every change. Tests are how you know, and packaging is how colleagues install exactly the version you tested.
Tests are like a practice fire drill: you check everyone knows what to do before there's a real fire. Packaging is putting the tool in a labelled box with instructions, so anyone can take it off the shelf and use it the same way you do.
A testable layout
opsctl/
├── pyproject.toml
├── src/opsctl/
│ ├── __init__.py
│ ├── checks.py # pure logic: easy to test
│ └── cli.py # argparse + wiring
└── tests/
└── test_checks.py
Keep logic (classify, parse, decide) separate from effects (running commands, calling APIs). Pure functions are trivial to test.
pytest basics
# src/opsctl/checks.py
def classify(pct: float, warn: float = 80, crit: float = 90) -> str:
if pct >= crit:
return "CRITICAL"
if pct >= warn:
return "WARNING"
return "OK"
# tests/test_checks.py
import pytest
from opsctl.checks import classify
@pytest.mark.parametrize("pct,expected", [
(0, "OK"), (79.9, "OK"), (80, "WARNING"), (89.9, "WARNING"), (90, "CRITICAL"), (100, "CRITICAL"),
])
def test_classify(pct, expected):
assert classify(pct) == expected
def test_custom_thresholds():
assert classify(85, warn=90, crit=95) == "OK"
$ pytest -q
....... [100%]
7 passed in 0.04s
Files: tmp_path
from pathlib import Path
from opsctl.config import load_hosts # reads a hosts file, skipping comments
def test_load_hosts_skips_comments(tmp_path: Path):
f = tmp_path / "hosts.txt"
f.write_text("# comment\nweb-01\n\nweb-02\n")
assert load_hosts(f) == ["web-01", "web-02"]
Mocking subprocess, HTTP and AWS
import subprocess
from opsctl import services
def test_service_down(monkeypatch):
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, returncode=3, stdout="inactive\n", stderr="")
monkeypatch.setattr(subprocess, "run", fake_run)
assert services.is_active("nginx") is False
This works when services.py calls subprocess.run(...) through the module. If it did from subprocess import run, patch services.run instead: you patch the name where it's looked up.
- HTTP: the
responseslibrary interceptsrequestscalls and returns prepared responses. - AWS: moto fakes AWS services in memory, so boto3 code runs against it without an account:
import boto3
from moto import mock_aws
@mock_aws
def test_bucket_report():
s3 = boto3.client("s3", region_name="us-east-1")
s3.create_bucket(Bucket="demo")
assert [b["Name"] for b in s3.list_buckets()["Buckets"]] == ["demo"]
(mock_aws is moto 5's single decorator; older versions used per-service decorators such as mock_s3.)
Lint and format: ruff
$ pip install ruff
$ ruff check . # bugs and style issues
$ ruff format . # consistent formatting
Package it: pyproject.toml
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "opsctl"
version = "0.3.0"
requires-python = ">=3.10"
dependencies = ["requests>=2.31", "pyyaml>=6", "kubernetes>=29"]
[project.optional-dependencies]
dev = ["pytest", "ruff", "moto", "responses"]
[project.scripts]
opsctl = "opsctl.cli:main"
$ pip install -e ".[dev]" # editable install with dev tools
$ opsctl --help # now a real command
$ python -m build # dist/opsctl-0.3.0-py3-none-any.whl (needs: pip install build)
Try it: test and package your CLI
- Restructure your
opsctlfrom lesson 05 into the layout above. - Write parametrised tests for your classification logic and a
tmp_pathtest for config loading. - Mock
subprocess.runto test "service up" and "service down" paths. - Add
pyproject.tomlwith an entry point, install it withpip install -e ., and runopsctl --help. - Add a CI job (GitHub Actions) running
ruff check,ruff format --checkandpyteston every push.
Going deeper: shipping tools
- Distribute internal tools as wheels to an internal package index, or as a container image. Both pin exact dependencies.
- Aim for coverage of decision logic and error paths, not a percentage number.
- Use
pytest --cov(pytest-cov) to find untested branches in risky code. - Version tools with semantic versioning and keep a changelog: operators need to know what changed before upgrading a tool that touches production.
Recap
- Separate logic from effects; test logic with pytest,
parametrizeandtmp_path. - Mock the world: monkeypatch for subprocess, responses for HTTP, moto for AWS.
- ruff for linting and formatting.
- pyproject.toml with
[project.scripts]turns your tool into an installable command.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.