Skip to content

Commit 8e252a0

Browse files
authored
Merge pull request #3 from NellyWhads/test_breakdown
Add support for test breakdown per worker
2 parents 2f8a310 + e6a5fa3 commit 8e252a0

File tree

9 files changed

+234
-117
lines changed

9 files changed

+234
-117
lines changed

README.md

+66-25
Original file line numberDiff line numberDiff line change
@@ -12,33 +12,74 @@ $ pip install pytest-xdist-worker-stats
1212

1313
All that is needed is to have xdist installed & enabled, and to run tests in multiple workers.
1414

15-
## Example output
15+
### Default mode
16+
17+
```shell
18+
pytest {all_your_options}
19+
```
20+
21+
```text
22+
============================= test session starts ==============================
23+
platform linux -- Python 3.10.7, pytest-8.1.1, pluggy-1.4.0
24+
plugins: xdist-worker-stats-0.2.0, xdist-3.5.0
25+
created: 2/2 workers
26+
2 workers [4 items]
27+
28+
.... [100%]
29+
============================== Worker statistics ===============================
30+
worker gw0 : 2 tests 0.00s runtime
31+
worker gw1 : 2 tests 0.00s runtime
32+
33+
Tests : min 2, max 2, average 2.0
34+
Runtime : min 0.00s, max 0.00s, average 0.00s
35+
============================== 4 passed in 1.82s ===============================
36+
```
37+
38+
### Summary mode
39+
40+
```shell
41+
pytest {all_your_options} --no-xdist-runtimes
42+
```
43+
44+
```text
45+
============================= test session starts ==============================
46+
platform linux -- Python 3.10.7, pytest-8.1.1, pluggy-1.4.0
47+
plugins: xdist-worker-stats-0.2.0, xdist-3.5.0
48+
created: 2/2 workers
49+
2 workers [4 items]
50+
51+
.... [100%]
52+
============================== Worker statistics ===============================
53+
Tests : min 2, max 2, average 2.0
54+
Runtime : min 0.00s, max 0.00s, average 0.00s
55+
============================== 4 passed in 1.82s ===============================
56+
```
57+
58+
### Breakdown mode
59+
60+
```shell
61+
pytest {all_your_options} --xdist-breakdown
62+
```
1663

1764
```text
18-
platform linux -- Python 3.10.11, pytest-7.3.2, pluggy-1.0.0
19-
plugins: xdist-3.3.1, xdist-worker-stats-0.1.0
20-
12 workers [359 items]
21-
.............................................................................................. [ 25%]
22-
.............................................................................................. [ 52%]
23-
.............................................................................................. [ 78%]
24-
............................................................................. [100%]
25-
========================================= Worker statistics ==========================================
26-
worker gw0 : 15 tests 12.25s runtime
27-
worker gw1 : 14 tests 12.00s runtime
28-
worker gw2 : 27 tests 11.66s runtime
29-
worker gw3 : 13 tests 12.08s runtime
30-
worker gw4 : 14 tests 12.59s runtime
31-
worker gw5 : 27 tests 12.13s runtime
32-
worker gw6 : 18 tests 12.22s runtime
33-
worker gw7 : 78 tests 12.04s runtime
34-
worker gw8 : 21 tests 12.01s runtime
35-
worker gw9 : 59 tests 12.36s runtime
36-
worker gw10 : 20 tests 11.79s runtime
37-
worker gw11 : 53 tests 12.09s runtime
38-
39-
Tests : min 13, max 78, average 29.9
40-
Runtime : min 11.66s, max 12.59s, average 12.10s
41-
======================================== 359 passed in 21.52s ========================================
65+
============================= test session starts ==============================
66+
platform linux -- Python 3.10.7, pytest-8.1.1, pluggy-1.4.0
67+
plugins: xdist-worker-stats-0.2.0, xdist-3.5.0
68+
created: 2/2 workers
69+
2 workers [4 items]
70+
71+
.... [100%]
72+
============================== Worker statistics ===============================
73+
worker gw0 : 2 tests 0.00s runtime
74+
test_plugin.py::test_bar[1]
75+
test_plugin.py::test_foo
76+
worker gw1 : 2 tests 0.00s runtime
77+
test_plugin.py::test_bar[2]
78+
test_plugin.py::test_bar[3]
79+
80+
Tests : min 2, max 2, average 2.0
81+
Runtime : min 0.00s, max 0.00s, average 0.00s
82+
============================== 4 passed in 1.82s ===============================
4283
```
4384

