Skip to main content

Generate Python unit tests from JSON and YAML files

Project description

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyinfra_testing-0.2.1.tar.gz (13.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyinfra_testing-0.2.1-py3-none-any.whl (16.3 kB view details)

Uploaded Python 3

File details

Details for the file pyinfra_testing-0.2.1.tar.gz.

File metadata

  • Download URL: pyinfra_testing-0.2.1.tar.gz
  • Upload date:
  • Size: 13.8 kB
  • Tags: Source
  • Uploaded using 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}

File hashes

Hashes for pyinfra_testing-0.2.1.tar.gz
Algorithm Hash digest
SHA256 239ad1f84c38a45604c685a65d263f92a503ca2b8427c8d30124b82fc1a4971d
MD5 f6d15a86c1a953c185d93f3c9dc9c86f
BLAKE2b-256 048d177c2c08a054dfad8aeb04177d021985722353c7bea1918fb9b306dec40d

See more details on using hashes here.

File details

Details for the file pyinfra_testing-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: pyinfra_testing-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 16.3 kB
  • Tags: Python 3
  • Uploaded using 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}

File hashes

Hashes for pyinfra_testing-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 e8ced1f0211b3ac71c7dff1b11ab85969587c3b0451bf83255fb5e4fb486fa79
MD5 16a7b6825f0fc2bf9d903867b10f1423
BLAKE2b-256 f565c23a40d7212298b00e3eb91b5c9d09f032950116f067e1ff0e4164b5c507

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page