Skip to content

Commit cb0af4a

Browse files
authored
refactor: extract loopback Zeroconf fixtures and mock_incoming_msg helper (#1758)
1 parent f4b5066 commit cb0af4a

5 files changed

Lines changed: 64 additions & 61 deletions

File tree

tests/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@
2626
import platform
2727
import socket
2828
import time
29+
from collections.abc import Iterable
2930
from functools import cache
3031
from unittest import mock
3132

3233
import ifaddr
3334

34-
from zeroconf import DNSIncoming, DNSQuestion, DNSRecord, Zeroconf
35+
from zeroconf import DNSIncoming, DNSOutgoing, DNSQuestion, DNSRecord, Zeroconf, const
3536
from zeroconf._history import QuestionHistory
3637

3738
_MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution
@@ -70,6 +71,14 @@ def suppresses(self, question: DNSQuestion, now: float, known_answers: set[DNSRe
7071
return False
7172

7273

74+
def mock_incoming_msg(records: Iterable[DNSRecord]) -> DNSIncoming:
75+
"""Build a `DNSIncoming` response message from a list of `DNSRecord`s."""
76+
generated = DNSOutgoing(const._FLAGS_QR_RESPONSE)
77+
for record in records:
78+
generated.add_answer_at_time(record, 0)
79+
return DNSIncoming(generated.packets()[0])
80+
81+
7382
def _inject_responses(zc: Zeroconf, msgs: list[DNSIncoming]) -> None:
7483
"""Inject a DNSIncoming response."""
7584
assert zc.loop is not None

tests/conftest.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@
33
from __future__ import annotations
44

55
import threading
6-
from collections.abc import Generator
6+
from collections.abc import AsyncGenerator, Generator
77
from unittest.mock import patch
88

99
import pytest
10+
import pytest_asyncio
1011

11-
from zeroconf import _core, const
12+
from zeroconf import Zeroconf, _core, const
1213
from zeroconf._handlers import query_handler
1314
from zeroconf._services import browser as service_browser
1415
from zeroconf._services import info as service_info
16+
from zeroconf.asyncio import AsyncZeroconf
1517

1618

1719
@pytest.fixture(autouse=True)
@@ -23,6 +25,36 @@ def verify_threads_ended():
2325
assert not threads
2426

2527

28+
@pytest.fixture
29+
def zc_loopback() -> Generator[Zeroconf]:
30+
"""Yield a loopback `Zeroconf` and close it on teardown.
31+
32+
Replaces the inline `zc = Zeroconf(interfaces=["127.0.0.1"])` +
33+
explicit `zc.close()` pattern duplicated across the suite. Calling
34+
`zc.close()` inside a test is still safe — `close()` is idempotent.
35+
"""
36+
zc = Zeroconf(interfaces=["127.0.0.1"])
37+
try:
38+
yield zc
39+
finally:
40+
zc.close()
41+
42+
43+
@pytest_asyncio.fixture
44+
async def aiozc_loopback() -> AsyncGenerator[AsyncZeroconf]:
45+
"""Yield a loopback `AsyncZeroconf` and close it on teardown.
46+
47+
Replaces the inline `aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])`
48+
+ explicit `await aiozc.async_close()` pattern duplicated across the
49+
suite. Calling `async_close()` inside a test is still safe.
50+
"""
51+
aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
52+
try:
53+
yield aiozc
54+
finally:
55+
await aiozc.async_close()
56+
57+
2658
@pytest.fixture
2759
def run_isolated():
2860
"""Change the mDNS port to run the test in isolation."""

tests/services/test_browser.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import socket
99
import time
1010
import unittest
11-
from collections.abc import Iterable
1211
from threading import Event
1312
from typing import cast
1413
from unittest.mock import patch
@@ -36,6 +35,7 @@
3635
_inject_response,
3736
_wait_for_start,
3837
has_working_ipv6,
38+
mock_incoming_msg,
3939
time_changed_millis,
4040
)
4141

@@ -54,13 +54,6 @@ def teardown_module():
5454
log.setLevel(original_logging_level)
5555

5656

57-
def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming:
58-
generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE)
59-
for record in records:
60-
generated.add_answer_at_time(record, 0)
61-
return r.DNSIncoming(generated.packets()[0])
62-
63-
6457
def test_service_browser_cancel_multiple_times():
6558
"""Test we can cancel a ServiceBrowser multiple times before close."""
6659

tests/services/test_info.py

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import socket
99
import threading
1010
import unittest
11-
from collections.abc import Iterable
1211
from ipaddress import ip_address
1312
from threading import Event
1413
from unittest.mock import patch
@@ -23,7 +22,7 @@
2322
from zeroconf._utils.net import IPVersion
2423
from zeroconf.asyncio import AsyncZeroconf
2524

26-
from .. import QUICK_REQUEST_TIMEOUT_MS, _inject_response, has_working_ipv6
25+
from .. import QUICK_REQUEST_TIMEOUT_MS, _inject_response, has_working_ipv6, mock_incoming_msg
2726

2827
log = logging.getLogger("zeroconf")
2928
original_logging_level = logging.NOTSET
@@ -279,14 +278,6 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()):
279278
# patch the zeroconf send
280279
with patch.object(zc, "async_send", send):
281280