4485
## Development

pyproject.toml

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "pytest-xdist-worker-stats"
3-
version = "0.1.6"
3+
version = "0.2.0"
44
description = "A pytest plugin to list worker statistics after a xdist run."
55
authors = ["Mikuláš Poul <[email protected]>"]
66
license = "MIT"
@@ -29,8 +29,8 @@ pytest-xdist-worker-stats = "pytest_xdist_worker_stats"
2929

3030
[tool.poetry.dependencies]
3131
python = ">=3.8"
32-
pytest = ">7.3.2"
33-
pytest-xdist = "^3.3"
32+
pytest = ">=7.0.0"
33+
pytest-xdist = ">=3"
3434

3535
[tool.poetry.group.dev.dependencies]
3636
black = "^23.9.1"

pytest_xdist_worker_stats/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from .options import pytest_configure # noqa: F401
1+
from pytest_xdist_worker_stats.options import pytest_addoption, pytest_configure # noqa: F401
22

3-
__version__ = "0.1.6"
3+
__version__ = "0.2.0"

pytest_xdist_worker_stats/options.py

+20-14
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
1-
from typing import TYPE_CHECKING, NoReturn
1+
import pytest
22

3-
if TYPE_CHECKING:
4-
from _pytest.config import Config, PytestPluginManager
5-
from _pytest.config.argparsing import Parser
63

4+
def pytest_addoption(parser: pytest.Parser):
5+
from pytest_xdist_worker_stats.plugin import (
6+
ARGPARSE_PARSER_GROUP,
7+
ARGPARSE_REPORT_TEST_BREAKDOWN_OPTION_NAME,
8+
ARGPARSE_REPORT_WORKER_RUNTIMES_OPTION_NAME,
9+
)
710

8-
def pytest_addoption(parser: "Parser", pluginmanager: "PytestPluginManager") -> NoReturn:
9-
group = parser.getgroup("pytest-unused-fixtures")
10-
group.addoption("--unused-fixtures", action="store_true", default=False, help="Try to identify unused fixtures.")
11+
group = parser.getgroup(ARGPARSE_PARSER_GROUP)
12+
group.addoption(
13+
"--no-xdist-runtimes",
14+
action="store_false",
15+
default=True,
16+
dest=ARGPARSE_REPORT_WORKER_RUNTIMES_OPTION_NAME,
17+
help="Do not report runtimes per 'xdist' worker.",
18+
)
1119
group.addoption(
12-
"--unused-fixtures-ignore-path",
13-
metavar="PATH",
14-
type=str,
15-
default=None,
16-
action="append",
17-
help="Ignore fixtures in PATHs from unused fixtures report.",
20+
"--xdist-breakdown",
21+
action="store_true",
22+
dest=ARGPARSE_REPORT_TEST_BREAKDOWN_OPTION_NAME,
23+
help="Display test breakdown per 'xdist' worker.",
1824
)
1925

2026

21-
def pytest_configure(config: "Config") -> NoReturn:
27+
def pytest_configure(config: pytest.Config):
2228
pluginmanager = config.pluginmanager
2329
if pluginmanager.hasplugin("xdist"):
2430
from pytest_xdist_worker_stats.plugin import XdistWorkerStatsPlugin

pytest_xdist_worker_stats/plugin.py

+53-41
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
from typing import NamedTuple
44

55
import pytest
6+
from _pytest.terminal import TerminalReporter
7+
8+
ARGPARSE_PARSER_GROUP = "pytest-xdist-worker-stats"
9+
ARGPARSE_REPORT_WORKER_RUNTIMES_OPTION_NAME = "pytest_xdist_worker_stats_report_worker_runtimes"
10+
ARGPARSE_REPORT_TEST_BREAKDOWN_OPTION_NAME = "pytest_xdist_worker_stats_report_test_breakdown"
611

