Python for Infrastructure Automation›08 · Testing & packaging

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.

Practitioner
Key wordspytestfixturesparametrizemockingmototmp_pathpyproject.tomlentry pointsruff

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 responses library intercepts requests calls 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

  1. Restructure your opsctl from lesson 05 into the layout above.
  2. Write parametrised tests for your classification logic and a tmp_path test for config loading.
  3. Mock subprocess.run to test "service up" and "service down" paths.
  4. Add pyproject.toml with an entry point, install it with pip install -e ., and run opsctl --help.
  5. Add a CI job (GitHub Actions) running ruff check, ruff format --check and pytest on 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, parametrize and tmp_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.