Skip to content

Commit 8378c01

Browse files
committed
more mocking for test_map
1 parent 6ec6077 commit 8378c01

1 file changed

Lines changed: 72 additions & 77 deletions

File tree

tests/test_qthreadexec.py

Lines changed: 72 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@
44
# BSD License
55
import logging
66
import threading
7-
import time
87
import weakref
98
from concurrent.futures import Future, TimeoutError
10-
from itertools import islice
119
from unittest.mock import Mock, patch
1210

1311
import pytest
@@ -48,6 +46,18 @@ def shutdown_executor():
4846
return exe
4947

5048

49+
@pytest.fixture
50+
def executor0():
51+
"""
52+
Provides a QThreadExecutor with max_workers=0 for deterministic testing.
53+
"""
54+
executor = qasync.QThreadExecutor(max_workers=0)
55+
try:
56+
yield executor
57+
finally:
58+
executor.shutdown(wait=True, cancel_futures=False)
59+
60+
5161
@pytest.mark.parametrize("wait", [True, False])
5262
def test_shutdown_after_shutdown(shutdown_executor, wait):
5363
# it is safe to shutdown twice
@@ -125,16 +135,13 @@ def test_context(executor):
125135

126136

127137
@pytest.mark.parametrize("cancel", [True, False])
128-
def test_shutdown_cancel_futures(cancel):
138+
def test_shutdown_cancel_futures(executor0, cancel):
129139
"""Test that shutdown with cancel_futures=True cancels all remaining futures in the queue."""
130140

131-
# Create an executor with no workers so futures stay queued and never execute
132-
executor = qasync.QThreadExecutor(max_workers=0)
133-
134-
futures = [executor.submit(lambda: None) for _ in range(10)]
141+
futures = [executor0.submit(lambda: None) for _ in range(10)]
135142

136143
# Shutdown with cancel_futures parameter
137-
executor.shutdown(wait=False, cancel_futures=cancel)
144+
executor0.shutdown(wait=False, cancel_futures=cancel)
138145

139146
if cancel:
140147
# All futures should be cancelled since no workers consumed them
@@ -149,8 +156,6 @@ def test_shutdown_cancel_futures(cancel):
149156
f"Expected no futures to be cancelled, got {cancelled_count}"
150157
)
151158

152-
executor.shutdown(wait=True, cancel_futures=False)
153-
154159

155160
def test_map(executor):
156161
"""Basic test of executor map functionality"""
@@ -161,86 +166,78 @@ def test_map(executor):
161166
assert results == [0, 2, 4, 6, 8, 10, 12, 14, 16]
162167

163168

164-
def test_map_timeout(executor):
165-
"""Test that map with timeout raises TimeoutError and cancels futures"""
166-
results = []
169+
def test_map_timeout(executor0):
170+
"""Test that map with timeout propagates the timeout parameter to future.result()"""
167171

168-
def func(x):
169-
nonlocal results
170-
time.sleep(0.05)
171-
results.append(x)
172-
return x
172+
f = Mock(spec=Future)
173+
f.result = Mock(side_effect=TimeoutError("Timeout"))
174+
f.cancel = Mock(return_value=True)
173175

174-
start = time.monotonic()
175-
with pytest.raises(TimeoutError):
176-
list(executor.map(func, range(10), timeout=0.01))
177-
duration = time.monotonic() - start
178-
# this test is flaky on some platforms, so we give it a wide bearth.
179-
assert duration < 0.1
176+
with patch.object(executor0, "submit", return_value=f):
177+
with pytest.raises(TimeoutError, match="Timeout"):
178+
list(executor0.map(lambda x: x, [1], timeout=0.5))
180179

