Skip to content

Commit 7064903

Browse files
committed
fix double reports
1 parent a68b704 commit 7064903

4 files changed

Lines changed: 220 additions & 56 deletions

File tree

‎doc/tutorial.rst‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -530,8 +530,11 @@ output::
530530
Note: these errors were not raised because the test did not call the check methods of the server (for example HTTPServer.check()).
531531

532532
Errors which were already raised during the test (by calling the ``check()``
533-
methods) are not added to the report again, and the errors collected during a
534-
test are not leaked into the report of the other tests.
533+
methods) are not added to the report again. If the test fails in multiple
534+
phases (such as setup and teardown), each error is attached to one report
535+
only - errors collected after a failed phase are added to the report of the
536+
next failed phase. The collected errors are also not leaked into the report
537+
of the other tests.
535538

536539
This works for every test which uses the ``httpserver``, ``httpserver_ipv4``
537540
or ``httpserver_ipv6`` fixture, no configuration is needed. If you use your

‎pyproject.toml‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ implicit_reexport = false
101101

102102
[tool.black]
103103
line-length = 120
104-
safe = true
105104

106105
[tool.ruff]
107106
lint.select = ["ALL"]

‎pytest_httpserver/pytest_plugin.py‎

Lines changed: 156 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,120 @@
1414

1515
REPORT_SECTION_TITLE = "pytest-httpserver report"
1616

17-
SERVERS_STASH_KEY = pytest.StashKey[list[HTTPServerBase]]()
17+
18+
class _ServerRegistry:
19+
"""
20+
Registry of the servers used by a test.
21+
22+
It keeps track of the server findings (assertions and handler errors):
23+
24+
* which findings were already attached to a test report, so a finding is
25+
never added to more than one report, even if the test fails in multiple
26+
phases (setup, call and teardown),
27+
28+
* a snapshot of the findings taken when the fixtures of the test are torn
29+
down (see :py:meth:`snapshot`), so the findings collected during the
30+
teardown phase can be attached to the teardown report even if the
31+
server's state is cleared by another (for example session scoped)
32+
fixture finalizer before the report is made.
33+
"""
34+
35+
def __init__(self) -> None:
36+
self._servers: list[HTTPServerBase] = []
37+
self._reported_findings: list[object] = []
38+
self._snapshotted_findings: list[tuple[HTTPServerBase, str, object]] = []
39+
40+
def register(self, server: HTTPServerBase) -> bool:
41+
"""
42+
Registers the server.
43+
44+
:param server: the server object
45+
:return: ``True`` if the server was not registered before, ``False`` otherwise
46+
"""
47+
if server in self._servers:
48+
return False
49+
self._servers.append(server)
50+
return True
51+
52+
def snapshot(self, server: HTTPServerBase) -> None:
53+
"""
54+
Copies the not yet reported findings of the server to the registry.
55+
56+
This is called from a finalizer of the fixture which registered the
57+
server, so the findings collected during the teardown phase of the test
58+
are copied before any other (typically session scoped) fixture
59+
finalizer clears the state of the server.
60+
61+
:param server: the server object
62+
"""
63+
for kind, finding in self._iter_findings(server):
64+
if not self._is_reported(finding):
65+
self._snapshotted_findings.append((server, kind, finding))
66+
67+
def _is_reported(self, finding: object) -> bool:
68+
return any(finding is reported for reported in self._reported_findings)
69+
70+
def _iter_findings(self, server: HTTPServerBase) -> Generator[tuple[str, object], None, None]:
71+
for assertion in server.assertions:
72+
yield ("assertion", assertion)
73+
for handler_error in server.handler_errors:
74+
yield ("error", handler_error)
75+
76+
def collect_findings(self) -> list[str]:
77+
"""
78+
Returns the report lines of the findings which were not reported yet.
79+
80+
The returned findings are marked as reported so subsequent calls won't
81+
include them again.
82+
83+
:return: list of the report lines, empty list if there's nothing to report
84+
"""
85+
86+
section_lines: list[str] = []
87+
88+
for server in self._servers:
89+
server_lines: list[str] = []
90+
new_assertions: list[object] = []
91+
new_handler_errors: list[object] = []
92+
93+
candidates: list[tuple[str, object]] = list(self._iter_findings(server))
94+
candidates.extend(
95+
(kind, finding)
96+
for snapshotted_server, kind, finding in self._snapshotted_findings
97+
if snapshotted_server is server
98+
)
99+
100+
for kind, finding in candidates:
101+
if self._is_reported(finding):
102+
continue
103+
if kind == "assertion":
104+
new_assertions.append(finding)
105+
else:
106+
new_handler_errors.append(finding)
107+
self._reported_findings.append(finding)
108+
109+
if new_assertions:
110+
server_lines.append("Pending server-side assertions:")
111+
server_lines.append("")
112+
for index, assertion in enumerate(new_assertions, start=1):
113+
server_lines.append("{}. {}".format(index, assertion))
114+
server_lines.append("")
115+
116+
if new_handler_errors:
117+
server_lines.append("Errors raised by request handlers:")
118+
server_lines.append("")
119+
for index, error in enumerate(new_handler_errors, start=1):
120+
server_lines.append("{}. {!r}".format(index, error))
121+
server_lines.append("")
122+
123+
if server_lines:
124+
section_lines.append("--- {}".format(server))
125+
section_lines.extend(server_lines)
126+
127+
return section_lines
128+
129+
130+
SERVERS_STASH_KEY = pytest.StashKey[_ServerRegistry]()
18131