282-
def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming:
283-
generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE)
284-
285-
for record in records:
286-
generated.add_answer_at_time(record, 0)
287-
288-
return r.DNSIncoming(generated.packets()[0])
289-
290281
def get_service_info_helper(zc, type, name):
291282
nonlocal service_info
292283
service_info = zc.get_service_info(type, name)
@@ -422,14 +413,6 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()):
422413
# patch the zeroconf send
423414
with patch.object(zc, "async_send", send):
424415

425-
def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming:
426-
generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE)
427-
428-
for record in records:
429-
generated.add_answer_at_time(record, 0)
430-
431-
return r.DNSIncoming(generated.packets()[0])
432-
433416
def get_service_info_helper(zc, type, name, timeout):
434417
nonlocal service_info
435418
service_info = zc.get_service_info(type, name, timeout)
@@ -552,12 +535,6 @@ def test_get_info_single(self):
552535
),
553536
]
554537

555-
def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming:
556-
generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE)
557-
for record in records:
558-
generated.add_answer_at_time(record, 0)
559-
return r.DNSIncoming(generated.packets()[0])
560-
561538
sent_queries: list[r.DNSOutgoing] = []
562539

563540
def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()):

tests/test_engine.py

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -74,39 +74,31 @@ async def test_reaper():
7474

7575

7676
@pytest.mark.asyncio
77-
async def test_setup_releases_socket_ownership() -> None:
77+
async def test_setup_releases_socket_ownership(aiozc_loopback: AsyncZeroconf) -> None:
7878
"""Engine releases its pending-socket refs once each socket has a transport."""
79-
aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
80-
try:
81-
await aiozc.zeroconf.async_wait_for_start()
82-
engine = aiozc.zeroconf.engine
83-
assert engine._listen_socket is None
84-
assert engine._respond_sockets == []
85-
assert engine.readers
86-
assert engine.senders
87-
finally:
88-
await aiozc.async_close()
79+
await aiozc_loopback.zeroconf.async_wait_for_start()
80+
engine = aiozc_loopback.zeroconf.engine
81+
assert engine._listen_socket is None
82+
assert engine._respond_sockets == []
83+
assert engine.readers
84+
assert engine.senders
8985

9086

9187
@pytest.mark.asyncio
92-
async def test_async_close_propagates_outer_cancellation() -> None:
88+
async def test_async_close_propagates_outer_cancellation(aiozc_loopback: AsyncZeroconf) -> None:
9389
"""Outer-task cancellation while awaiting setup propagates to the caller."""
94-
aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
90+
await aiozc_loopback.zeroconf.async_wait_for_start()
91+
engine = aiozc_loopback.zeroconf.engine
92+
loop = asyncio.get_running_loop()
93+
original_task = engine._setup_task
94+
fake_task = loop.create_future()
95+
fake_task.set_exception(asyncio.CancelledError())
96+
engine._setup_task = fake_task # type: ignore[assignment]
9597
try:
96-
await aiozc.zeroconf.async_wait_for_start()
97-
engine = aiozc.zeroconf.engine
98-
loop = asyncio.get_running_loop()
99-
original_task = engine._setup_task
100-
fake_task = loop.create_future()
101-
fake_task.set_exception(asyncio.CancelledError())
102-
engine._setup_task = fake_task # type: ignore[assignment]
103-
try:
104-
with pytest.raises(asyncio.CancelledError):
105-
await engine._async_close()
106-
finally:
107-
engine._setup_task = original_task
98+
with pytest.raises(asyncio.CancelledError):
99+
await engine._async_close()
108100
finally:
109-
await aiozc.async_close()
101+
engine._setup_task = original_task
110102

111103

112104
@pytest.mark.asyncio

0 commit comments

Comments
 (0)