181-
executor.shutdown(wait=True)
182-
# only about half of the tasks should have completed
183-
# because the max number of workers is 5 and the rest of
184-
# the tasks were not started at the time of the cancel.
185-
assert set(results) != {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
180+
# Verify the timeout parameter was passed to result() (not None)
181+
# Note: The timeout is calculated as (deadline - time.monotonic()), so it will be
182+
# slightly less than 0.5 due to the time taken to submit futures and start iteration
183+
assert f.result.called
184+
f_timeout = f.result.call_args[0][0] if f.result.call_args[0] else None
185+
assert f_timeout is not None
186+
assert f_timeout <= 0.5
186187

187188

188-
def test_map_error(executor):
189+
def test_map_error(executor0):
189190
"""Test that map with an exception will raise, and remaining tasks are cancelled"""
190-
results = []
191191

192-
def func(x):
193-
nonlocal results
194-
time.sleep(0.05)
195-
if len(results) == 5:
196-
raise ValueError("Test error")
197-
results.append(x)
198-
return x
192+
# Create 3 futures: one success, one exception, one to be cancelled
193+
mock_futures = []
199194

200-
with pytest.raises(ValueError):
201-
list(executor.map(func, range(15)))
195+
# First future succeeds
196+
f0 = Mock(spec=Future)
197+
f0.result = Mock(return_value=0)
198+
f0.cancel = Mock(return_value=True)
199+
mock_futures.append(f0)
202200

203-
executor.shutdown(wait=True, cancel_futures=False)
204-
assert len(results) <= 10, "Final 5 at least should have been cancelled"
201+
# Second future raises an exception
202+
f1 = Future()
203+
f1.set_exception(ValueError("Test error"))
204+
mock_futures.append(f1)
205205

206+
# Third future should be cancelled
207+
f2 = Mock(spec=Future)
208+
f2.result = Mock(return_value=2)
209+
f2.cancel = Mock(return_value=True)
210+
mock_futures.append(f2)
206211

207-
@pytest.mark.parametrize("cancel", [True, False])
208-
def test_map_shutdown(executor, cancel):
209-
results = []
210-
211-
def func(x):
212-
nonlocal results
213-
time.sleep(0.05)
214-
results.append(x)
215-
return x
216-
217-
# Get the first few results.
218-
# Keep the iterator alive so that it isn't closed when its reference is dropped.
219-
m = executor.map(func, range(15))
220-
values = list(islice(m, 5))
221-
assert values == [0, 1, 2, 3, 4]
222-
223-
executor.shutdown(wait=True, cancel_futures=cancel)
224-
if cancel:
225-
assert len(results) < 15, "Some tasks should have been cancelled"
226-
else:
227-
assert len(results) == 15, "All tasks should have been completed"
228-
m.close()
212+
with patch.object(executor0, "submit", side_effect=mock_futures):
213+
with pytest.raises(ValueError, match="Test error"):
214+
list(executor0.map(lambda x: x, range(3)))
215+
216+
# Verify the third future was cancelled when the exception occurred
217+
assert f2.cancel.called, "Future after exception should have been cancelled"
229218

230219

231-
def test_map_start(executor):
220+
def test_map_start(executor0):
232221
"""Test that map starts tasks immediately, before iterating"""
233-
e = threading.Event()
234-
m = executor.map(lambda x: (e.set(), x), range(1))
235-
e.wait(timeout=0.1)
236-
assert list(m) == [(None, 0)]
237222

223+
# Mock future that returns immediately
224+
mock_future = Mock(spec=Future)
225+
mock_future.result = Mock(return_value=0)
226+
mock_future.cancel = Mock(return_value=True)
238227

239-
def test_map_close():
240-
"""Test that closing a running map cancels all remaining tasks."""
228+
with patch.object(executor0, "submit", return_value=mock_future) as mock_submit:
229+
# Create the map - submit should be called immediately
230+
m = executor0.map(lambda x: x, range(1))
241231

242-
# Create an executor with no workers so we have full control
243-
executor = qasync.QThreadExecutor(max_workers=0)
232+
# Verify submit was called before we start iterating
233+
mock_submit.assert_called_once()
234+
235+
# Now iterate to verify the result
236+
assert list(m) == [0]
237+
238+
239+
def test_map_close(executor0):
240+
"""Test that closing a running map cancels all remaining tasks."""
244241

245242
# Create mock futures with proper result() method
246243
mock_futures = []
@@ -251,8 +248,8 @@ def test_map_close():
251248
mock_futures.append(mock_future)
252249

253250
# Mock submit to return our pre-created futures
254-
with patch.object(executor, "submit", side_effect=mock_futures):
255-
m = executor.map(lambda x: x, range(10))
251+
with patch.object(executor0, "submit", side_effect=mock_futures):
252+
m = executor0.map(lambda x: x, range(10))
256253
# must start the generator so that close() has any effect
257254
assert next(m) == 0
258255
m.close()
@@ -262,5 +259,3 @@ def test_map_close():
262259
# - The rest via the finally block when the generator is closed
263260
for i, f in enumerate(mock_futures):
264261
assert f.cancel.called, f"Future {i} should have been cancelled"
265-
266-
executor.shutdown(wait=True, cancel_futures=False)

0 commit comments

Comments
 (0)