Testland
Browse all skills & agents

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-tests
View source

python-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

  1. pytest for new code - the modern default; migration from unittest is mostly mechanical because pytest runs TestCase classes natively.
  2. Match an existing unittest convention when maintaining a legacy suite or constrained to stdlib-only (no pip install) → references/unittest.md. unittest.mock is the canonical mocking library in either style.
  3. doctest only for documentation-as-tests - executable examples in docstrings, not regression coverage → references/doctest.md.
  4. Async code → the standalone pytest-asyncio-patterns skill (loop scoping, modes, AsyncMock).

Step 1 - Install

pip install pytest
# Common plugins:
pip install pytest-cov pytest-asyncio pytest-mock pytest-xdist

Step 2 - First test

# test_sum.py
def sum(a, b):
    return a + b

def test_adds_1_and_2():
    assert sum(1, 2) == 3
pytest

pytest 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 None

Fixture 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) == expected

Stacked @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 = 80

branch = 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:

  1. Detect the framework, never assume. Check pyproject.toml ([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.
  2. Match the layout. Existing 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.
  3. One spec → one new test; never modify existing test methods and never fabricate attributes/methods the target module does not expose.
  4. Assert the spec's concrete outcome - no assert True / self.assertTrue(True) smoke asserts. Plain assert in pytest functions; self.assertEqual in TestCase classes (diff-aware).
  5. Use present data peers only: 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.
  6. Refuse universally-quantified specs ("holds for all valid inputs") - that is property-based-test scope (qa-property-based plugin).
  7. Beware mixed lifecycles: pytest's 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-patterns

Anti-patternWhy it failsFix
setUp / tearDown (TestCase style) in new pytest codeLoses dependency-injection benefitsFixtures (Step 4)
Skip --strict-markersMarker typos silently skip testsAlways set in config (Step 3)
scope='session' fixtures for stateful resourcesState leaks across testsFunction scope unless setup is expensive
pytest -k 'expr' in CI to skip slow testsBrittle string match-m markers (Step 6)
Skip --cov-fail-under in CICoverage drops silently over timeAlways gate coverage (Step 8)
doctest as the only test surface for non-trivial logicBrittle string matching; no fixtures/mockspytest for regression coverage; doctest for examples

Limitations

  • Plugin ecosystem is large; conflicting plugins can cause subtle issues.
  • Fixture-scope reasoning has a learning curve.
  • assert rewriting requires pytest's importer; running tests as scripts bypasses it.
  • Built-in async support is limited - use pytest-asyncio (see pytest-asyncio-patterns).

References

  • pt-docs (opens in new window) - official pytest documentation
  • docs.pytest.org/en/stable/how-to/fixtures.html - fixtures
  • docs.pytest.org/en/stable/how-to/parametrize.html - parametrize
  • pypi.org/project/pytest-mock - pytest-mock plugin
  • pypi.org/project/pytest-xdist - parallel execution
  • references/unittest.md - stdlib unittest + unittest.mock
  • references/doctest.md - stdlib doctest
  • pytest-asyncio-patterns - async test patterns (standalone sibling)
  • test-code-conventions (qa-test-review) - test code hygiene

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 + b
python -m doctest module.py       # silent on pass
python -m doctest module.py -v    # verbose; show all examples

Directives

Per dt-docs (opens in new window):

DirectiveUse
# doctest: +ELLIPSIS... matches arbitrary substrings
# doctest: +NORMALIZE_WHITESPACECollapse whitespace before compare
# doctest: +SKIPSkip this example
# doctest: +IGNORE_EXCEPTION_DETAILMatch exception type only
# doctest: +DONT_ACCEPT_TRUE_FOR_1Strict 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 zero

The 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 modules

or 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

  • Tests with shared expensive setup (no fixtures).
  • Parametrized tests across many cases (verbose).
  • Non-deterministic output (timestamps, IDs) - needs +ELLIPSIS at best.
  • Mocking external systems (no built-in mock).

For those, use pytest (SKILL.md).

Anti-patterns

Anti-patternWhy it failsFix
doctest for complex logicDocstrings become unreadablepytest for non-trivial tests
Non-deterministic output without ELLIPSISFails on every run+ELLIPSIS directive
Wrong Traceback patternException expectation doesn't matchFollow the exact format above

References

  • dt-docs (opens in new window) - official doctest reference
  • docs.pytest.org/en/stable/how-to/doctest.html - pytest --doctest-modules
  • sphinx-doc.org/en/master/usage/extensions/doctest.html - Sphinx integration

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):

MethodUse
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=80

pytest 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-patternWhy it failsFix
assertTrue(x == y)Generic boolean; loses diff on failureSpecific assert method
Patch where defined, not where usedPatch silently doesn't applyPatch where USED
Loop over cases without subTestFirst failure stops the loopsubTest
Missing if __name__ == '__main__': unittest.main()Can't run via python test.pyAlways include

Limitations

  • Class-based syntax is verbose vs pytest function-style.
  • No built-in parametrize beyond subTest.
  • Async testing requires unittest.IsolatedAsyncioTestCase (Python 3.8+); less polished than pytest-asyncio.

References