Use whenever the task is to write, add, fix or review Python tests, even quick pytest tests for a single function: edge cases, fixtures and conftest.py, parametrize tables, mocking, markers, coverage gates and CI. Has the pytest checklist for edge cases and weak assertions.
88%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
Per docs.pytest.org/en/stable:
pytest is the de facto Python test framework. Unlike stdlib unittest, it
uses function-style tests (no TestCase), fixture-based dependency
injection, parametrize for data-driven tests, and plain-assert rewriting
for diff-rich failures.
Lifecycle scope: configure / run / fixtures / mocking / coverage / CI. Test
code hygiene (assertions, AAA, mocking anti-patterns) is in
test-code-conventions (qa-test-review plugin).
TestCase classes natively.unittest.mock is
the canonical mocking library in either style.pytest-asyncio-patterns skill (loop
scoping, modes, AsyncMock).pip install "pytest>=9"
# Common plugins:
pip install pytest-cov pytest-asyncio pytest-mock pytest-xdist# test_sum.py
def sum(a, b):
return a + b
def test_adds_1_and_2():
assert sum(1, 2) == 3pytestpytest auto-discovers via test_*.py / *_test.py filenames and test_* /
Test* function/class names (pt-docs).
pytest 9 (2025-11-05) reads native TOML. Per pt-config:
"Use [tool.pytest] to leverage native TOML types (supported since
pytest 9.0)". New projects on pytest 9+ use that table:
# pyproject.toml (pytest 9+)
[tool.pytest]
minversion = "9.0"
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
addopts = ["-ra"]
strict = true
markers = [
"slow: marks tests as slow (deselect with -m 'not slow')",
"integration: marks tests requiring DB/external resources",
]Per the changelog, strict = true enables
strict_config, strict_markers, strict_parametrization_ids and
strict_xfail in one switch, and future strictness options join it.
Strict markers reject undeclared names, which catches typos like
@pytest.mark.skipp (silently skipped before).
Other config locations, and what to keep when a project already has one:
| File and table | pytest version | Notes |
|---|---|---|
pyproject.toml [tool.pytest] | 9.0+ | Native TOML lists, booleans and numbers. |
pyproject.toml [tool.pytest.ini_options] | 6.0+ | INI-style string values (addopts = "-ra -q"). Per the changelog it "can't be used alongside [tool.pytest]": convert, don't add a second table. |
pytest.toml or .pytest.toml [pytest] | 9.0+ | Per pt-config, "pytest.toml files take precedence over other files, even when empty." |
pytest.ini, tox.ini [pytest], setup.cfg [tool:pytest] | all | Older INI formats; still read. |
Per pt-config, "Options from multiple configfiles
candidates are never merged - the first match wins", so a stray
pytest.ini silently hides the pyproject.toml table. Check which file
pytest picked from the configfile: line in the run header.
On pytest 8 or older, keep [tool.pytest.ini_options] with
addopts = "-ra --strict-markers --strict-config"; the strict switch
does not exist there.
import pytest
@pytest.fixture
def db_connection():
conn = create_connection()
yield conn
conn.close()
@pytest.fixture(scope="session")
def app_config():
return load_config()
@pytest.fixture(autouse=True)
def reset_state():
yield
cleanup_after_test()
def test_user_creation(db_connection, app_config):
user = create_user(db_connection, app_config)
assert user.id is not NoneFixture scopes: function (default), class, module, package,
session. Choose the narrowest scope that doesn't waste setup time.
conftest.py shares fixtures across test files in the same directory (and
subdirectories). Fixtures are requested by naming them as test-function
parameters - pytest "searches for fixtures that have the same names as
those parameters"
(docs.pytest.org/how-to/fixtures).
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_sum_parametrized(a, b, expected):
assert sum(a, b) == expectedStacked @pytest.mark.parametrize decorators multiply into a
cross-product (docs.pytest.org/how-to/parametrize).
@pytest.mark.skip(reason="Requires staging DB")
def test_skip_example(): ...
@pytest.mark.skipif(sys.version_info < (3, 11), reason="Python 3.11+ syntax")
def test_modern_syntax(): ...
@pytest.mark.xfail(reason="Known bug; tracked in JIRA-1234")
def test_known_failure():
assert 1 == 2
@pytest.mark.slow
def test_long_running(): ...Filter: pytest -m "not slow" skips slow-marked tests.
def test_with_mock(mocker):
mock_api = mocker.patch('mymodule.api_client.fetch')
mock_api.return_value = {'id': 1, 'name': 'Alice'}
result = my_function()
mock_api.assert_called_once_with('/users')
assert result == {'id': 1, 'name': 'Alice'}The mocker fixture from pytest-mock wraps unittest.mock.patch with
auto-cleanup at test end. Patch target rule: patch where the name is
used, not where it's defined - if mymodule.py does
from api import fetch_user, patch mymodule.fetch_user, not
api.fetch_user (see references/unittest.md for
the full unittest.mock catalog).
pytest --cov=src --cov-report=term-missing --cov-report=html --cov-report=xml \
--cov-fail-under=80--cov-fail-under=N fails the run if coverage drops below N%. Config-side
equivalent in pyproject.toml:
[tool.coverage.run]
source = ["src"]
branch = true
omit = ["**/__init__.py", "**/types.py"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
fail_under = 80branch = true enables branch (not just line) coverage; exclude_lines
drops never-coverable lines from the denominator.
- run: pip install -e .[dev]
- run: pytest --cov --cov-report=xml --cov-fail-under=80 --junitxml=junit.xml
- uses: codecov/codecov-action@v7
with: { files: coverage.xml }--junitxml=junit.xml emits a JUnit report (feeds junit-xml-analysis in
qa-test-reporting); --cov-report=xml emits coverage.xml for the
uploader. Parallel: pytest -n auto via pytest-xdist distributes tests
across workers; pytest-cov merges per-worker data automatically.
pytest 9 adds a subtests fixture. Per the changelog, its
subtests.test() context manager "reports each failing subtest
separately", and the feature is marked experimental. Use it when one
test checks many items and every failure should show, not only the
first:
def test_every_price_rounds_to_cents(subtests, catalog):
for item in catalog:
with subtests.test(msg=item.sku):
assert round(item.price, 2) == item.pricePrefer @pytest.mark.parametrize when the inputs are known at
collection time; subtests fit data produced at run time.
pytest --lf # only re-run last-failed tests
pytest --ff # run last-failed first, then the rest
pytest -x # stop on first failure
pytest -k "name_pat" # only tests matching name pattern
pytest -s # don't capture stdout (see print() output)When authoring a new unit test in an existing project:
pytest.toml,
pyproject.toml ([tool.pytest], [tool.pytest.ini_options] or
pytest in dev-deps), setup.cfg
([tool:pytest]), tox.ini ([pytest]); then grep existing tests -
unittest.TestCase subclasses → unittest; otherwise default to pytest.
Doctest is opt-in per-module, only when the spec explicitly asks for
in-docstring examples. Conflicting signals → stop and ask.tests/ dir → tests/test_<module>.py;
co-located test_<module>.py → match it. For doctest, patch the source
module's docstring instead of creating a file.assert True /
self.assertTrue(True) smoke asserts. Plain assert in pytest
functions; self.assertEqual in TestCase classes (diff-aware).
Cover the edges, not only the happy path: empty and whitespace
input, the smallest and largest valid values, one step past each
boundary, and malformed input. Assert each documented error with
pytest.raises(ValueError, match=...) (pytest: expected exceptions);
for systematic boundaries generate the cases with
boundary-value-generator (qa-test-data).mimesis in dev-deps → locale-aware
fixtures via synthetic-data-toolkit (qa-test-data); never install
packages as a side effect. 3+ interacting inputs → generate the case
set with pairwise-test-case-generator (qa-test-data), then map through
@pytest.mark.parametrize.setup_method runs alongside (not
instead of) setUp on TestCase subclasses - pick one mechanism per
class. Avoid mutable default arguments in test helpers (shared across
calls; leaks state).| Anti-pattern | Why it fails | Fix |
|---|---|---|
setUp / tearDown (TestCase style) in new pytest code | Loses dependency-injection benefits | Fixtures (Step 4) |
Skip strict mode (strict = true on pytest 9, --strict-markers before) | Marker typos silently skip tests | Always set in config (Step 3) |
Both [tool.pytest] and [tool.pytest.ini_options] in one pyproject.toml | pytest 9 refuses the combination | Convert to one table (Step 3) |
A leftover pytest.ini next to a pyproject.toml table | First config file wins; the other is ignored without warning | Keep one config file (Step 3) |
Passing ids, indirect or scope positionally to parametrize | Raises TypeError since pytest 9.2 (changelog) | Pass them as keywords |
scope='session' fixtures for stateful resources | State leaks across tests | Function scope unless setup is expensive |
pytest -k 'expr' in CI to skip slow tests | Brittle string match | -m markers (Step 6) |
Skip --cov-fail-under in CI | Coverage drops silently over time | Always gate coverage (Step 8) |
| doctest as the only test surface for non-trivial logic | Brittle string matching; no fixtures/mocks | pytest for regression coverage; doctest for examples |
assert rewriting requires pytest's importer; running tests as scripts
bypasses it.pytest-asyncio-patterns).[tool.pytest], pytest.toml,
precedence, first match winsstrict,
subtests; 9.2 keyword-only parametrize argumentspytest.raises and match for expected exceptionspytest-asyncio-patterns - async test patterns (standalone sibling)test-code-conventions (qa-test-review) - test code hygiene