Skip to main content

pyinfra testing utils

Generate unittest tests for pyinfra facts and operations from data files (JSON/YAML) instead of hand-writing assertions.

You point a generator at a fact or operation and a folder of test-case files. The harness creates a TestCase subclass with one test method per file in that folder, so adding a case is just dropping in another .json/.yaml.

Install

From PyPI as a development dependency, using uv:

uv add --dev pyinfra-testing

This adds it to your dev dependency group:

# pyproject.toml
[dependency-groups]
dev = ["pyinfra-testing>=0.2.0"]

Then uv sync to install. The generated classes are plain unittest.TestCases, so pytest (or python -m unittest) discovers them with no extra config.

Layout

The conventional layout uses one directory per fact/operation, named after its dotted import path, with the case files inside:

tests/
  test_facts.py                 # discovers tests/facts/*/
  facts/
    proxmox.pve.PVEContainers/  # dir name == "<module>.<FactClass>"
      two_running.json
      empty.json
  test_operations.py            # discovers tests/operations/*/
  operations/
    proxmox.pve.container/      # dir name == "<module>.<operation>"
      create_minimal.json
      destroy.json

test_facts.py just iterates those directories and hands each one to the generator (the directory name is the dotted path argument):

# tests/test_facts.py
from pathlib import Path
from pyinfra_testing.facts import make_fact_tests

BASE_IMPORT_PATH = "facts"          # your facts package root
TESTS_BASE = Path(__file__).parent / "facts"

for fact_path in sorted(d.name for d in TESTS_BASE.iterdir() if d.is_dir()):
    locals()[fact_path] = make_fact_tests(BASE_IMPORT_PATH, fact_path, TESTS_BASE / fact_path)
# tests/test_operations.py
from pathlib import Path
from pyinfra_testing.operations import make_operation_tests

BASE_IMPORT_PATH = "operations"
TESTS_BASE = Path(__file__).parent / "operations"

for op_path in sorted(d.name for d in TESTS_BASE.iterdir() if d.is_dir()):
    locals()[op_path] = make_operation_tests(BASE_IMPORT_PATH, op_path, TESTS_BASE / op_path)

make_*_tests(base_import_path, dotted_path, folder) imports base_import_path + "." + dotted_path and resolves the trailing attribute as the fact class / operation. Both flat (pyinfra.facts + server.LinuxName) and nested (facts + proxmox.pve.PVEContainers) namespaces work — the final .-segment is the attribute, everything before it is the module.

A test directory must contain only case files: every .json/.yaml/.yml in it becomes one test.

Testing facts

A fact case file describes the command output and the expected parsed result:

# facts/proxmox.pve.PVEContainers/two_running.yaml
# `output`: the raw lines the command would print (a str is split on newlines).
output: |
  VMID       Status     Lock         Name
  100        running                 postgres
  102        running    backup       web-server
# `fact`: the expected result of fact.process(output), JSON-normalised.
fact:
  "100": { vmid: 100, status: running, lock: null, name: postgres }
  "102": { vmid: 102, status: running, lock: backup, name: web-server }
# Optional — asserted when present, otherwise a warning is emitted:
# command: pct list
# requires_command: pveum
# arg: [100]          # args passed when instantiating the fact class
# facts: { ... }      # other facts the fact's process() reads via the host

process() runs under a frozen clock (2025-01-01) so facts that use the current date are deterministic.

Supported fact return types

Before comparison the result of process() is run through json.dumps(..., default=...) and back. A fact may return anything that survives that round-trip:

Returned value Serialised as Notes
dict, list, str, int, float, bool, None themselves the JSON-native types
dataclass instance object via dataclasses.asdict nested dataclasses, and dataclasses inside dict/list, are handled recursively
enum.StrEnum / enum.IntEnum (or any Enum subclassing str/int) its underlying value a plain Enum is not serialisable — make it a StrEnum/IntEnum
datetime ISO-8601 string
pathlib.Path str
set sorted list
bytes decoded str
object exposing to_json() whatever to_json() returns pyinfra's own extension hook; also used at runtime by the CLI

Anything else raises TypeError: Cannot serialize: .... If you need a custom shape (omit None fields, rename keys, serialise a plain Enum), give the returned type a to_json() method — it takes precedence and is the same hook pyinfra uses to serialise facts at runtime.

Two consequences worth noting when writing the expected fact:

  • Dict keys become strings. JSON object keys are always strings, so a fact returning dict[int, ...] (e.g. keyed by VMID) must use string keys in the expected fact — "100": not 100:. A fact keyed by a tuple is encoded with its JSON-array form as the key — dict[tuple, ...] keyed by ("/", "user", "alice@pve") becomes the key "[\"/\", \"user\", \"alice@pve\"]".
  • asdict emits every field, including those left at their default/None. The expected fact must list them too (or give the dataclass a to_json() that drops them).

