SKILL.rst#
Claude Code Skill — punit#
This document describes the punit skill for Claude Code and embeds the full
SKILL.md snippet. Copy the embedded SKILL.md content (see below) to
.claude/skills/punit/SKILL.md in any project that consumes or develops pUnit
tests — Claude Code loads it automatically when the user mentions “punit” or
discusses unit testing.
Source code: https://github.com/wilson0x4d/punit
Docs: https://punit.readthedocs.io/
Install: python3 -m pip install punit
Overview#
pUnit is a modernized xUnit-style unit-testing framework for Python. It provides
two core test types: Facts (invariant state tests) and Theories (parameterized
tests via @inlinedata). Tests are plain functions/methods — no base classes,
no __init__.py required.
Embedded Skill Markdown#
The full SKILL.md content follows verbatim. It is also maintained as a separate
file at [docs/SKILL.md](SKILL.md) for standalone use.
1---
2name: punit
3description: Provides additional unit-testing guidance for the pUnit framework. MUST use when user mentions "punit" or discusses unit testing. MUST use when creating, refactoring, or reading files inside the `tests/` directory. MUST use when creating or modifying files matching `*test*.py`.
4user-invocable: true
5disable-model-invocation: false
6---
7
8`punit` requires Python 3.11+. It exposes `@fact`, `@theory`, `@inlinedata`, `@trait`, and `@fails` decorators to organize code into unit tests. Tests are plain functions/methods — no inheritance required, no `__init__.py` needed. By default pUnit discovers tests under the `tests/` directory.
9
10- **Documentation:** https://punit.readthedocs.io/
11- **Install:** `python3 -m pip install punit`
12- **Source:** https://github.com/wilson0x4d/punit
13
14## CLI Invocation
15
16Always prefix with the project's virtual-environment interpreter and always supply a filter:
17
18```bash
19.venv/bin/python -m punit --filter '*'
20```
21
22Multiple filters narrow results (logical AND):
23
24```bash
25.venv/bin/python -m punit --filter '*Widget*' --filter '*Cache*'
26```
27
28## CLI Flags Reference
29
30| Flag | Meaning | Example |
31| :--- | :--- | :--- |
32| `-q, --quiet` | Suppress normal output | `--quiet` |
33| `-v, --verbose` | Exhaustive discovery & result detail | `--verbose` |
34| `-z, --failfast` | Stop on first failure or error | `--failfast` |
35| `-p, --test-package NAME` | Override test package (default `tests`) | `--test-package foo` |
36| `-i, --include PATTERN` | Include pattern for file discovery | `--include '*widget*'` |
37| `-e, --exclude PATTERN` | Exclude pattern (overrides include) | `--exclude '*hardcoded*'` |
38| `-f, --filter PATTERN\|@FILE` | Restrict tests by qualified name/path | `--filter 'MyClass.fact'` |
39| `-t, --trait [!]NAME[=VALUE]` | Include/exclude by trait (`!` to exclude) | `--trait '!integration'` |
40| `--no-pathfix` | Rely on PYTHONPATH instead of adding `src/` | `--no-pathfix` |
41| `-r, --report {html\|json}` | Generate a report to stdout | `--report json` |
42| `-o, --output FILENAME` | Write report to file (not stdout) | `--output result.json` |
43
44**Wildcard syntax** for include/exclude/filter:
45- `*` — match one or more characters
46- `?` — match any single character
47
48## Filters File
49
50Use `--filter '@path/to/file.txt'` to load patterns from a plaintext file (one per line; lines starting with `#` are comments). Prefix individual filter entries with `!` to exclude them. Order matters: the first matching rule wins.
51
52Piped input via stdin is also supported:
53
54```bash
55cat tests/filters-file.txt | .venv/bin/python -m punit --filter '@stdin'
56```
57
58## Writing Tests
59
60### Facts — single-case assertions
61
62```python
63from punit import fact
64
65@fact
66async def when_initialized_touch_must_return_true():
67 mylib = MyLibrary()
68 mylib.initialize()
69 await asyncio.sleep(1)
70 assert mylib.touch(), 'Expected touch() == True after initialize().'
71```
72
73### Theories — parametrized assertions
74
75Each `@inlinedata(...)` call produces one test instance; values are passed positionally:
76
77```python
78from punit import theory, inlinedata
79
80@theory
81@inlinedata(2, 2, 4, 'two plus two equals four')
82@inlinedata(1, 1, 2, 'one plus one equals two')
83def add_theory(x: int, y: int, z: int, message: str):
84 assert x + y == z, message
85```
86
87### Traits — categorize tests for CI filtering
88
89```python
90from punit import theory, inlinedata, trait
91
92@theory
93@inlinedata(0, 1, 1)
94@trait('integration')
95@trait('redis')
96def api_query_theory(a: int, b: int, c: int):
97 assert a + b == c
98```
99
100### Expected Failures — regression detection for known bugs
101
102The `@fails` decorator marks a test as expected to fail; the runner inverts its result (passing becomes failure). Useful for tracking known issues. Always stack **below** `@fact` or `@theory`:
103
104```python
105from punit import fact, fails
106
107@fact
108@fails(reason='bug #123: pending fix')
109def broken_feature_should_pass():
110 assert False # reported as passed; a fix that makes this pass = regression
111```
112
113Requires the `reason=` keyword argument (positional not allowed). Raises if stacked below another pUnit decorator or double-stacked.
114
115### Methods & Classes — same decorators, no inheritance required
116
117```python
118class MyTestFixture:
119
120 @fact
121 def verify_calc_error_condition(self):
122 from punit.assertions.exceptions import raises
123 def call_with_none(): self.calc(None)
124 assert raises[Exception](call_with_none)
125 assert not raises[Exception](lambda: self.calc(1))
126```
127
128### Setup & Teardown
129
130```python
131from punit import setup, teardown
132
133@setup # runs before each test (module-scoped if at module level; class-scoped if a method inside a test class)
134def prepare_data(): ...
135
136@teardown # runs after each test (same scoping model as @setup)
137def cleanup(): ...
138```
139
140## Assertion Helpers
141
142pUnit uses Python's `assert` directly. Optional helper modules live under `punit.assertions`:
143
144### Collections
145
146Both `punit` top-level re-exports (`from punit import collections`) and submodule paths (`from punit.assertions import collections`) are supported.
147
148```python
149from punit.assertions import collections
150
151assert collections.are_same([1, 2], (1, 2)) # element-wise equality
152assert collections.has_length(lst, min=3) # length check (keyword args only: min, max)
153assert collections.is_none_or_empty([]) # None or empty?
154```
155
156### Strings
157
158Both `punit` top-level re-exports (`from punit import strings`) and submodule paths (`from punit.assertions import strings`) are supported.
159
160```python
161from punit.assertions import strings
162
163assert strings.are_same('a', 'a') # string equality
164assert strings.has_length(s, min=3) # length check (keyword args only: min, max)
165assert strings.is_none_or_empty(None) # None or empty?
166assert strings.is_none_or_whitespace(' ') # whitespace-only?
167```
168
169### Exceptions
170
171Both `punit` top-level re-exports (`from punit import raises`) and submodule paths (`from punit.assertions.exceptions import raises`) are supported.
172
173```python
174# Preferred: generic syntax (Python 3.11+)
175assert raises[TypeError](lambda: int("bad"))
176
177# Fallback: function-arg syntax
178assert raises(lambda: int("bad"), expect=TypeError, exact=True)
179```
180
181### Numeric
182
183Both `punit` top-level re-exports (`from punit import approx`) and submodule paths (`from punit.assertions.numeric import approx, isclose, isnan, isinfinite, percentage`) are supported.
184
185The `approx` class supports natural Python comparison syntax with directional (one-sided) tolerance:
186
187 * ``x == approx(expected)``; approximately equal (bidirectional tolerance)
188 * ``x > approx(threshold)``; strictly greater than (tolerance extends above)
189 * ``x >= approx(threshold)``; at least the threshold (tolerance extends below)
190 * ``x < approx(threshold)``; strictly less than (tolerance extends above)
191 * ``x <= approx(threshold)``; at most the threshold (tolerance extends below)
192
193```python
194from punit.assertions.numeric import approx, isclose, isnan, isinfinite, percentage
195
196# Primary: operator overloads (preferred by user)
197assert 0.1 + 0.2 == approx(0.3) # approximately equal
198assert value > approx(5) # strictly greater than
199assert value >= approx(5) # at least the threshold
200assert value < approx(10) # strictly less than
201assert value <= approx(10) # at most the threshold
202
203# Secondary: fluent methods for advanced scenarios
204assert some_value == approx(3.14).greater_than() # >= 3.14 with one-sided tolerance below
205assert some_value == approx(10.0).less_than() # <= 10.0 with one-sided tolerance above
206assert some_value == approx(5).at_least() # same as greater_than()
207assert some_value == approx(10).at_most() # same as less_than()
208assert some_value == approx(5).in_range(3, 7) # within [3, 7] with directional tolerance
209assert some_value == approx().zero() # approximately zero
210
211# Standalone helpers
212assert isclose(3.141, 3.14, rel_tol=0.01) # drop-in math.isclose replacement
213assert isnan(float('nan')) # NaN check
214assert isinfinite(float('inf')) # infinity check
215assert percentage(10, 100) == 90.0 # percentage difference
216```
217
218## Default CLI Invocation
219
220The default behavior is equivalent to:
221
222```bash
223python3 -m punit \
224 --test-package tests \
225 --include '*.py' \
226 --exclude '/__*__' \
227 --filter '*'
228```
229
230This includes all Python files under `tests/`, excludes dunder names, and runs every test.
231
232## Troubleshooting
233
234- If pUnit fails to execute, prefix with the venv path: `.venv/bin/python -m punit`.
235- Use `--verbose` to debug discovery / filtering issues.
236- Test files do **not** need `__init__.py`.
237- Exit code `119` indicates one or more tests failed.