python-unit-tests
Python unit testing with pytest as the primary framework - fixtures (`@pytest.fixture` scopes, `conftest.py`), `@pytest.mark.parametrize` table-driven tests, markers (`skip` / `xfail` / custom with `--strict-markers`), `pyproject.toml` config, mocking via pytest-mock, coverage gating with pytest-cov (`--cov-fail-under`), parallel runs with pytest-xdist, and CI wiring - plus stdlib `unittest` (TestCase, unittest.mock, discovery) and `doctest` (docstring examples, directives) as references. Includes framework choice (pytest for new code; match an existing unittest convention; doctest only for documented examples) and test-authoring conventions (framework detection from pyproject.toml/setup.cfg/tox.ini, layout matching, no fabricated attributes). Use for any Python unit-test task: setting up pytest, writing fixtures or parametrized tests, mocking, gating coverage, wiring CI, or maintaining unittest/doctest suites. For async tests, see pytest-asyncio-patterns.
Install with skills.sh (any agent)
npx skills add testland/qa --skill python-unit-testspython-unit-tests
Overview
Per docs.pytest.org/en/stable (opens in new window):
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).
Choosing a framework
Step 1 - Install
pip install pytest
# Common plugins:
pip install pytest-cov pytest-asyncio pytest-mock pytest-xdistStep 2 - First test
# 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 (opens in new window)).
Step 3 - Configuration
pytest.ini (or pyproject.toml [tool.pytest.ini_options] / setup.cfg [tool:pytest]):
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-ra --strict-markers --strict-config"
markers = [
"slow: marks tests as slow (deselect with -m 'not slow')",
"integration: marks tests requiring DB/external resources",
]--strict-markers rejects undeclared marker names - catches typos like @pytest.mark.skipp (silently skipped before).
Step 4 - Fixtures
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 (opens in new window)).
Step 5 - Parametrize
@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 (opens in new window)).
Step 6 - Markers + skip/xfail
@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.
Step 7 - Mocking with pytest-mock
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).
Step 8 - Coverage with pytest-cov
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.
Step 9 - CI and parallel execution
- run: pip install -e .[dev]
- run: pytest --cov --cov-report=xml --cov-fail-under=80 --junitxml=junit.xml
- uses: codecov/codecov-action@v4
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.
Step 10 - Fast-feedback flags
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)Authoring conventions
When authoring a new unit test in an existing project:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
setUp / tearDown (TestCase style) in new pytest code | Loses dependency-injection benefits | Fixtures (Step 4) |
Skip --strict-markers | Marker typos silently skip tests | Always set in config (Step 3) |
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 |
Limitations
References
doctest - executable docstring examples (reference)
View source (opens in new window)doctest - executable docstring examples (reference)
Companion reference for python-unit-tests. Consult for documentation-as-tests: library code where API docs include usage examples that must not drift from the implementation. Not a replacement for pytest - use it as a complement (smoke + docs).
Per docs.python.org/3/library/doctest.html (opens in new window):
doctest embeds executable examples in docstrings: the interactive-prompt convention (>>> ... input, expected output on the next line) becomes a test case automatically. Examples render in help() and Sphinx HTML.
Basic doctest
def sum(a, b):
"""Add two numbers.
>>> sum(1, 2)
3
>>> sum(-1, 1)
0
"""
return a + bpython -m doctest module.py # silent on pass
python -m doctest module.py -v # verbose; show all examplesDirectives
Per dt-docs (opens in new window):
| Directive | Use |
|---|---|
# doctest: +ELLIPSIS | ... matches arbitrary substrings |
# doctest: +NORMALIZE_WHITESPACE | Collapse whitespace before compare |
# doctest: +SKIP | Skip this example |
# doctest: +IGNORE_EXCEPTION_DETAIL | Match exception type only |
# doctest: +DONT_ACCEPT_TRUE_FOR_1 | Strict bool != int comparison |
>>> list_users() # doctest: +ELLIPSIS
[{'id': 1, 'name': 'Alice', 'created_at': ...}, ...]Expected exceptions
>>> divide(10, 0)
Traceback (most recent call last):
...
ZeroDivisionError: division by zeroThe Traceback (most recent call last): + ... + exception line pattern is doctest's expected-error format - it must match exactly.
pytest and Sphinx integration
pytest --doctest-modules src/ # collects doctests from all modulesor in pyproject.toml: addopts = "--doctest-modules" (docs.pytest.org/en/stable/how-to/doctest.html).
sphinx.ext.doctest runs doctests during the Sphinx build (sphinx-doc.org/en/master/usage/extensions/doctest.html):
sphinx-build -b doctest docs/ build/doctest/When doctest is the WRONG choice
For those, use pytest (SKILL.md).
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| doctest for complex logic | Docstrings become unreadable | pytest for non-trivial tests |
| Non-deterministic output without ELLIPSIS | Fails on every run | +ELLIPSIS directive |
Wrong Traceback pattern | Exception expectation doesn't match | Follow the exact format above |
References
unittest - Python stdlib testing (maintenance reference)
View source (opens in new window)unittest - Python stdlib testing (maintenance reference)
Companion reference for python-unit-tests. Consult when constrained to stdlib-only (no pip install), maintaining a legacy unittest codebase, or using unittest.mock patterns from pytest test bodies.
Per docs.python.org/3/library/unittest.html (opens in new window):
unittest is Python's stdlib testing framework, modeled on JUnit (xUnit family): no pip install required, class-based tests as TestCase methods, and unittest.mock bundled - the canonical Python mocking library even in pytest projects.
First test
# test_sum.py
import unittest
def sum(a, b):
return a + b
class TestSum(unittest.TestCase):
def test_adds_1_and_2(self):
self.assertEqual(sum(1, 2), 3)
if __name__ == '__main__':
unittest.main()Run python -m unittest test_sum.py. A passing run ends with OK after a Ran N tests summary; FAILED (failures=N) prints the AssertionError diff.
TestCase lifecycle hooks
setUpClass / tearDownClass (classmethods, once per class) and setUp / tearDown (per test). No fixture-scope concept beyond these two levels.
Assertion catalog
Per ut-docs (opens in new window) - assert with the method specific to the check, never assertTrue(x == y) (the specific method prints a useful diff on failure):
| Method | Use |
|---|---|
assertEqual(a, b) / assertNotEqual(a, b) | Equality |
assertTrue(x) / assertFalse(x) | Boolean |
assertIs(a, b) / assertIsNot(a, b) | Identity (is) |
assertIsNone(x) / assertIsNotNone(x) | None |
assertIn(a, b) / assertNotIn(a, b) | Membership |
assertIsInstance(a, type) | Type check |
assertRaises(Exception) | Sync raise (context manager + decorator forms) |
assertRaisesRegex(Exception, regex) | Raise + message match |
assertWarns(Warning) | Warning emission |
assertAlmostEqual(a, b, places=N) | Float comparison |
assertGreater(a, b) / assertGreaterEqual(a, b) | Numeric |
assertCountEqual(a, b) | Same elements regardless of order |
unittest.mock patterns
Per docs.python.org/3/library/unittest.mock.html (opens in new window):
from unittest.mock import Mock, MagicMock, patch
# Standalone mocks
m = Mock()
m.method.return_value = 42
result = m.method(5)
m.method.assert_called_once_with(5)
# MagicMock supports magic methods (__len__, __iter__, etc.)
mm = MagicMock()
mm.__len__.return_value = 5
assert len(mm) == 5
# Patch a function in the target module
@patch('mymodule.fetch_user')
def test_with_patched_fetch(mock_fetch):
mock_fetch.return_value = {'id': 1}
...
# Context-manager form
with patch('mymodule.fetch_user') as mock_fetch:
mock_fetch.return_value = {'id': 1}
...
# Patch an attribute / a dictionary
@patch.object(SomeClass, 'method', return_value='mocked')
@patch.dict('os.environ', {'API_KEY': 'test-key'})Patch target rule: patch where the function is used, not where it's defined. If mymodule.py does from api import fetch_user, patch mymodule.fetch_user, not api.fetch_user.
Worked example - greeting.py builds a welcome string from a user fetched over HTTP:
# tests/test_greeting.py
import unittest
from unittest.mock import patch
from greeting import welcome
class TestWelcome(unittest.TestCase):
@patch('greeting.fetch_user')
def test_welcome_names_user(self, mock_fetch):
mock_fetch.return_value = {'name': 'Ada'}
self.assertEqual(welcome(1), 'Hi Ada')
mock_fetch.assert_called_once_with(1)subTest for parametrization
def test_addition_cases(self):
cases = [(1, 2, 3), (0, 0, 0), (-1, 1, 0)]
for a, b, expected in cases:
with self.subTest(a=a, b=b):
self.assertEqual(sum(a, b), expected)subTest reports each iteration as a separate failure; without it the loop stops at the first failure.
Skip + expected failure
@unittest.skip(reason), @unittest.skipIf(cond, reason), and @unittest.expectedFailure (the test passes because it is expected to fail).
Discovery and CI
python -m unittest discover # from cwd
python -m unittest discover -s tests/ -p 'test_*.py'
python -m unittest tests.test_user.TestUser.test_creation
# CI with coverage:
coverage run -m unittest discover && coverage report --fail-under=80pytest interop (migration path)
pytest runs unittest.TestCase classes natively: keep TestCase classes, write new tests as pytest functions, convert gradually. unittest.mock works in either style.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
assertTrue(x == y) | Generic boolean; loses diff on failure | Specific assert method |
| Patch where defined, not where used | Patch silently doesn't apply | Patch where USED |
Loop over cases without subTest | First failure stops the loop | subTest |
Missing if __name__ == '__main__': unittest.main() | Can't run via python test.py | Always include |