Testing operations

An operation case file describes the inputs and the exact commands the operation should yield:

# operations/proxmox.pve.container/create_minimal.yaml
args: [100, "ubuntu-22.04-standard_22.04-1_amd64.tar.zst"]
kwargs: { present: true }
# `facts`: data returned by host.get_fact(...) inside the operation, keyed by
# "<module>.<FactClass>". Global/executor kwargs the operation passes to
# get_fact (_sudo, _su_user, ...) are ignored when matching; real fact args
# (positional, or kwargs like _id) are used to build the lookup key.
facts:
  pve.PVEContainers: {}        # no existing containers
# `commands`: the exact list the operation should yield.
commands:
  - pct create 100 ubuntu-22.04-standard_22.04-1_amd64.tar.zst
# Optional:
# noop_description: "Container '175' already exists. Use force=True to recreate."
# exception: { name: ValueError, message: "..." }   # or names: [A, B]
# require_platform: [Linux]                          # skip on other platforms
# local_files: { files: {...}, dirs: {...}, links: {...} }

Operations are invoked via op._inner(*args, **kwargs). If a case yields no commands, set noop_description to assert the operation called host.noop(...).

Injected facts values are returned to the operation as attribute-accessible dicts, so an operation that reads a fact value as an object (info.some_field) works against plain JSON data. Lookup keys are matched JSON-style — an operation doing fact.get(100) matches the "100" key, and fact.get(("/", "user", "alice@pve")) matches the tuple's JSON-array key "[\"/\", \"user\", \"alice@pve\"]" (the same canonical form a dict[tuple, ...] fact result is encoded with). So facts keyed by ints, enums, or tuples can all be supplied; key the JSON facts data with that canonical string.

Yielded commands may be plain strings, StringCommand, FunctionCommand, FileUploadCommand/FileDownloadCommand (compared as ["upload", data, dest] / ["download", src, dest]), or a connector-argument dict.

Enum and dataclass arguments

args/kwargs are coerced to the operation's annotated parameter types using the operation's own signature, so you write plain JSON and the types are reconstructed for you:

  • a parameter annotated as an Enum (e.g. arch: PVEContainerArch | None) receives the member built from the JSON value — "arch": "amd64" becomes PVEContainerArch.AMD64;
  • a parameter annotated as a dataclass receives an instance built from a JSON object — "features": {"nesting": true} becomes Features(nesting=True);
  • dict[..., T] / list[T] containers of those are coerced element-wise — "networks": {"0": {"name": "eth0"}} becomes {"0": NetworkInterface(...)}.

Coercion only applies where the annotation is an enum, a dataclass, or a container of them. Plain parameters, unannotated parameters, and union members the value already satisfies (e.g. the str in Protocol | str) are left untouched, and an operation whose hints can't be resolved is skipped entirely.

Typed values in args/kwargs

The coercion above is driven by parameter types. For values whose target type is not visible in the signature, JSON/YAML strings are also expanded by prefix:

  • "datetime:2025-01-01T00:00:00" → datetime
  • "path:/etc/hosts" → pathlib.Path
  • ["set:", "a", "b"] → set

Running

uv run pytest                      # all generated (+ any hand-written) tests
uv run pytest tests/test_facts.py  # just the fact suites

Metadata

Release files for pyinfra-testing 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyinfra-testing 0.2.1
File Size Uploaded
pyinfra_testing-0.2.1.tar.gz 13.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pyinfra-testing 0.2.1
File Interpreter ABI Platform
pyinfra_testing-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 30.1 kB

Release files / pyinfra_testing-0.2.1.tar.gz

Download URL pyinfra_testing-0.2.1.tar.gz
Size 13.8 kB
Tags Source
SHA-256 checksum
How to use checksums
239ad1f84c38a45604c685a65d263f92a503ca2b8427c8d30124b82fc1a4971d
BLAKE2b-256 checksum
How to use checksums
048d177c2c08a054dfad8aeb04177d021985722353c7bea1918fb9b306dec40d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / pyinfra_testing-0.2.1-py3-none-any.whl

Download URL pyinfra_testing-0.2.1-py3-none-any.whl
Size 16.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e8ced1f0211b3ac71c7dff1b11ab85969587c3b0451bf83255fb5e4fb486fa79
BLAKE2b-256 checksum
How to use checksums
f565c23a40d7212298b00e3eb91b5c9d09f032950116f067e1ff0e4164b5c507
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

0.2.1 This release

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page