From abfea482685cd5396d9c7a60797f9e56ca88342c Mon Sep 17 00:00:00 2001 From: cfirth-nasa Date: Fri, 1 Nov 2024 13:16:12 -0400 Subject: [PATCH] Add unit testing for Services --- test/onair/services/test_service_manager.py | 147 +++++++++++++ .../src/run_scripts/test_execution_engine.py | 37 ++++ test/onair/src/util/test_service_import.py | 199 ++++++++++++++++++ test/onair/src/util/test_singleton.py | 59 ++++++ 4 files changed, 442 insertions(+) create mode 100644 test/onair/services/test_service_manager.py create mode 100644 test/onair/src/util/test_service_import.py create mode 100644 test/onair/src/util/test_singleton.py diff --git a/test/onair/services/test_service_manager.py b/test/onair/services/test_service_manager.py new file mode 100644 index 00000000..cffd6efa --- /dev/null +++ b/test/onair/services/test_service_manager.py @@ -0,0 +1,147 @@ +# GSC-19165-1, "The On-Board Artificial Intelligence Research (OnAIR) Platform" +# +# Copyright © 2023 United States Government as represented by the Administrator of +# the National Aeronautics and Space Administration. No copyright is claimed in the +# United States under Title 17, U.S. Code. All Other Rights Reserved. +# +# Licensed under the NASA Open Source Agreement version 1.3 +# See "NOSA GSC-19165-1 OnAIR.pdf" +# +# NOTE: For testing singleton-like classes, a teardown procedure must be implemented +# to delete the instance after every test. Otherwise, proceeding tests will have +# access to the last test's instance. This happens due to the nature of singletons, +# which have a single instance per global scope (which the tests are running in). +# +import pytest +from unittest.mock import MagicMock, patch +from onair.services.service_manager import ServiceManager +import onair.services.service_manager as service_manager_import + + +def test_ServiceManager__init__raises_ValueError_when_service_dict_is_None_on_first_instantiation(mocker): + # Arrange / Act + with pytest.raises(ValueError) as e_info: + ServiceManager() + + # Assert + assert str(e_info.value) == "'service_dict' parameter required on first instantiation" + + +def test_ServiceManager__init__imports_services_and_sets_attributes(mocker): + # Arrange + fake_service_dict = {'service1': {'path': 'path/to/service1'}, 'service2': {'path': 'path/to/service2'}} + fake_imported_services = { + 'service1': MagicMock(), + 'service2': MagicMock() + } + mocker.patch('onair.services.service_manager.import_services', return_value=fake_imported_services) + + # Act + service_manager = ServiceManager(fake_service_dict) + + # Assert + assert service_manager.service1 == fake_imported_services['service1'] + assert service_manager.service2 == fake_imported_services['service2'] + assert service_manager._initialized == True + + # Teardown + del ServiceManager.instance + + +def test_ServiceManager__init__does_not_reinitialize_if_already_initialized(mocker): + # Arrange + fake_service_dict = {'service1': {'path': 'path/to/service1'}} + mocker.patch.object(ServiceManager, '_initialized', True, create=True) + mock_import_services = mocker.patch('onair.src.util.service_import.import_services') # called in __init__ + + # Act + ServiceManager(fake_service_dict) + + # Assert + assert mock_import_services.call_count == 0 + + # Teardown + del ServiceManager.instance + + +def test_ServiceManager_get_services_returns_dict_of_services_and_their_functions(mocker): + # Arrange + class FakeService1: + def func1(self): + pass + def _private_func(self): + pass + + class FakeService2: + def func2(self): + pass + def func3(self): + pass + + service_manager = ServiceManager.__new__(ServiceManager) + service_manager.service1 = FakeService1() + service_manager.service2 = FakeService2() + + # Act + result = service_manager.get_services() + + # Assert + assert result == { + 'service1': {'func1'}, # correctly avoids _private_func + 'service2': {'func2', 'func3'} + } + + # Teardown + del ServiceManager.instance + + +def test_ServiceManager_get_services_returns_empty_dict_when_no_services(mocker): + # Arrange + service_manager = ServiceManager.__new__(ServiceManager) + + # Act + result = service_manager.get_services() + + # Assert + assert result == {} + + # Teardown + del ServiceManager.instance + +def test_ServiceManager_get_services_returns_empty_dict_and_does_reach_second_for_loop_when_own_items_returns_only_internal_or_private_attributes(mocker): + # Arrange + service_manager = ServiceManager.__new__(ServiceManager) + fake_vars_return = MagicMock() + fake_internal_variable = MagicMock() + + mocker.patch(service_manager_import.__name__ + ".vars", return_value=fake_vars_return) + mocker.patch(service_manager_import.__name__ + ".dir") + fake_vars_return.items.return_value = iter([(fake_internal_variable, MagicMock())]) + fake_internal_variable.startswith.return_value = True + + # Act + result = service_manager.get_services() + + # Assert + assert result == {} + assert fake_internal_variable.startswith.call_count == 1 + assert fake_internal_variable.startswith.call_args_list[0].args == ('_', ) + assert service_manager_import.dir.call_count == 0 + + +def test_ServiceManager_behaves_as_singleton(mocker): + # Arrange + fake_service_dict1 = {'service1': 'path1'} + fake_imported_service = {'service1': MagicMock()} + mocker.patch('onair.services.service_manager.import_services', return_value=fake_imported_service) + + # Act + service_manager1 = ServiceManager(fake_service_dict1) + service_manager2 = ServiceManager() + + # Assert + assert service_manager1 is service_manager2 + assert hasattr(service_manager2, 'service1') + + # Teardown + del ServiceManager.instance \ No newline at end of file diff --git a/test/onair/src/run_scripts/test_execution_engine.py b/test/onair/src/run_scripts/test_execution_engine.py index 97dcad50..8a4de2e3 100644 --- a/test/onair/src/run_scripts/test_execution_engine.py +++ b/test/onair/src/run_scripts/test_execution_engine.py @@ -69,6 +69,7 @@ def test_ExecutionEngine__init__does_calls_when_config_file_is_an_occupied_strin mocker.patch.object(cut, "parse_configs") mocker.patch.object(cut, "parse_data") mocker.patch.object(cut, "setup_sim") + mocker.patch.object(cut, "setup_services") # Act cut.__init__(arg_config_file, arg_run_name, arg_save_flag) @@ -172,7 +173,40 @@ def test_ExecutionEngine_parse_configs_raises_KeyError_with_config_file_info_whe assert e_info.match( f"Config file: '{arg_config_filepath}', missing key: {missing_key}" ) + +def test_ExecutionEngine_parse_configs_does_not_raise_error_when_optional_key_SERVICES_is_not_in_config( + mocker, +): + # Arrange + arg_config_filepath = MagicMock() + fake_dict_for_Config = { + "FILES": MagicMock(), + "DATA_HANDLING": MagicMock(), + "PLUGINS": MagicMock(), + "OPTIONS": MagicMock(), + } + fake_config = MagicMock() + fake_config.__getitem__.side_effect = fake_dict_for_Config.__getitem__ + fake_config_read_result = MagicMock() + fake_config_read_result.__len__.return_value = 1 + + cut = ExecutionEngine.__new__(ExecutionEngine) + + mocker.patch( + execution_engine.__name__ + ".configparser.ConfigParser", + return_value=fake_config, + ) + mocker.patch.object(fake_config, "read", return_value=fake_config_read_result) + # config.has_section("SERVICES") is always returning true even without "SERVICES" key in fake_dict_for_config()? + mocker.patch.object(fake_config, "has_section", return_value=False) + mocker.patch.object(cut, "parse_plugins_dict", return_value=None) + + # Act + try: + cut.parse_configs(arg_config_filepath) + except Exception as e: + pytest.fail(f"Unexpected exception: {e}") def test_ExecutionEngine_parse_configs_raises_KeyError_with_config_file_info_when_a_required_FILES_subkey_is_not_in_config( mocker, @@ -325,6 +359,7 @@ def test_ExecutionEngine_parse_configs_sets_all_items_without_error(mocker): "PlannersPluginDict": "{fake_name:fake_path}", "ComplexPluginDict": "{fake_name:fake_path}", } + fake_services = MagicMock() fake_options = MagicMock() fake_plugin_dict = MagicMock() fake_plugin_dict.body = MagicMock() @@ -335,6 +370,7 @@ def test_ExecutionEngine_parse_configs_sets_all_items_without_error(mocker): "DATA_HANDLING": fake_data_handling, "PLUGINS": fake_plugins, "OPTIONS": fake_options, + "SERVICES": fake_services, } fake_config = MagicMock() fake_config.__getitem__.side_effect = fake_dict_for_Config.__getitem__ @@ -393,6 +429,7 @@ def test_ExecutionEngine_parse_configs_sets_all_items_without_error(mocker): assert fake_options.getboolean.call_count == 1 assert fake_options.getboolean.call_args_list[0].args == ("IO_Enabled",) assert cut.IO_Enabled == fake_IO_enabled + assert cut.services_dict != None # parse_plugins_dict diff --git a/test/onair/src/util/test_service_import.py b/test/onair/src/util/test_service_import.py new file mode 100644 index 00000000..667feaef --- /dev/null +++ b/test/onair/src/util/test_service_import.py @@ -0,0 +1,199 @@ +# GSC-19165-1, "The On-Board Artificial Intelligence Research (OnAIR) Platform" +# +# Copyright © 2023 United States Government as represented by the Administrator of +# the National Aeronautics and Space Administration. No copyright is claimed in the +# United States under Title 17, U.S. Code. All Other Rights Reserved. +# +# Licensed under the NASA Open Source Agreement version 1.3 +# See "NOSA GSC-19165-1 OnAIR.pdf" + +import pytest +import os +from unittest.mock import MagicMock + +import onair.src.util.service_import as service_import + + +def test_service_import_returns_empty_dict_when_given_service_dict_is_empty(): + # Arrange + arg_service_dict = {} + + # Act + result = service_import.import_services(arg_service_dict) + + # Assert + assert result == {} + + +def test_service_import_returns_service_dict_containing_single_entry_with_return_key_as_arg_dict_key_and_return_value_as_Service_object_initialized_with_arg_value_after_popping_path_key_when_not_already_in_sys_modules(mocker, ): + + # Arrange + fake_service_name = MagicMock() + fake_service_kwarg = 'i_am_fake' + fake_service_kwarg_value = MagicMock() + fake_service_info = MagicMock() + fake_mod_name = MagicMock() + fake_full_path = MagicMock() + fake_true_path = MagicMock() + + fake_service_info = {'path': fake_true_path, fake_service_kwarg: fake_service_kwarg_value} + arg_module_dict = {fake_service_name: fake_service_info} + + fake_spec = MagicMock() + fake_module = MagicMock() + fake_service = MagicMock() + fake_Service_instance = MagicMock() + + mocker.patch(service_import.__name__ + ".os.path.basename", return_value=fake_mod_name) + mocker.patch(service_import.__name__ + ".os.path.join", return_value=fake_full_path) + mocker.patch(service_import.__name__ + ".importlib.util.spec_from_file_location",return_value=fake_spec) + mocker.patch(service_import.__name__ + ".importlib.util.module_from_spec", return_value=fake_module) + + mocker.patch.object(fake_spec, "loader.exec_module") + + mocker.patch.dict(service_import.sys.modules) + import_mock = mocker.patch("builtins.__import__", return_value=fake_service) + mocker.patch.object(fake_service, "Service", return_value=fake_Service_instance) + + # Act + result = service_import.import_services(arg_module_dict) + + # Assert + # If import checks fail, test fails with INTERNALERROR due to test output using patched code + # Therefore import_mock is checked first then stopped, so other items failures output correctly + # When this test fails because of INTERNALERROR the problem is with import_mock + assert import_mock.call_count == 1 + assert import_mock.call_args_list[0].args == ( + f"{fake_mod_name}.{fake_mod_name}_service", + ) + assert import_mock.call_args_list[0].kwargs == ( + {"fromlist": [f"{fake_mod_name}_service"]} + ) + # # Without the stop of import_mock any other fails will also cause INTERNALERROR + mocker.stop(import_mock) + + assert service_import.os.path.basename.call_count == 1 + assert service_import.os.path.basename.call_args_list[0].args == (fake_true_path, ) + assert service_import.os.path.join.call_count == 1 + assert service_import.os.path.join.call_args_list[0].args == (fake_true_path, "__init__.py") + assert service_import.importlib.util.spec_from_file_location.call_count == 1 + assert service_import.importlib.util.spec_from_file_location.call_args_list[0].args == (fake_mod_name, fake_full_path) + assert service_import.importlib.util.module_from_spec.call_count == 1 + assert service_import.importlib.util.module_from_spec.call_args_list[0].args == (fake_spec,) + + assert fake_spec.loader.exec_module.call_count == 1 + assert fake_spec.loader.exec_module.call_args_list[0].args == (fake_module,) + assert fake_mod_name in service_import.sys.modules + assert service_import.sys.modules[fake_mod_name] == fake_module + + assert fake_service.Service.call_count == 1 + assert result == {fake_mod_name: fake_Service_instance} + assert fake_service.Service.call_args_list[0].kwargs == {fake_service_kwarg: fake_service_kwarg_value} + +def test_service_import_returns_service_dict_containing_multiple_entries_with_return_keys_as_arg_dict_keys_and_return_values_as_Service_objects_initialized_with_arg_value_after_popping_path_key_when_not_already_in_sys_modules(mocker, ): + + # Arrange + num_services = pytest.gen.randint(1,5) + num_service_kwargs = pytest.gen.randint(1,5) + + fake_full_path = MagicMock() + fake_mod_names = [] + fake_Service_instances = [] + + + arg_module_dict = {} + + for service_number in range(num_services): # 1-5 arbitrary length + temp_service_info = {'path': MagicMock()} + for arg_number in range(num_service_kwargs): + temp_service_info.update({f"service_arg_{arg_number}": MagicMock()}) + arg_module_dict.update({MagicMock(): temp_service_info}) + fake_Service_instances.append(MagicMock()) + fake_mod_names.append(MagicMock()) + + fake_spec = MagicMock() + fake_module = MagicMock() + fake_service = MagicMock() + + mocker.patch(service_import.__name__ + ".os.path.basename", side_effect=fake_mod_names) + mocker.patch(service_import.__name__ + ".os.path.join", return_value=fake_full_path) + mocker.patch(service_import.__name__ + ".importlib.util.spec_from_file_location",return_value=fake_spec) + mocker.patch(service_import.__name__ + ".importlib.util.module_from_spec", return_value=fake_module) + + mocker.patch.object(fake_spec, "loader.exec_module") + + mocker.patch.dict(service_import.sys.modules) + import_mock = mocker.patch("builtins.__import__", return_value=fake_service) + mocker.patch.object(fake_service, "Service", side_effect=fake_Service_instances) + + # Act + result = service_import.import_services(arg_module_dict) + + # Assert + mocker.stop(import_mock) + + for i, (service_name, service_instance) in enumerate(result.items()): + assert service_name == fake_mod_names[i] + assert service_instance == fake_Service_instances[i] + e = arg_module_dict.values() + print(e.__iter__().__next__()) + print(fake_service.Service.call_args_list[0].kwargs) + assert fake_service.Service.call_count == num_services + for i, service_info_dict in enumerate(arg_module_dict.values()): + assert fake_service.Service.call_args_list[i].kwargs == service_info_dict + +def test_service_import_returns_service_dict_containing_single_entry_with_return_key_as_arg_dict_key_and_return_value_as_Service_object_initialized_with_arg_value_after_popping_path_key_when_exists_in_sys_modules(mocker, ): + + # Arrange + fake_service_name = MagicMock() + fake_service_kwarg = 'i_am_fake' + fake_service_kwarg_value = MagicMock() + fake_service_info = MagicMock() + fake_mod_name = MagicMock() + fake_true_path = MagicMock() + + fake_service_info = {'path': fake_true_path, fake_service_kwarg: fake_service_kwarg_value} + arg_module_dict = {fake_service_name: fake_service_info} + + fake_spec = MagicMock() + fake_service = MagicMock() + fake_Service_instance = MagicMock() + + mocker.patch(service_import.__name__ + ".os.path.basename", return_value=fake_mod_name) + mocker.patch(service_import.__name__ + ".os.path.join") + mocker.patch(service_import.__name__ + ".importlib.util.spec_from_file_location") + mocker.patch(service_import.__name__ + ".importlib.util.module_from_spec") + + mocker.patch.object(fake_spec, "loader.exec_module") + + mocker.patch.dict(service_import.sys.modules, {fake_mod_name: None}) + import_mock = mocker.patch("builtins.__import__", return_value=fake_service) + mocker.patch.object(fake_service, "Service", return_value=fake_Service_instance) + + # Act + result = service_import.import_services(arg_module_dict) + + # Assert + # If import checks fail, test fails with INTERNALERROR due to test output using patched code + # Therefore import_mock is checked first then stopped, so other items failures output correctly + # When this test fails because of INTERNALERROR the problem is with import_mock + assert import_mock.call_count == 1 + assert import_mock.call_args_list[0].args == ( + f"{fake_mod_name}.{fake_mod_name}_service", + ) + assert import_mock.call_args_list[0].kwargs == ( + {"fromlist": [f"{fake_mod_name}_service"]} + ) + # # Without the stop of import_mock any other fails will also cause INTERNALERROR + mocker.stop(import_mock) + + assert service_import.os.path.basename.call_count == 1 + assert service_import.os.path.join.call_count == 0 + assert service_import.importlib.util.spec_from_file_location.call_count == 0 + assert service_import.importlib.util.module_from_spec.call_count == 0 + assert fake_spec.loader.exec_module.call_count == 0 + assert fake_mod_name in service_import.sys.modules + + assert fake_service.Service.call_count == 1 + assert result == {fake_mod_name: fake_Service_instance} + assert fake_service.Service.call_args_list[0].kwargs == {fake_service_kwarg: fake_service_kwarg_value} diff --git a/test/onair/src/util/test_singleton.py b/test/onair/src/util/test_singleton.py new file mode 100644 index 00000000..ae13be0f --- /dev/null +++ b/test/onair/src/util/test_singleton.py @@ -0,0 +1,59 @@ +# GSC-19165-1, "The On-Board Artificial Intelligence Research (OnAIR) Platform" +# +# Copyright © 2023 United States Government as represented by the Administrator of +# the National Aeronautics and Space Administration. No copyright is claimed in the +# United States under Title 17, U.S. Code. All Other Rights Reserved. +# +# Licensed under the NASA Open Source Agreement version 1.3 +# See "NOSA GSC-19165-1 OnAIR.pdf" +# +# NOTE: For testing singleton-like classes, a teardown procedure must be implemented +# to delete the instance after every test. Otherwise, proceeding tests will have +# access to the last test's instance. This happens due to the nature of singletons, +# which have a single instance per global scope (which the tests are running in). +# +import pytest +from onair.src.util.singleton import Singleton + + +def test_Singleton_creates_only_one_instance(): + # Arrange/Act + instance1 = Singleton() + instance2 = Singleton() + + # Assert + assert instance1 is instance2 + + # Teardown + del Singleton.instance + + +def test_Singleton_with_inheritance(): + # Arrange + class DerivedSingleton(Singleton): + pass + + # Act + instance1 = DerivedSingleton() + instance2 = DerivedSingleton() + + # Assert + assert instance1 is instance2 + + # Teardown + del DerivedSingleton.instance + + +def test_Singleton_maintains_state(): + # Arrange + instance1 = Singleton() + instance1.data = "test data" + + # Act + instance2 = Singleton() + + # Assert + assert instance2.data == "test data" + + # Teardown + del Singleton.instance \ No newline at end of file