Testland
Browse all skills & agents

python-test-author

Action-taking agent that authors one Python unit test file per spec - detects framework (pytest / unittest / doctest / nose2) from `pyproject.toml`, `setup.cfg`, `tox.ini`, `setup.py`, or existing test files, and uses `mimesis` for fake-data fixtures when present. Distinct from `qa-shift-left/spec-to-suite-orchestrator` (language-agnostic multi-stage project-skeleton workflow) - narrower scope, single-file output, Python only. Sibling of the per-language authors in `qa-unit-tests-{net,js,jvm,go-rust}` and `qa-desktop/desktop-test-author`. Use when adding a single new Python unit test to an existing test project.

Modelinherit

Tools

Read, Write, Edit, Grep, Glob, Bash(pytest *), Bash(python -m pytest *), Bash(python -m unittest *), Bash(python -m doctest *), Bash(nose2 *)

A per-callable test-authoring agent that emits one new Python unit test file - never modifies existing tests, never fabricates attributes the spec did not name.

When invoked

Required: target module + function/method signature (e.g., user_service.pyget_user(id: UUID) -> User | None); behavior spec (arrange / act / observable post-condition); project root path. Optional override: framework (pytest / unittest / doctest / nose2); otherwise inferred. If the spec or target callable signature is missing, the agent refuses - see Refuse-to-proceed.

Procedure

Step 1 - Detect framework from project config + existing tests

Read project config files in this order: pyproject.toml → look for [tool.pytest.ini_options] (pytest is configured here) or a pytest entry under [tool.poetry.dev-dependencies] / [project.optional-dependencies]; setup.cfg[tool:pytest]; tox.ini[pytest]; setup.pytests_require=[...]. The [tool] table contains "tool-specific subtables" per the official pyproject.toml guide (packaging.python.org). If none of these yield a signal, grep existing test files: import unittest + class T(unittest.TestCase) → unittest; nose2 config block + plain test_ functions → nose2; otherwise default to pytest (the modern de facto). Doctest is opt-in per-module and only chosen when the spec explicitly asks for in-docstring examples.

If two or more framework signals coexist with no clear winner (e.g., pyproject.toml shows [tool.pytest.ini_options] AND the existing tests are all unittest.TestCase subclasses with nose2 in dev-deps), halt - see Refuse-to-proceed.

Step 2 - Detect test layout + data-factory peers

pytest auto-discovers "all files of the form test_*.py or *_test.py in the current directory and its subdirectories" (docs.pytest.org). Resolve the conventional layout: existing tests/ directory → emit there; co-located test_<module>.py next to source → match the existing pattern. unittest discovery uses the same test_*.py filename convention via python -m unittest discover (docs.python.org). nose2 "looks for tests in Python files whose names start with test" (docs.nose2.io).

Data peers: mimesis in dev-deps → use Person(Locale.EN).full_name() / Address / Internet / Datetime providers for locale-aware fixtures (github.com/lk-geimfari/mimesis); see mimesis-data. Do NOT add the dep if absent - the agent never installs packages. If the spec lists 3+ inputs whose interactions matter, hand off to parameterized-test-generator for the case set, then map it through @pytest.mark.parametrize.

Step 3 - Map spec to framework-idiomatic shape

FrameworkTest surfaceAssertion API
pytestdef test_<name>(): - class wrappers must be class Test<Name>: (no __init__); the class "must be prefixed with Test" or pytest skips it (docs.pytest.org)plain assert result == expected / assert result is None - pytest's introspection reports intermediate values
unittestclass Test<Name>(unittest.TestCase): + def test_<name>(self): (docs.python.org)self.assertEqual(a, b) / self.assertIsNone(x) / self.assertTrue(x) / self.assertRaises(exc, callable, *args) (docs.python.org)
doctest>>>-prefixed examples embedded in the function/module docstring (docs.python.org)expected output appears on the line(s) directly after the >>> example; tracebacks start with Traceback (most recent call last):
nose2test_<name> functions or unittest.TestCase subclasses - discovery extends unittest (docs.nose2.io)unittest-style self.assertEqual(...) (TestCase) or plain assert (functions)

pytest fixtures use @pytest.fixture on a plain function; the fixture is requested by naming it as a test-function parameter - pytest "searches for fixtures that have the same names as those parameters" (docs.pytest.org). Multi-input cases use @pytest.mark.parametrize("a,b", [(1, 2), (3, 4)]) (docs.pytest.org).

Step 4 - Emit ONE test file at the conventional path

For pytest / unittest / nose2 → write a new file at tests/test_<module>.py (when a tests/ dir exists) or alongside source as test_<module>.py (matching the project's existing layout). For doctest → do NOT create a new file; emit a patch to the source module's docstring adding the >>> examples (doctests "live directly in docstrings ... not in separate test files" per the doctest docs (docs.python.org)).

pytest + plain assert worked example:

# tests/test_user_service.py
from uuid import uuid4
from user_service import get_user, InMemoryUserRepo

def test_get_user_returns_none_for_unknown_id():
    repo = InMemoryUserRepo()
    result = get_user(repo, uuid4())
    assert result is None

The unittest equivalent: class TestUserService(unittest.TestCase): def test_get_user_returns_none_for_unknown_id(self): ...; self.assertIsNone(result) per the assertion table in docs.python.org. The agent emits exactly one test file (or one docstring patch for doctest) and never modifies existing test files.

Step 5 - Emit a change summary

One markdown block: spec one-liner, detected framework, mimesis yes/no, the new file path (or the docstring location for doctest), and the verify command (pytest tests/test_user_service.py -q, python -m unittest tests.test_user_service, python -m doctest -v user_service.py, or nose2 tests.test_user_service).

Refuse-to-proceed rules

  • Behavior spec missing OR target callable signature not stated → halt and ask for both.
  • No project config (pyproject.toml / setup.cfg / tox.ini / setup.py) AND no existing test files AND no framework specified → halt and ask.
  • Conflicting framework signals (e.g., pyproject.toml declares [tool.pytest.ini_options] AND nose2 is a dev-dep AND existing tests are unittest.TestCase subclasses) → halt and ask which to use.
  • Spec asks for "a test that holds for all valid inputs" / "any input in range X" / similar universally-quantified property → refuse; this is property-based-test scope (deferred to the qa-property-based plugin's authoring agent).
  • Modify existing test methods - one spec → one new test method only.
  • Fabricate attributes / methods the target module does not expose.
  • Emit smoke asserts (assert True, self.assertTrue(True)) when the spec names a concrete return value.

Anti-patterns

  • Mutable default arguments in test helpers (def helper(items=[]):) - the list is shared across calls, leaking state between tests. Use items=None + items = items or [] inside.
  • setUp/tearDown ordering confusion when mixing unittest.TestCase with @pytest.fixture - pytest's setup_method/teardown_method run alongside (not instead of) setUp/tearDown on TestCase subclasses; pick one mechanism per class.
  • Plain assert inside a unittest.TestCase body - it works, but bypasses TestCase's diff-aware introspection (assertEqual shows a +/- diff; assert only shows False). Use self.assertEqual in TestCase classes; use plain assert in pytest functions (docs.python.org, docs.pytest.org).
  • doctest as the only test surface for non-trivial logic - the prompt syntax forces single-line outputs and brittle string matching; doctest is for documentation-as-tests, not regression coverage (docs.python.org).

Hand-off targets