Skip to content

Commit eee3aa0

Browse files
fix: tests that utilize threading from failing with pypy (#383)
* fix pypy error with thread safe call counter * fix polling thread running after test completion * fix thread interfering with fetch_datafile
1 parent 1545fb8 commit eee3aa0

File tree

2 files changed

+174
-31
lines changed

2 files changed

+174
-31
lines changed

tests/test_config_manager.py

+146-25
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2019-2021, Optimizely
1+
# Copyright 2019-2022, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -218,6 +218,38 @@ def test_get_config_blocks(self):
218218
self.assertEqual(1, round(end_time - start_time))
219219

220220

221+
class MockPollingConfigManager(config_manager.PollingConfigManager):
222+
''' Wrapper class to allow manual call of fetch_datafile in the polling thread by
223+
overriding the _run method.'''
224+
def __init__(self, *args, **kwargs):
225+
self.run = False
226+
self.stop = False
227+
super().__init__(*args, **kwargs)
228+
229+
def _run(self):
230+
'''Parent thread can use self.run to start fetch_datafile in polling thread and wait for it to complete.'''
231+
while self.is_running and not self.stop:
232+
if self.run:
233+
self.fetch_datafile()
234+
self.run = False
235+
236+
237+
class MockAuthDatafilePollingConfigManager(config_manager.AuthDatafilePollingConfigManager):
238+
''' Wrapper class to allow manual call of fetch_datafile in the polling thread by
239+
overriding the _run method.'''
240+
def __init__(self, *args, **kwargs):
241+
self.run = False
242+
self.stop = False
243+
super().__init__(*args, **kwargs)
244+
245+
def _run(self):
246+
'''Parent thread can use self.run to start fetch_datafile and wait for it to complete.'''
247+
while self.is_running and not self.stop:
248+
if self.run:
249+
self.fetch_datafile()
250+
self.run = False
251+
252+
221253
@mock.patch('requests.get')
222254
class PollingConfigManagerTest(base.BaseTest):
223255
def test_init__no_sdk_key_no_url__fails(self, _):
@@ -294,9 +326,13 @@ def test_get_datafile_url__sdk_key_and_url_and_template_provided(self, _):
294326

295327
def test_set_update_interval(self, _):
296328
""" Test set_update_interval with different inputs. """
297-
with mock.patch('optimizely.config_manager.PollingConfigManager.fetch_datafile'):
329+
330+
# prevent polling thread from starting in PollingConfigManager.__init__
331+
# otherwise it can outlive this test and get out of sync with pytest
332+
with mock.patch('threading.Thread.start') as mock_thread:
298333
project_config_manager = config_manager.PollingConfigManager(sdk_key='some_key')
299334

335+
mock_thread.assert_called_once()
300336
# Assert that if invalid update_interval is set, then exception is raised.
301337
with self.assertRaisesRegex(
302338
optimizely_exceptions.InvalidInputException, 'Invalid update_interval "invalid interval" provided.',
@@ -321,9 +357,13 @@ def test_set_update_interval(self, _):
321357

322358
def test_set_blocking_timeout(self, _):
323359
""" Test set_blocking_timeout with different inputs. """
324-
with mock.patch('optimizely.config_manager.PollingConfigManager.fetch_datafile'):
360+
361+
# prevent polling thread from starting in PollingConfigManager.__init__
362+
# otherwise it can outlive this test and get out of sync with pytest
363+
with mock.patch('threading.Thread.start') as mock_thread:
325364
project_config_manager = config_manager.PollingConfigManager(sdk_key='some_key')
326365

366+
mock_thread.assert_called_once()
327367
# Assert that if invalid blocking_timeout is set, then exception is raised.
328368
with self.assertRaisesRegex(
329369
optimizely_exceptions.InvalidInputException, 'Invalid blocking timeout "invalid timeout" provided.',
@@ -352,9 +392,13 @@ def test_set_blocking_timeout(self, _):
352392

353393
def test_set_last_modified(self, _):
354394
""" Test that set_last_modified sets last_modified field based on header. """
355-
with mock.patch('optimizely.config_manager.PollingConfigManager.fetch_datafile'):
395+
396+
# prevent polling thread from starting in PollingConfigManager.__init__
397+
# otherwise it can outlive this test and get out of sync with pytest
398+
with mock.patch('threading.Thread.start') as mock_thread:
356399
project_config_manager = config_manager.PollingConfigManager(sdk_key='some_key')
357400

401+
mock_thread.assert_called_once()
358402
last_modified_time = 'Test Last Modified Time'
359403
test_response_headers = {
360404
'Last-Modified': last_modified_time,
@@ -366,24 +410,40 @@ def test_set_last_modified(self, _):
366410
def test_fetch_datafile(self, _):
367411
""" Test that fetch_datafile sets config and last_modified based on response. """
368412
sdk_key = 'some_key'
369-
with mock.patch('optimizely.config_manager.PollingConfigManager.fetch_datafile'):
370-
project_config_manager = config_manager.PollingConfigManager(sdk_key=sdk_key)
413+
414+
# use wrapper class to control start and stop of fetch_datafile
415+
# this prevents the polling thread from outliving the test
416+
# and getting out of sync with pytest
417+
project_config_manager = MockPollingConfigManager(sdk_key=sdk_key)
371418
expected_datafile_url = enums.ConfigManager.DATAFILE_URL_TEMPLATE.format(sdk_key=sdk_key)
372419
test_headers = {'Last-Modified': 'New Time'}
373420
test_datafile = json.dumps(self.config_dict_with_features)
374421
test_response = requests.Response()
375422
test_response.status_code = 200
376423
test_response.headers = test_headers
377424
test_response._content = test_datafile
378-
with mock.patch('requests.get', return_value=test_response):
379-
project_config_manager.fetch_datafile()
425+
with mock.patch('requests.get', return_value=test_response) as mock_request:
426+
# manually trigger fetch_datafile in the polling thread
427+
project_config_manager.run = True
428+
# Wait for polling thread to finish
429+
while project_config_manager.run:
430+
pass
380431

432+
mock_request.assert_called_once_with(
433+
expected_datafile_url,
434+
headers={},
435+
timeout=enums.ConfigManager.REQUEST_TIMEOUT
436+
)
381437
self.assertEqual(test_headers['Last-Modified'], project_config_manager.last_modified)
382438
self.assertIsInstance(project_config_manager.get_config(), project_config.ProjectConfig)
383439

384440
# Call fetch_datafile again and assert that request to URL is with If-Modified-Since header.
385441
with mock.patch('requests.get', return_value=test_response) as mock_requests:
386-
project_config_manager.fetch_datafile()
442+
# manually trigger fetch_datafile in the polling thread
443+
project_config_manager.run = True
444+
# Wait for polling thread to finish
445+
while project_config_manager.run:
446+
pass
387447

388448
mock_requests.assert_called_once_with(
389449
expected_datafile_url,
@@ -394,6 +454,9 @@ def test_fetch_datafile(self, _):
394454
self.assertIsInstance(project_config_manager.get_config(), project_config.ProjectConfig)
395455
self.assertTrue(project_config_manager.is_running)
396456

457+
# Shut down the polling thread
458+
project_config_manager.stop = True
459+
397460
def test_fetch_datafile__status_exception_raised(self, _):
398461
""" Test that config_manager keeps running if status code exception is raised when fetching datafile. """
399462
class MockExceptionResponse(object):
@@ -402,24 +465,40 @@ def raise_for_status(self):
402465

403466
sdk_key = 'some_key'
404467
mock_logger = mock.Mock()
405-
with mock.patch('optimizely.config_manager.PollingConfigManager.fetch_datafile'):
406-
project_config_manager = config_manager.PollingConfigManager(sdk_key=sdk_key, logger=mock_logger)
407468
expected_datafile_url = enums.ConfigManager.DATAFILE_URL_TEMPLATE.format(sdk_key=sdk_key)
408469
test_headers = {'Last-Modified': 'New Time'}
409470
test_datafile = json.dumps(self.config_dict_with_features)
410471
test_response = requests.Response()
411472
test_response.status_code = 200
412473
test_response.headers = test_headers
413474
test_response._content = test_datafile
414-
with mock.patch('requests.get', return_value=test_response):
415-
project_config_manager.fetch_datafile()
416475

476+
# use wrapper class to control start and stop of fetch_datafile
477+
# this prevents the polling thread from outliving the test
478+
# and getting out of sync with pytest
479+
project_config_manager = MockPollingConfigManager(sdk_key=sdk_key, logger=mock_logger)
480+
with mock.patch('requests.get', return_value=test_response) as mock_request:
481+
# manually trigger fetch_datafile in the polling thread
482+
project_config_manager.run = True
483+
# Wait for polling thread to finish
484+
while project_config_manager.run:
485+
pass
486+
487+
mock_request.assert_called_once_with(
488+
expected_datafile_url,
489+
headers={},
490+
timeout=enums.ConfigManager.REQUEST_TIMEOUT
491+
)
417492
self.assertEqual(test_headers['Last-Modified'], project_config_manager.last_modified)
418493
self.assertIsInstance(project_config_manager.get_config(), project_config.ProjectConfig)
419494

420495
# Call fetch_datafile again, but raise exception this time
421496
with mock.patch('requests.get', return_value=MockExceptionResponse()) as mock_requests:
422-
project_config_manager.fetch_datafile()
497+
# manually trigger fetch_datafile in the polling thread
498+
project_config_manager.run = True
499+
# Wait for polling thread to finish
500+
while project_config_manager.run:
501+
pass
423502

424503
mock_requests.assert_called_once_with(
425504
expected_datafile_url,
@@ -434,22 +513,37 @@ def raise_for_status(self):
434513
# Confirm that config manager keeps running
435514
self.assertTrue(project_config_manager.is_running)
436515

516+
# Shut down the polling thread
517+
project_config_manager.stop = True
518+
437519
def test_fetch_datafile__request_exception_raised(self, _):
438520
""" Test that config_manager keeps running if a request exception is raised when fetching datafile. """
439521
sdk_key = 'some_key'
440522
mock_logger = mock.Mock()
441-
with mock.patch('optimizely.config_manager.PollingConfigManager.fetch_datafile'):
442-
project_config_manager = config_manager.PollingConfigManager(sdk_key=sdk_key, logger=mock_logger)
523+
524+
# use wrapper class to control start and stop of fetch_datafile
525+
# this prevents the polling thread from outliving the test
526+
# and getting out of sync with pytest
527+
project_config_manager = MockPollingConfigManager(sdk_key=sdk_key, logger=mock_logger)
443528
expected_datafile_url = enums.ConfigManager.DATAFILE_URL_TEMPLATE.format(sdk_key=sdk_key)
444529
test_headers = {'Last-Modified': 'New Time'}
445530
test_datafile = json.dumps(self.config_dict_with_features)
446531
test_response = requests.Response()
447532
test_response.status_code = 200
448533
test_response.headers = test_headers
449534
test_response._content = test_datafile
450-
with mock.patch('requests.get', return_value=test_response):
451-
project_config_manager.fetch_datafile()
535+
with mock.patch('requests.get', return_value=test_response) as mock_request:
536+
# manually trigger fetch_datafile in the polling thread
537+
project_config_manager.run = True
538+
# Wait for polling thread to finish
539+
while project_config_manager.run:
540+
pass
452541

542+
mock_request.assert_called_once_with(
543+
expected_datafile_url,
544+
headers={},
545+
timeout=enums.ConfigManager.REQUEST_TIMEOUT
546+
)
453547
self.assertEqual(test_headers['Last-Modified'], project_config_manager.last_modified)
454548
self.assertIsInstance(project_config_manager.get_config(), project_config.ProjectConfig)
455549

@@ -458,7 +552,11 @@ def test_fetch_datafile__request_exception_raised(self, _):
458552
'requests.get',
459553
side_effect=requests.exceptions.RequestException('Error Error !!'),
460554
) as mock_requests:
461-
project_config_manager.fetch_datafile()
555+
# manually trigger fetch_datafile in the polling thread
556+
project_config_manager.run = True
557+
# Wait for polling thread to finish
558+
while project_config_manager.run:
559+
pass
462560

463561
mock_requests.assert_called_once_with(
464562
expected_datafile_url,
@@ -473,12 +571,18 @@ def test_fetch_datafile__request_exception_raised(self, _):
473571
# Confirm that config manager keeps running
474572
self.assertTrue(project_config_manager.is_running)
475573

574+
# Shut down the polling thread
575+
project_config_manager.stop = True
576+
476577
def test_is_running(self, _):
477578
""" Test that polling thread is running after instance of PollingConfigManager is created. """
478579
with mock.patch('optimizely.config_manager.PollingConfigManager.fetch_datafile'):
479580
project_config_manager = config_manager.PollingConfigManager(sdk_key='some_key')
480581
self.assertTrue(project_config_manager.is_running)
481582

583+
# Prevent the polling thread from running fetch_datafile if it hasn't already
584+
project_config_manager._polling_thread._is_stopped = True
585+
482586

483587
@mock.patch('requests.get')
484588
class AuthDatafilePollingConfigManagerTest(base.BaseTest):
@@ -495,10 +599,14 @@ def test_set_datafile_access_token(self, _):
495599
""" Test that datafile_access_token is properly set as instance variable. """
496600
datafile_access_token = 'some_token'
497601
sdk_key = 'some_key'
498-
with mock.patch('optimizely.config_manager.AuthDatafilePollingConfigManager.fetch_datafile'):
602+
603+
# prevent polling thread from starting in PollingConfigManager.__init__
604+
# otherwise it can outlive this test and get out of sync with pytest
605+
with mock.patch('threading.Thread.start') as mock_thread:
499606
project_config_manager = config_manager.AuthDatafilePollingConfigManager(
500607
datafile_access_token=datafile_access_token, sdk_key=sdk_key)
501608

609+
mock_thread.assert_called_once()
502610
self.assertEqual(datafile_access_token, project_config_manager.datafile_access_token)
503611

504612
def test_fetch_datafile(self, _):
@@ -538,9 +646,11 @@ def test_fetch_datafile__request_exception_raised(self, _):
538646
sdk_key = 'some_key'
539647
mock_logger = mock.Mock()
540648

541-
with mock.patch('optimizely.config_manager.AuthDatafilePollingConfigManager.fetch_datafile'):
542-
project_config_manager = config_manager.AuthDatafilePollingConfigManager(
543-
datafile_access_token=datafile_access_token, sdk_key=sdk_key, logger=mock_logger)
649+
# use wrapper class to control start and stop of fetch_datafile
650+
# this prevents the polling thread from outliving the test
651+
# and getting out of sync with pytest
652+
project_config_manager = MockAuthDatafilePollingConfigManager(datafile_access_token=datafile_access_token,
653+
sdk_key=sdk_key, logger=mock_logger)
544654
expected_datafile_url = enums.ConfigManager.AUTHENTICATED_DATAFILE_URL_TEMPLATE.format(sdk_key=sdk_key)
545655
test_headers = {'Last-Modified': 'New Time'}
546656
test_datafile = json.dumps(self.config_dict_with_features)
@@ -552,7 +662,11 @@ def test_fetch_datafile__request_exception_raised(self, _):
552662
# Call fetch_datafile and assert that request was sent with correct authorization header
553663
with mock.patch('requests.get',
554664
return_value=test_response) as mock_request:
555-
project_config_manager.fetch_datafile()
665+
# manually trigger fetch_datafile in the polling thread
666+
project_config_manager.run = True
667+
# Wait for polling thread to finish
668+
while project_config_manager.run:
669+
pass
556670

557671
mock_request.assert_called_once_with(
558672
expected_datafile_url,
@@ -568,7 +682,11 @@ def test_fetch_datafile__request_exception_raised(self, _):
568682
'requests.get',
569683
side_effect=requests.exceptions.RequestException('Error Error !!'),
570684
) as mock_requests:
571-
project_config_manager.fetch_datafile()
685+
# manually trigger fetch_datafile in the polling thread
686+
project_config_manager.run = True
687+
# Wait for polling thread to finish
688+
while project_config_manager.run:
689+
pass
572690

573691
mock_requests.assert_called_once_with(
574692
expected_datafile_url,
@@ -586,3 +704,6 @@ def test_fetch_datafile__request_exception_raised(self, _):
586704
self.assertIsInstance(project_config_manager.get_config(), project_config.ProjectConfig)
587705
# Confirm that config manager keeps running
588706
self.assertTrue(project_config_manager.is_running)
707+
708+
# Shut down the polling thread
709+
project_config_manager.stop = True

tests/test_user_context.py

+28-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2021, Optimizely
1+
# Copyright 2021-2022, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -1859,6 +1859,28 @@ def clone_loop(user_context):
18591859
for x in range(100):
18601860
user_context._clone()
18611861

1862+
# custom call counter because the mock call_count is not thread safe
1863+
class MockCounter:
1864+
def __init__(self):
1865+
self.lock = threading.Lock()
1866+
self.call_count = 0
1867+
1868+
def increment(self, *args):
1869+
with self.lock:
1870+
self.call_count += 1
1871+
1872+
set_forced_decision_counter = MockCounter()
1873+
get_forced_decision_counter = MockCounter()
1874+
remove_forced_decision_counter = MockCounter()
1875+
remove_all_forced_decisions_counter = MockCounter()
1876+
clone_counter = MockCounter()
1877+
1878+
set_forced_decision_mock.side_effect = set_forced_decision_counter.increment
1879+
get_forced_decision_mock.side_effect = get_forced_decision_counter.increment
1880+
remove_forced_decision_mock.side_effect = remove_forced_decision_counter.increment
1881+
remove_all_forced_decisions_mock.side_effect = remove_all_forced_decisions_counter.increment
1882+
clone_mock.side_effect = clone_counter.increment
1883+
18621884
set_thread_1 = threading.Thread(target=set_forced_decision_loop, args=(user_context, context_1, decision_1))
18631885
set_thread_2 = threading.Thread(target=set_forced_decision_loop, args=(user_context, context_2, decision_2))
18641886
set_thread_3 = threading.Thread(target=get_forced_decision_loop, args=(user_context, context_1))
@@ -1888,8 +1910,8 @@ def clone_loop(user_context):
18881910
set_thread_7.join()
18891911
set_thread_8.join()
18901912

1891-
self.assertEqual(200, set_forced_decision_mock.call_count)
1892-
self.assertEqual(200, get_forced_decision_mock.call_count)
1893-
self.assertEqual(200, remove_forced_decision_mock.call_count)
1894-
self.assertEqual(100, remove_all_forced_decisions_mock.call_count)
1895-
self.assertEqual(100, clone_mock.call_count)
1913+
self.assertEqual(200, set_forced_decision_counter.call_count)
1914+
self.assertEqual(200, get_forced_decision_counter.call_count)
1915+
self.assertEqual(200, remove_forced_decision_counter.call_count)
1916+
self.assertEqual(100, remove_all_forced_decisions_counter.call_count)
1917+
self.assertEqual(100, clone_counter.call_count)

0 commit comments

Comments
 (0)