712
SHARED_WORKER_INFO = "worker_info"
813

914

10-
class RunStatistics(NamedTuple):
15+
class RuntimeStats(NamedTuple):
1116
mininum_tests: int
1217
maximum_tests: int
1318
average_tests: float
@@ -17,10 +22,12 @@ class RunStatistics(NamedTuple):
1722

1823

1924
class XdistWorkerStatsPlugin:
20-
def __init__(self, config):
25+
def __init__(self, config: pytest.Config):
2126
self.config = config
2227
self.test_stats = {}
23-
self.worker_test_times = {}
28+
self.worker_stats = {}
29+
self.report_worker_runtimes = config.getoption(ARGPARSE_REPORT_WORKER_RUNTIMES_OPTION_NAME, False)
30+
self.report_test_breakdown = config.getoption(ARGPARSE_REPORT_TEST_BREAKDOWN_OPTION_NAME, False)
2431

2532
def add(self, name):
2633
self.test_stats[name] = self.test_stats.get(name) or {}
@@ -32,52 +39,57 @@ def pytest_runtest_setup(self, item):
3239
@pytest.hookimpl(hookwrapper=True)
3340
def pytest_runtest_call(self, item):
3441
yield
35-
end = time.time()
36-
self.add(item.nodeid)["diff"] = end - self.add(item.nodeid)["start"]
37-
38-
if (worker := os.environ.get("PYTEST_XDIST_WORKER", "primary")) not in self.worker_test_times:
39-
self.worker_test_times[worker] = []
40-
41-
self.worker_test_times[worker].append(self.add(item.nodeid)["diff"])
42-
43-
def get_statistics(self) -> RunStatistics:
44-
workers = self.worker_test_times.keys()
45-
tests = [len(self.worker_test_times[worker]) for worker in workers]
46-
runtimes = [sum(self.worker_test_times[worker]) for worker in workers]
47-
48-
return RunStatistics(
49-
mininum_tests=min(tests),
50-
maximum_tests=max(tests),
51-
average_tests=sum(tests) / len(tests),
52-
mininum_runtime=min(runtimes),
53-
maximum_runtime=max(runtimes),
54-
average_runtime=sum(runtimes) / len(runtimes),
42+
runtime = time.time() - self.add(item.nodeid)["start"]
43+
self.add(item.nodeid)["runtime"] = runtime
44+
45+
if (worker := os.environ.get("PYTEST_XDIST_WORKER", "primary")) not in self.worker_stats:
46+
self.worker_stats[worker] = {}
47+
48+
self.worker_stats[worker][item.nodeid] = runtime
49+
50+
def get_runtime_stats(self) -> RuntimeStats:
51+
test_counts = [len(stats) for stats in self.worker_stats.values()]
52+
test_runtimes = [sum(stats.values()) for stats in self.worker_stats.values()]
53+
54+
return RuntimeStats(
55+
mininum_tests=min(test_counts),
56+
maximum_tests=max(test_counts),
57+
average_tests=sum(test_counts) / len(test_counts),
58+
mininum_runtime=min(test_runtimes),
59+
maximum_runtime=max(test_runtimes),
60+
average_runtime=sum(test_runtimes) / len(test_runtimes),
5561
)
5662

57-
def pytest_terminal_summary(self, terminalreporter):
63+
def pytest_terminal_summary(self, terminalreporter: TerminalReporter):
5864
"""
5965
If there's multiple workers, report on number of tests and total runtime.
6066
"""
6167
tr = terminalreporter
62-
if self.worker_test_times and len(self.worker_test_times) > 1:
68+
if self.worker_stats and len(self.worker_stats) > 1:
6369
tr._tw.sep("=", "Worker statistics", yellow=True)
64-
workers = sorted(self.worker_test_times.keys(), key=lambda x: int(x.lstrip("gw")))
65-
statistics = self.get_statistics()
66-
67-
for worker in workers:
68-
worker_times = self.worker_test_times[worker]
69-
tr._tw.line(f"worker {worker: <5}: {len(worker_times): >4} tests {sum(worker_times):10.2f}s runtime")
70-
71-
tr._tw.line("")
70+
worker_columns = len(max(self.worker_stats.keys(), key=len)) + 2
71+
72+
if self.report_worker_runtimes:
73+
for worker, stats in sorted(self.worker_stats.items()):
74+
runtimes = stats.values()
75+
tr._tw.line(
76+
f"worker {worker: <{worker_columns}}: {len(runtimes): >4} tests {sum(runtimes):10.2f}s runtime"
77+
)
78+
if self.report_test_breakdown:
79+
for nodeid in sorted(stats.keys()):
80+
tr._tw.line(f" {nodeid}")
81+
tr._tw.line("")
82+
83+
runtime_stats = self.get_runtime_stats()
7284
tr._tw.line(
73-
f"Tests : min {statistics.mininum_tests: >8}, "
74-
f"max {statistics.maximum_tests: >8}, "
75-
f"average {statistics.average_tests:.1f}"
85+
f"Tests : min {runtime_stats.mininum_tests: >8}, "
86+
f"max {runtime_stats.maximum_tests: >8}, "
87+
f"average {runtime_stats.average_tests:.1f}"
7688
)
7789
tr._tw.line(
78-
f"Runtime : min {statistics.mininum_runtime:7.2f}s, "
79-
f"max {statistics.maximum_runtime:7.2f}s, "
80-
f"average {statistics.average_runtime:.2f}s"
90+
f"Runtime : min {runtime_stats.mininum_runtime:7.2f}s, "
91+
f"max {runtime_stats.maximum_runtime:7.2f}s, "
92+
f"average {runtime_stats.average_runtime:.2f}s"
8193
)
8294

8395
def pytest_testnodedown(self, node, error):
@@ -88,7 +100,7 @@ def pytest_testnodedown(self, node, error):
88100
hasattr(node, "workeroutput")
89101
and (node_worker_stats := node.workeroutput.get(SHARED_WORKER_INFO)) is not None
90102
):
91-
self.worker_test_times.update(dict(node_worker_stats))
103+
self.worker_stats.update(dict(node_worker_stats))
92104