19132

20133
class Plugin:
@@ -50,12 +163,20 @@ def register_server(request: pytest.FixtureRequest, server: HTTPServerBase) -> N
50163
:param server: the server object to register
51164
"""
52165
node = request.node
53-
servers = node.stash.get(SERVERS_STASH_KEY, None)
54-
if servers is None:
55-
servers = []
56-
node.stash[SERVERS_STASH_KEY] = servers
57-
if server not in servers:
58-
servers.append(server)
166+
registry = node.stash.get(SERVERS_STASH_KEY, None)
167+
if registry is None:
168+
registry = _ServerRegistry()
169+
node.stash[SERVERS_STASH_KEY] = registry
170+
171+
def snapshot() -> None:
172+
registry.snapshot(server)
173+
174+
if registry.register(server):
175+
# copy the findings of the server before the state of the server is
176+
# cleared (which may happen in another fixture finalizer), so the
177+
# findings collected during the teardown phase of the test can be
178+
# attached to the report of the teardown phase
179+
request.addfinalizer(snapshot)
59180

60181

61182
def get_httpserver_listen_address() -> tuple[str | None, int | None]:
@@ -150,39 +271,19 @@ def httpserver_ipv6(make_httpserver_ipv6: HTTPServer, request: pytest.FixtureReq
150271
return server
151272

152273

153-
def _format_server_findings(server: HTTPServerBase) -> list[str]:
154-
"""
155-
Formats the pending (not yet raised) errors of the specified server.
156-
157-
:param server: the server object
158-
:return: list of the report lines, empty list if there's nothing to report
159-
"""
160-
161-
lines: list[str] = []
162-
163-
assertions = list(server.assertions)
164-
if assertions:
165-
lines.append("Pending server-side assertions:")
166-
lines.append("")
167-
for index, assertion in enumerate(assertions, start=1):
168-
lines.append("{}. {}".format(index, assertion))
169-
lines.append("")
170-
171-
handler_errors = list(server.handler_errors)
172-
if handler_errors:
173-
lines.append("Errors raised by request handlers:")
174-
lines.append("")
175-
for index, error in enumerate(handler_errors, start=1):
176-
lines.append("{}. {!r}".format(index, error))
177-
lines.append("")
178-
179-
return lines
274+
def _format_report_section(section_lines: list[str]) -> str:
275+
section_lines.insert(0, "Server-side errors collected by pytest-httpserver (not raised during the test):")
276+
section_lines.append(
277+
"Note: these errors were not raised because the test did not call the check "
278+
"methods of the server (for example HTTPServer.check())."
279+
)
280+
return "\n".join(section_lines)
180281

181282

182283
@pytest.hookimpl(wrapper=True)
183284
def pytest_runtest_makereport(
184285
item: pytest.Item,
185-
call: pytest.CallInfo[None], # noqa: ARG001
286+
call: pytest.CallInfo[None],
186287
) -> Generator[None, object, object]:
187288
"""
188289
Adds the pending server-side errors to the report of the failed test.
@@ -197,24 +298,26 @@ def pytest_runtest_makereport(
197298
To make the debugging easier in these cases, the errors are attached to
198299
the report of the failed test (as a report section), as they may explain
199300
why the test failed.
301+
302+
Each error is attached to one report only, so if the test fails in
303+
multiple phases (such as setup and teardown), the errors are not
304+
duplicated - errors collected after a failed phase are added to the
305+
report of the next failed phase.
306+
307+
The registry of the servers is removed from the test item when the report
308+
of the teardown phase is made, so neither the servers nor their state
309+
(such as the log of the requests) outlives the test.
200310
"""
201311
report = yield
202-
if isinstance(report, pytest.TestReport) and report.failed:
203-
servers = item.stash.get(SERVERS_STASH_KEY, None)
204-
if servers:
205-
section_lines: list[str] = []
206-
for server in servers:
207-
server_lines = _format_server_findings(server)
208-
if server_lines:
209-
section_lines.append("--- {}".format(server))
210-
section_lines.extend(server_lines)
211-
if section_lines:
212-
section_lines.insert(
213-
0, "Server-side errors collected by pytest-httpserver (not raised during the test):"
214-
)
215-
section_lines.append(
216-
"Note: these errors were not raised because the test did not call the check "
217-
"methods of the server (for example HTTPServer.check())."
218-
)
219-
report.sections.append((REPORT_SECTION_TITLE, "\n".join(section_lines)))
312+
if isinstance(report, pytest.TestReport):
313+
registry = item.stash.get(SERVERS_STASH_KEY, None)
314+
if registry is not None:
315+
if report.failed:
316+
section_lines = registry.collect_findings()
317+
if section_lines:
318+
report.sections.append((REPORT_SECTION_TITLE, _format_report_section(section_lines)))
319+
if call.when == "teardown":
320+
# this is the last report of the test, drop the registry to
321+
# not keep the server objects (and their state) alive
322+
del item.stash[SERVERS_STASH_KEY]
220323
return report

‎tests/test_makereport.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,65 @@ def test_unmatched(httpserver_ipv4):
182182
)
183183

184184

185+
def test_no_duplication_when_setup_and_teardown_fails(pytester: pytest.Pytester):
186+
pytester.makepyfile("""
187+
import pytest
188+
import requests
189+
190+
@pytest.fixture
191+
def client(httpserver):
192+
requests.get(httpserver.url_for("/unexpected_setup"))
193+
raise RuntimeError("error in setup")
194+
195+
@pytest.fixture
196+
def teardown_error():
197+
yield
198+
raise RuntimeError("error in teardown")
199+
200+
def test_setup_and_teardown_error(teardown_error, client):
201+
pass
202+
""")
203+
result = pytester.runpytest(*pytester_args)
204+
result.assert_outcomes(errors=2)
205+
# the same pending assertion must be attached to one report only
206+
title_lines = [line for line in result.stdout.lines if "pytest-httpserver report" in line]
207+
assert len(title_lines) == 1
208+
result.stdout.fnmatch_lines(
209+
[
210+
"*pytest-httpserver report*",
211+
"*No handler found for request*unexpected_setup*",
212+
]
213+
)
214+
215+
216+
def test_errors_made_in_teardown_reported_once(pytester: pytest.Pytester):
217+
pytester.makepyfile("""
218+
import pytest
219+
import requests
220+
221+
@pytest.fixture
222+
def client(httpserver):
223+
yield "client"
224+
requests.get(httpserver.url_for("/unexpected_teardown"))
225+
raise RuntimeError("error in teardown")
226+
227+
def test_teardown_error(client, httpserver):
228+
requests.get(httpserver.url_for("/unexpected_call"))
229+
assert False, "test failed"
230+
""")
231+
result = pytester.runpytest(*pytester_args)
232+
result.assert_outcomes(failed=1, errors=1)
233+
# each error is reported in the report of the phase where it was collected
234+
title_lines = [line for line in result.stdout.lines if "pytest-httpserver report" in line]
235+
assert len(title_lines) == 2
236+
result.stdout.fnmatch_lines(
237+
[
238+
"*No handler found for request*unexpected_teardown*",
239+
"*No handler found for request*unexpected_call*",
240+
]
241+
)
242+
243+
185244
def test_registered_custom_server_reported(pytester: pytest.Pytester):
186245
pytester.makepyfile("""
187246
import pytest

0 commit comments

Comments
 (0)