Skip to content

Commit 7a85f58

Browse files
cartertinneyCopilot
andcommitted
tests: replace timing sleeps with condition-based waits
Avoid flaky assertions that assume cross-thread and cross-event-loop work completes within a fixed delay. Wait for observable outcomes instead, directly await callback futures, and explicitly synchronize blocking inbox tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent af98a07 commit 7a85f58

9 files changed

Lines changed: 224 additions & 165 deletions

‎tests/conftest.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,55 @@
44
# license information.
55
# --------------------------------------------------------------------------
66

7+
import asyncio
8+
import threading
9+
import time
10+
711
import pytest
812

13+
14+
@pytest.fixture
15+
def wait_for():
16+
def wait_for_condition(condition, timeout=5, interval=0.01):
17+
deadline = time.monotonic() + timeout
18+
while not condition():
19+
remaining = deadline - time.monotonic()
20+
if remaining <= 0:
21+
pytest.fail("Timed out waiting for condition")
22+
time.sleep(min(interval, remaining))
23+
24+
return wait_for_condition
25+
26+
27+
@pytest.fixture
28+
def async_wait_for():
29+
async def wait_for_condition(condition, timeout=5, interval=0.01):
30+
deadline = time.monotonic() + timeout
31+
while not condition():
32+
remaining = deadline - time.monotonic()
33+
if remaining <= 0:
34+
pytest.fail("Timed out waiting for condition")
35+
await asyncio.sleep(min(interval, remaining))
36+
37+
return wait_for_condition
38+
39+
40+
@pytest.fixture
41+
def track_call_started(mocker):
42+
def track_call(obj, method_name):
43+
call_started = threading.Event()
44+
original_method = getattr(obj, method_name)
45+
46+
def tracked_call(*args, **kwargs):
47+
call_started.set()
48+
return original_method(*args, **kwargs)
49+
50+
mocker.patch.object(obj, method_name, side_effect=tracked_call)
51+
return call_started
52+
53+
return track_call
54+
55+
956
"""
1057
NOTE: ALL (yes, ALL) tests need some kind of non-specific, arbitrary exception should use
1158
one of the following fixtures. This is to ensure the tests operate correctly - many tests used to

‎tests/unit/common/pipeline/test_pipeline_stages_base.py‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
from .fixtures import ArbitraryOperation
2929
from tests.unit.common.pipeline import pipeline_stage_test
3030

31-
3231
this_module = sys.modules[__name__]
3332
logging.basicConfig(level=logging.DEBUG)
3433
pytestmark = pytest.mark.usefixtures("fake_pipeline_thread")
@@ -184,11 +183,11 @@ def event(self):
184183
return pipeline_events_base.ConnectedEvent()
185184

186185
@pytest.mark.it("Invokes the 'on_connected_handler' handler function, if set")
187-
def test_invoke_handler(self, mocker, stage, event):
186+
def test_invoke_handler(self, mocker, stage, event, wait_for):
188187
mock_handler = mocker.MagicMock()
189188
stage.on_connected_handler = mock_handler
190189
stage.handle_pipeline_event(event)
191-
time.sleep(0.1) # Needs a brief sleep so thread can switch
190+
wait_for(lambda: mock_handler.call_count >= 1)
192191
assert mock_handler.call_count == 1
193192
assert mock_handler.call_args == mocker.call()
194193

@@ -204,11 +203,11 @@ def event(self):
204203
return pipeline_events_base.DisconnectedEvent()
205204

206205
@pytest.mark.it("Invokes the 'on_disconnected_handler' handler function, if set")
207-
def test_invoke_handler(self, mocker, stage, event):
206+
def test_invoke_handler(self, mocker, stage, event, wait_for):
208207
mock_handler = mocker.MagicMock()
209208
stage.on_disconnected_handler = mock_handler
210209
stage.handle_pipeline_event(event)
211-
time.sleep(0.1) # Needs a brief sleep so thread can switch
210+
wait_for(lambda: mock_handler.call_count >= 1)
212211
assert mock_handler.call_count == 1
213212
assert mock_handler.call_args == mocker.call()
214213

@@ -224,11 +223,11 @@ def event(self):
224223
return pipeline_events_base.NewSasTokenRequiredEvent()
225224

226225
@pytest.mark.it("Invokes the 'on_new_sastoken_required_handler' handler function, if set")
227-
def test_invoke_handler(self, mocker, stage, event):
226+
def test_invoke_handler(self, mocker, stage, event, wait_for):
228227
mock_handler = mocker.MagicMock()
229228
stage.on_new_sastoken_required_handler = mock_handler
230229
stage.handle_pipeline_event(event)
231-
time.sleep(0.1) # Needs a brief sleep so thread can switch
230+
wait_for(lambda: mock_handler.call_count >= 1)
232231
assert mock_handler.call_count == 1
233232
assert mock_handler.call_args == mocker.call()
234233

@@ -246,11 +245,11 @@ def event(self, arbitrary_exception):
246245
@pytest.mark.it(
247246
"Invokes the 'on_background_exception_handler' handler function, passing the exception object, if set"
248247
)
249-
def test_invoke_handler(self, mocker, stage, event):
248+
def test_invoke_handler(self, mocker, stage, event, wait_for):
250249
mock_handler = mocker.MagicMock()
251250
stage.on_background_exception_handler = mock_handler
252251
stage.handle_pipeline_event(event)
253-
time.sleep(0.1) # Needs a brief sleep so thread can switch
252+
wait_for(lambda: mock_handler.call_count >= 1)
254253
assert mock_handler.call_count == 1
255254
assert mock_handler.call_args == mocker.call(event.e)
256255

@@ -266,11 +265,11 @@ def event(self, arbitrary_event):
266265
return arbitrary_event
267266

268267
@pytest.mark.it("Invokes the 'on_pipeline_event_handler' handler function, if set")
269-
def test_invoke_handler(self, mocker, stage, event):
268+
def test_invoke_handler(self, mocker, stage, event, wait_for):
270269
mock_handler = mocker.MagicMock()
271270
stage.on_pipeline_event_handler = mock_handler
272271
stage.handle_pipeline_event(event)
273-
time.sleep(0.1) # Needs a brief sleep so thread can switch
272+
wait_for(lambda: mock_handler.call_count >= 1)
274273
assert mock_handler.call_count == 1
275274
assert mock_handler.call_args == mocker.call(event)
276275

‎tests/unit/common/test_alarm.py‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,8 @@ def test_fn_called_w_args(self, mocker, desired_function, args, kwargs):
4242
a.start()
4343

4444
assert desired_function.call_count == 0
45-
time.sleep(1) # hasn't been 2 seconds yet
46-
assert desired_function.call_count == 0
47-
time.sleep(1.1) # it has now been just over 2 seconds, so the call HAS been made
45+
assert a.finished.wait(timeout=5)
46+
assert time.time() >= alarm_time
4847
assert desired_function.call_count == 1
4948
assert desired_function.call_args == mocker.call(*args, **kwargs)
5049

@@ -55,16 +54,18 @@ def test_fn_called_no_args(self, mocker, desired_function):
5554
a.start()
5655

5756
assert desired_function.call_count == 0
58-
time.sleep(1.1) # it has now been just over 1 second, so the call HAS been made
57+
assert a.finished.wait(timeout=5)
58+
assert time.time() >= alarm_time
5959
assert desired_function.call_count == 1
60-
desired_function.call_args == mocker.call()
60+
assert desired_function.call_args == mocker.call()
6161

6262
@pytest.mark.it("Invokes the function immediately if the given alarm time is in the past")
6363
def test_alarm_already_expired(self, mocker, desired_function):
6464
alarm_time = time.time() - 1
6565
a = Alarm(alarm_time=alarm_time, function=desired_function)
6666
a.start()
6767

68+
assert a.finished.wait(timeout=5)
6869
assert desired_function.call_count == 1
6970

7071
@pytest.mark.it(
@@ -75,9 +76,8 @@ def test_cancel_alarm(self, mocker, desired_function):
7576
a = Alarm(alarm_time=alarm_time, function=desired_function)
7677
a.start()
7778

78-
assert desired_function.call_count == 0
79-
time.sleep(1) # hasn't been 2 seconds yet
8079
assert desired_function.call_count == 0
8180
a.cancel() # cancel the alarm
82-
time.sleep(1.5) # it has now been more than 2 seconds
83-
assert desired_function.call_count == 0 # still not called
81+
a.join(timeout=5)
82+
assert not a.is_alive()
83+
assert desired_function.call_count == 0

‎tests/unit/common/test_async_adapter.py‎

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
import pytest
88
import inspect
9-
import asyncio
109
import logging
1110
import azure.iot.device.common.async_adapter as async_adapter
1211

@@ -86,10 +85,9 @@ async def test_calling_object_completes_future(self):
8685
callback = async_adapter.AwaitableCallback()
8786
assert not callback.future.done()
8887
callback()
89-
await asyncio.sleep(0.1) # wait to give time to complete the callback
88+
await callback.completion()
9089
assert callback.future.done()
9190
assert not callback.future.exception()
92-
await callback.completion()
9391

9492
@pytest.mark.it(
9593
"Completes the instance Future when a call is invoked on the instance (with return_arg_name)"
@@ -100,10 +98,10 @@ async def test_calling_object_completes_future_with_return_arg_name(
10098
callback = async_adapter.AwaitableCallback(return_arg_name="arg_name")
10199
assert not callback.future.done()
102100
callback(arg_name=fake_return_arg_value)
103-
await asyncio.sleep(0.1) # wait to give time to complete the callback
101+
result = await callback.completion()
104102
assert callback.future.done()
105103
assert not callback.future.exception()
106-
assert await callback.completion() == fake_return_arg_value
104+
assert result == fake_return_arg_value
107105

108106
@pytest.mark.it(
109107
"Raises a TypeError when a call is invoked on the instance without the correct return argument (with return_arg_name)"
@@ -122,11 +120,10 @@ async def test_raises_error_without_return_arg_name(self, arbitrary_exception):
122120
callback = async_adapter.AwaitableCallback()
123121
assert not callback.future.done()
124122
callback(error=arbitrary_exception)
125-
await asyncio.sleep(0.1) # wait to give time to complete the callback
126-
assert callback.future.done()
127-
assert callback.future.exception() == arbitrary_exception
128123
with pytest.raises(arbitrary_exception.__class__) as e_info:
129124
await callback.completion()
125+
assert callback.future.done()
126+
assert callback.future.exception() == arbitrary_exception
130127
assert e_info.value is arbitrary_exception
131128

132129
@pytest.mark.it(
@@ -136,9 +133,8 @@ async def test_raises_error_with_return_arg_name(self, arbitrary_exception):
136133
callback = async_adapter.AwaitableCallback(return_arg_name="arg_name")
137134
assert not callback.future.done()
138135
callback(error=arbitrary_exception)
139-
await asyncio.sleep(0.1) # wait to give time to complete the callback
140-
assert callback.future.done()
141-
assert callback.future.exception() == arbitrary_exception
142136
with pytest.raises(arbitrary_exception.__class__) as e_info:
143137
await callback.completion()
138+
assert callback.future.done()
139+
assert callback.future.exception() == arbitrary_exception
144140
assert e_info.value is arbitrary_exception

0 commit comments

Comments
 (0)