93105
@pytest.hookimpl(hookwrapper=True, trylast=True)
94106
def pytest_sessionfinish(self, session, exitstatus):
@@ -98,4 +110,4 @@ def pytest_sessionfinish(self, session, exitstatus):
98110
"""
99111
yield
100112
if hasattr(self.config, "workeroutput"):
101-
self.config.workeroutput[SHARED_WORKER_INFO] = self.worker_test_times
113+
self.config.workeroutput[SHARED_WORKER_INFO] = self.worker_stats

tests/conftest.py

+41
Original file line numberDiff line numberDiff line change
@@ -1 +1,42 @@
1+
import pytest
2+
13
pytest_plugins = ["pytester"]
4+
5+
6+
@pytest.fixture
7+
def sample_testfile(pytester: pytest.Pytester):
8+
code = """
9+
import pytest
10+
11+
def test_foo():
12+
pass
13+
14+
@pytest.mark.parametrize("fix1", (1, 2, 3))
15+
def test_bar(fix1):
16+
pass
17+
"""
18+
pytester.makepyfile(test_plugin=code)
19+
20+
21+
expected_header_lines = [
22+
"*Worker statistics*",
23+
]
24+
25+
expected_statistics_lines = [
26+
"Tests : min 2, max 2, average 2.0",
27+
"Runtime : min 0.00s, max 0.00s, average 0.00s",
28+
]
29+
30+
expected_runtime_lines = [
31+
"worker gw0 : 2 tests 0.00s runtime",
32+
"worker gw1 : 2 tests 0.00s runtime",
33+
]
34+
35+
expected_breakdown_lines = [
36+
"worker gw0 : 2 tests 0.00s runtime",
37+
" test_plugin.py::test_bar[1]",
38+
" test_plugin.py::test_foo",
39+
"worker gw1 : 2 tests 0.00s runtime",
40+
" test_plugin.py::test_bar[2]",
41+
" test_plugin.py::test_bar[3]",
42+
]

0 commit comments

Comments
 (0)