diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 85f99f6..3310f0d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,7 +29,7 @@ jobs: - name: Run tox # Run tox using the version of Python in `PATH` - run: tox -e py + run: tox run-parallel -e py,subtests coverage: needs: build @@ -47,7 +47,7 @@ jobs: run: pip install tox - name: Collect coverage data - run: tox -e coverage + run: tox run-parallel -e coverage,subtests-coverage - name: Combine coverage & fail if it's <100% run: | diff --git a/pyproject.toml b/pyproject.toml index 940540c..5f595ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ tap = "pytest_tap.plugin" [dependency-groups] dev = [ "pytest-tap", + "pytest-subtests", "tox>=4.24.1", ] @@ -55,6 +56,7 @@ packages = ["pytest_tap"] [tool.pytest.ini_options] pythonpath = [".", "src"] +markers = ["subtests"] [tool.uv.sources] pytest-tap = { workspace = true } diff --git a/src/pytest_tap/plugin.py b/src/pytest_tap/plugin.py index a952bd4..0d41132 100644 --- a/src/pytest_tap/plugin.py +++ b/src/pytest_tap/plugin.py @@ -36,13 +36,17 @@ def __init__(self, config: pytest.Config) -> None: def pytest_runtestloop(self, session): """Output the plan line first.""" option = session.config.option - if option.tap_stream or option.tap_combined: + if (option.tap_stream or option.tap_combined) and not ( + session.config.pluginmanager.has_plugin("subtests") + ): self._tracker.set_plan(session.testscollected) @pytest.hookimpl(optionalhook=True) def pytest_xdist_node_collection_finished(self, node, ids): """Output the plan line first when using xdist.""" - if self._tracker.streaming or self._tracker.combined: + if (self._tracker.streaming or self._tracker.combined) and not ( + node.config.pluginmanager.has_plugin("subtests") + ): self._tracker.set_plan(len(ids)) @pytest.hookimpl() @@ -57,6 +61,10 @@ def pytest_runtest_logreport(self, report: pytest.TestReport): return description = str(report.location[0]) + "::" + str(report.location[2]) + if hasattr(report, "sub_test_description"): + # Handle pytest-subtests plugin + description += report.sub_test_description() + testcase = report.location[0] # Handle xfails first because they report in unusual ways. @@ -116,6 +124,9 @@ def pytest_runtest_logreport(self, report: pytest.TestReport): @pytest.hookimpl() def pytest_unconfigure(self, config: pytest.Config): """Dump the results.""" + if self._tracker.combined and config.pluginmanager.has_plugin("subtests"): + # Force Tracker to write plan at beginning of file + self._tracker.set_plan(self._tracker.combined_line_number) self._tracker.generate_tap_reports() diff --git a/tests/test_subtests.py b/tests/test_subtests.py new file mode 100644 index 0000000..6d2aaa8 --- /dev/null +++ b/tests/test_subtests.py @@ -0,0 +1,61 @@ +import pytest +from tap.tracker import ENABLE_VERSION_13 + + +@pytest.mark.subtests +def test_log_subtests_stream(testdir): + """Subtests are added individually to stream.""" + testdir.makepyfile( + """ + import pytest + + def test_subtests(subtests): + for i in range(2): + with subtests.test(msg="sub_msg", i=i): + assert i % 2 == 0 + """ + ) + result = testdir.runpytest_subprocess("--tap") + + result.stdout.fnmatch_lines( + [ + "ok 1 test_log_subtests_stream.py::test_subtests[sub_msg] (i=0)", + "not ok 2 test_log_subtests_stream.py::test_subtests[sub_msg] (i=1)", + "ok 3 test_log_subtests_stream.py::test_subtests", + "1..3", + ] + ) + + +@pytest.mark.subtests +def test_log_subtests_combined(testdir): + """Subtests are added individually to combined.""" + testdir.makepyfile( + """ + import pytest + + def test_subtests(subtests): + for i in range(2): + with subtests.test(msg="sub_msg", i=i): + assert i % 2 == 0 + """ + ) + testdir.runpytest_subprocess("--tap-outdir", "subtest-results", "--tap-combined") + outdir = testdir.tmpdir.join("subtest-results") + testresults = outdir.join("testresults.tap") + assert testresults.check() + actual_results = [ + line.strip() for line in testresults.readlines() if not line.startswith("#") + ] + + expected_results = [ + "1..3", + "ok 1 test_log_subtests_combined.py::test_subtests[sub_msg] (i=0)", + "not ok 2 test_log_subtests_combined.py::test_subtests[sub_msg] (i=1)", + "ok 3 test_log_subtests_combined.py::test_subtests", + ] + + # If the dependencies for version 13 happen to be installed, tweak the output. + if ENABLE_VERSION_13: + expected_results.insert(0, "TAP version 13") + assert actual_results == expected_results diff --git a/tests/test_xdist.py b/tests/test_xdist.py index abacac9..406cb95 100644 --- a/tests/test_xdist.py +++ b/tests/test_xdist.py @@ -23,6 +23,7 @@ def test_sets_plan_streaming(combined, stream, expected): config.option.tap_stream = stream plugin = TAPPlugin(config) node = mock.Mock() + node.config.pluginmanager.has_plugin = lambda x: x != "subtests" test_ids = ["a", "b", "c"] plugin.pytest_xdist_node_collection_finished(node, test_ids) diff --git a/tox.ini b/tox.ini index 3c9f296..e332bc8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,30 @@ [tox] envlist = + subtests coverage [testenv] deps = pytest -commands = pytest --tap-combined {posargs} +commands = pytest -m 'not subtests' --tap-combined {posargs} + +[testenv:subtests] +deps = + pytest + pytest-subtests +commands = pytest -m 'subtests' --tap-combined {posargs} [testenv:coverage] deps = pytest pytest-cov commands = - pytest --cov=pytest_tap --cov-report xml --cov-report term + pytest -m 'not subtests' --cov=pytest_tap --cov-append --cov-report xml --cov-report term + +[testenv:subtests-coverage] +deps = + pytest + pytest-subtests + pytest-cov +commands = + pytest -m 'subtests' --cov=pytest_tap --cov-append --cov-report xml --cov-report term diff --git a/uv.lock b/uv.lock index f251446..d59debe 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.9" +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + [[package]] name = "cachetools" version = "5.5.1" @@ -122,6 +131,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload-time = "2024-12-01T12:54:19.735Z" }, ] +[[package]] +name = "pytest-subtests" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/d9/20097971a8d315e011e055d512fa120fd6be3bdb8f4b3aa3e3c6bf77bebc/pytest_subtests-0.15.0.tar.gz", hash = "sha256:cb495bde05551b784b8f0b8adfaa27edb4131469a27c339b80fd8d6ba33f887c", size = 18525, upload-time = "2025-10-20T16:26:18.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/64/bba465299b37448b4c1b84c7a04178399ac22d47b3dc5db1874fe55a2bd3/pytest_subtests-0.15.0-py3-none-any.whl", hash = "sha256:da2d0ce348e1f8d831d5a40d81e3aeac439fec50bd5251cbb7791402696a9493", size = 9185, upload-time = "2025-10-20T16:26:17.239Z" }, +] + [[package]] name = "pytest-tap" version = "3.5" @@ -133,6 +155,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pytest-subtests" }, { name = "pytest-tap" }, { name = "tox" }, ] @@ -145,6 +168,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "pytest-subtests" }, { name = "pytest-tap", editable = "." }, { name = "tox", specifier = ">=4.24.1" }, ]