lms.backend.testing
1import glob 2import os 3import typing 4 5import edq.core.log 6import edq.net.exchangeserver 7import edq.testing.cli 8import edq.testing.unittest 9import edq.testing.httpserver 10import edq.util.pyimport 11import edq.util.serial 12import quizcomp.parser.math 13 14import lms.model.backend 15import lms.model.base 16import lms.model.constants 17import lms.backend.instance 18import lms.testing.serverrunner 19 20THIS_DIR: str = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) 21TESTDATA_DIR: str = os.path.join(THIS_DIR, 'testdata') 22 23BACKEND_TESTS_DIR: str = os.path.join(TESTDATA_DIR, 'backendtests') 24 25CLI_TESTDATA_DIR: str = os.path.join(TESTDATA_DIR, 'cli') 26CLI_TESTS_DIR: str = os.path.join(CLI_TESTDATA_DIR, 'tests') 27CLI_DATA_DIR: str = os.path.join(CLI_TESTDATA_DIR, 'data') 28CLI_GLOBAL_CONFG_PATH: str = os.path.join(CLI_DATA_DIR, 'testing-edq-lms.json') 29 30TEST_FUNC_NAME_PREFIX: str = 'test_' 31TEST_FILENAME_GLOB_PATTERN: str = '*_backendtest.py' 32 33class BackendTest(edq.testing.httpserver.HTTPServerTest): 34 """ 35 A special test suite that is common across all LMS backends. 36 37 This is an HTTP test that will start a test server with exchanges specific to the target backend. 38 39 A common directory (BACKEND_TESTS_DIR) will be searched for any file that starts with TEST_FILENAME_GLOB_PATTERN. 40 Then, that file will be checked for any function that starts with TEST_FUNC_NAME_PREFIX and matches BackendTestFunction. 41 """ 42 43 backend_type: typing.Union[lms.model.constants.BackendType, None] = None 44 """ 45 The backend type for this test. 46 Must be set by the child class. 47 """ 48 49 server_runner: typing.Union[lms.testing.serverrunner.LMSServerRunner, None] = None 50 """ If a current server runner for this test (if there is one). """ 51 52 exchanges_dir: typing.Union[str, None] = None 53 """ 54 The directory to load HTTP exchanges from. 55 Must be set by the child class. 56 """ 57 58 params_to_skip: typing.List[str] = [] 59 """ Parameters to skip while looking up exchanges. """ 60 61 headers_to_skip: typing.List[str] = [] 62 """ Headers to skip while looking up exchanges. """ 63 64 backend: typing.Union[lms.model.backend.APIBackend, None] = None 65 """ 66 The backend for this test. 67 Will be created during setup_server(). 68 """ 69 70 backend_args: typing.Dict[str, typing.Any] = { 71 'testing': True, 72 } 73 """ Any additional arguments to send to get_backend(). """ 74 75 skip_base_request_test: bool = False 76 """ Skip any base request tests. """ 77 78 allowed_backend: typing.Union[lms.model.constants.BackendType, None] = None 79 """ If set, skip any backend tests that do not match this filter. """ 80 81 def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: 82 super().__init__(*args, **kwargs) 83 84 self._user_email: typing.Union[str, None] = None 85 """ 86 The email of the current user for this backend. 87 Setting the user allows child classes to fetch specific information (like authentication information). 88 """ 89 90 # Most backends have to modify exchanges in some way that makes these tests unreliable. 91 # Instead, exchanges are tested enough through normal testing usage. 92 self.skip_test_exchanges_base = True 93 94 @classmethod 95 def setUpClass(cls) -> None: 96 super().setUpClass() 97 98 # Disable KaTeX for testing. 99 quizcomp.parser.math._katex_available = False 100 101 @classmethod 102 def tearDownClass(cls) -> None: 103 super().tearDownClass() 104 105 quizcomp.parser.math._katex_available = None 106 107 @classmethod 108 def setup_server(cls, server: edq.net.exchangeserver.HTTPExchangeServer) -> None: 109 if (cls.server_key == ''): 110 raise ValueError("BackendTest subclass did not set server key properly.") 111 112 edq.testing.httpserver.HTTPServerTest.setup_server(server) 113 114 @classmethod 115 def create_server(cls) -> edq.net.exchangeserver.HTTPExchangeServer: 116 return LMSHTTPExchangeServer() 117 118 @classmethod 119 def post_start_server(cls, server: edq.net.exchangeserver.HTTPExchangeServer) -> None: 120 if (cls.backend_type is None): 121 raise ValueError("BackendTest subclass did not set backend type properly.") 122 123 if (cls.exchanges_dir is None): 124 raise ValueError("BackendTest subclass did not set exchanges dir properly.") 125 126 context = edq.util.serial.SerializationContext(json_options = { 127 'strict': True, 128 }) 129 server.load_exchanges_dir(cls.exchanges_dir, context = context, finalize_func = cls._finalize_exchange) 130 131 # Update match options. 132 for (key, values) in [('params_to_skip', cls.params_to_skip), ('headers_to_skip', cls.headers_to_skip)]: 133 if (key not in server.match_options): 134 server.match_options[key] = [] 135 136 server.match_options[key] += values 137 138 config_data: typing.Dict[str, typing.Any] = { 139 'server': cls.get_server_url(), 140 'backend_type': cls.backend_type, 141 } 142 config_data.update(cls.backend_args) 143 144 config = lms.model.config.Config.from_dict(config_data) 145 cls.backend = lms.backend.instance.get_backend(config, **cls.backend_args) 146 147 if (cls.server_runner is not None): 148 cls.server_runner.backend = cls.backend 149 150 @classmethod 151 def get_base_args(cls) -> typing.Dict[str, typing.Any]: 152 """ Get a copy of the base arguments for a request (function). """ 153 154 return {} 155 156 def setUp(self) -> None: 157 edq.core.log.init('ERROR') 158 159 self.clear_user() 160 161 def get_backend(self) -> lms.model.backend.APIBackend: 162 """ Get the backend or fail if there is no backend. """ 163 164 if (self.backend is None): 165 self.fail("No backend is set.") 166 167 return self.backend 168 169 def set_user(self, email: str) -> None: 170 """ 171 Set the current user for this test. 172 This can be especially useful for child classes that need to set information based on the user 173 (like authentication headers). 174 """ 175 176 self._user_email = email 177 178 def clear_user(self) -> None: 179 """ 180 Clear the current user for this test. 181 This is automatically called before each test method. 182 """ 183 184 self._user_email = None 185 186 def base_request_test(self, 187 request_function: typing.Callable, 188 test_cases: typing.List[typing.Tuple[typing.Dict[str, typing.Any], typing.Any, typing.Union[str, None]]], 189 stop_on_notimplemented: bool = True, 190 actual_clean_func: typing.Union[typing.Callable, None] = None, 191 expected_clean_func: typing.Union[typing.Callable, None] = None, 192 assertion_func: typing.Union[typing.Callable, None] = None, 193 disable_server_restart: bool = False, 194 ) -> None: 195 """ 196 A common test for the base request functionality. 197 Test cases are passed in as: `[(kwargs (and overrides), expected, error substring), ...]`. 198 """ 199 200 if ((self.backend_type is not None) and (self.allowed_backend is not None) and (self.allowed_backend != self.backend_type)): 201 self.skipTest(f"Backend '{self.backend_type.value}' has been filtered, only allowing '{self.allowed_backend.value}'.") 202 203 skip_reason = None 204 205 for (i, test_case) in enumerate(test_cases): 206 (extra_kwargs, expected, error_substring) = test_case 207 208 with self.subTest(msg = f"Case {i}:"): 209 kwargs = self.get_base_args() 210 kwargs.update(extra_kwargs) 211 212 if (disable_server_restart and (self.server_runner is not None)): 213 self.server_runner.skip_restart = True 214 215 try: 216 actual = request_function(**kwargs) 217 except NotImplementedError as ex: 218 # We must handle this directly since we are in a subtest. 219 if (stop_on_notimplemented): 220 skip_reason = str(ex) 221 break 222 223 self.skipTest(f"Backend component not implemented: {str(ex)}.") 224 except Exception as ex: 225 error_string = self.format_error_string(ex) 226 if (error_substring is None): 227 self.fail(f"Unexpected error: '{error_string}'.") 228 229 self.assertIn(error_substring, error_string, 'Error is not as expected.') 230 continue 231 finally: 232 if (disable_server_restart and (self.server_runner is not None)): 233 self.server_runner.skip_restart = False 234 self.server_runner.restart() 235 236 if (error_substring is not None): 237 self.fail(f"Did not get expected error: '{error_substring}'.") 238 239 if (actual_clean_func is not None): 240 actual = actual_clean_func(actual) 241 242 if (expected_clean_func is not None): 243 expected = expected_clean_func(expected) 244 245 # If we expect a tuple, compare the tuple contents instead of the tuple itself. 246 if (isinstance(expected, tuple)): 247 if (not isinstance(actual, tuple)): 248 raise ValueError(f"Expected results to be a tuple, found '{type(actual)}'.") 249 250 if (len(expected) != len(actual)): 251 raise ValueError(f"Result size mismatch. Expected: {len(expected)}, Actual: {len(actual)}.") 252 else: 253 # Wrap the results in a tuple. 254 expected = (expected, ) 255 actual = (actual, ) 256 257 for i in range(len(expected)): # pylint: disable=consider-using-enumerate 258 expected_value = expected[i] 259 actual_value = actual[i] 260 261 if (assertion_func is not None): 262 assertion_func(expected_value, actual_value) 263 elif (isinstance(expected_value, lms.model.base.BaseType)): 264 self.assertJSONEqual(expected_value, actual_value) 265 elif (isinstance(expected_value, (dict, edq.util.serial.DictConverter))): 266 self.assertJSONDictEqual(expected_value, actual_value) 267 elif (isinstance(expected_value, list)): 268 self.assertJSONListEqual(expected_value, actual_value) 269 elif (isinstance(expected_value, edq.util.serial.PODConverter)): 270 self.assertJSONEqual(expected_value, actual_value) 271 else: 272 self.assertEqual(expected_value, actual_value) 273 274 if (skip_reason is not None): 275 self.skipTest(f"Backend component not implemented: {skip_reason}.") 276 277 def modify_cli_test_info(self, test_info: edq.testing.cli.CLITestInfo) -> None: 278 """ Adjust the CLI test info to include core info (like server information). """ 279 280 if ((self.backend_type is not None) and (self.backend_type.value in test_info.extra_options.get('skip_backends', []))): 281 test_info.skip_reasons.append(f"CLI test backend '{self.backend_type.value}' has been skipped by test info.") 282 return 283 284 test_info.arguments += [ 285 '--config-global', CLI_GLOBAL_CONFG_PATH, 286 '--server', self.get_server_url(), 287 '--config', 'testing=true', 288 ] 289 290 if (self.backend_type is not None): 291 test_info.arguments += ['--server-type', self.backend_type.value] 292 293 # Mark this CLI test for skipping based on the backend filter. 294 if ((self.backend_type is not None) and (self.allowed_backend is not None) and (self.allowed_backend != self.backend_type)): 295 test_info.skip_reasons.append( 296 f"CLI test backend '{self.backend_type.value}' has been filtered, only allowing '{self.allowed_backend.value}'.") 297 298 @classmethod 299 def get_test_basename(cls, path: str) -> str: 300 """ Get the test's name based off of its filename and location. """ 301 302 return edq.testing.cli.compute_ancestor_basename(path, CLI_TESTS_DIR) 303 304 @classmethod 305 def _finalize_exchange(cls, exchange: edq.net.exchange.HTTPExchange) -> edq.net.exchange.HTTPExchange: 306 """ 307 Finalize an exchange before loading it into the test server. 308 """ 309 310 # Check for redirect locations with a slug. 311 if ('location' in exchange.response_headers): 312 location = exchange.response_headers['location'].replace(lms.model.constants.SERVER_SLUG, cls.get_server_url()) 313 exchange.response_headers['location'] = location 314 315 return exchange 316 317class LMSHTTPExchangeServer(edq.net.exchangeserver.HTTPExchangeServer): 318 """ A custom exchange server for our tests. """ 319 320 def missing_request(self, query: edq.net.exchange.HTTPExchange) -> typing.Union[edq.net.exchange.HTTPExchange, None]: 321 # Specal Canvas patch to handle a multi-stage file upload (which uses redirects). 322 if (query.url_path == 'files_api'): 323 query.parameters = {'filename': query.parameters['filename']} 324 query.files = [] 325 326 exchange, _ = self.lookup_exchange(query) 327 return exchange 328 329 return None 330 331@typing.runtime_checkable 332class BackendTestFunction(typing.Protocol): 333 """ 334 A test function for backend tests. 335 A copy of this function will be attached to a test class created for each backend. 336 Therefore, `self` will be an instance of BackendTest. 337 """ 338 339 def __call__(self, test: BackendTest) -> None: 340 """ 341 A unit test for a BackendTest. 342 """ 343 344def _wrap_test_function(test_function: BackendTestFunction) -> typing.Callable: 345 """ Wrap the backend test function in some common code for backend tests. """ 346 347 def __method(self: BackendTest) -> None: 348 try: 349 test_function(self) 350 except NotImplementedError as ex: 351 # Skip tests for backend component that do not have implementations. 352 self.skipTest(f"Backend component not implemented: {str(ex)}.") 353 354 return __method 355 356def add_test_path(target_class: type, path: str) -> None: 357 """ Add tests from the given test files. """ 358 359 test_module = edq.util.pyimport.import_path(path) 360 361 for attr_name in sorted(dir(test_module)): 362 if (not attr_name.startswith(TEST_FUNC_NAME_PREFIX)): 363 continue 364 365 test_function = getattr(test_module, attr_name) 366 setattr(target_class, attr_name, _wrap_test_function(test_function)) 367 368def discover_test_cases(target_class: type) -> None: 369 """ Look in the text cases directory for any test cases and add them as test methods to the test class. """ 370 371 paths = list(sorted(glob.glob(os.path.join(BACKEND_TESTS_DIR, "**", TEST_FILENAME_GLOB_PATTERN), recursive = True))) 372 for path in sorted(paths): 373 add_test_path(target_class, path) 374 375def attach_test_cases(target_class: type) -> None: 376 """ Attach all the standard test cases to the given class. """ 377 378 # Attach backend tests. 379 discover_test_cases(target_class) 380 381 # Attach CLI tests. 382 edq.testing.cli.discover_test_cases(target_class, CLI_TESTS_DIR, CLI_DATA_DIR, test_method_wrapper = _wrap_cli_test_method) 383 384def _wrap_cli_test_method(test_method: typing.Callable, test_info_path: str) -> typing.Callable: 385 """ Wrap the CLI tests to ignore NotImplemented errors. """ 386 387 def __method(self: edq.testing.unittest.BaseTest) -> None: 388 try: 389 test_method(self, reraise_exception_types = (NotImplementedError,)) 390 except NotImplementedError as ex: 391 # Skip tests for backend component that do not have implementations. 392 self.skipTest(f"Backend component not implemented: {str(ex)}.") 393 394 return __method
34class BackendTest(edq.testing.httpserver.HTTPServerTest): 35 """ 36 A special test suite that is common across all LMS backends. 37 38 This is an HTTP test that will start a test server with exchanges specific to the target backend. 39 40 A common directory (BACKEND_TESTS_DIR) will be searched for any file that starts with TEST_FILENAME_GLOB_PATTERN. 41 Then, that file will be checked for any function that starts with TEST_FUNC_NAME_PREFIX and matches BackendTestFunction. 42 """ 43 44 backend_type: typing.Union[lms.model.constants.BackendType, None] = None 45 """ 46 The backend type for this test. 47 Must be set by the child class. 48 """ 49 50 server_runner: typing.Union[lms.testing.serverrunner.LMSServerRunner, None] = None 51 """ If a current server runner for this test (if there is one). """ 52 53 exchanges_dir: typing.Union[str, None] = None 54 """ 55 The directory to load HTTP exchanges from. 56 Must be set by the child class. 57 """ 58 59 params_to_skip: typing.List[str] = [] 60 """ Parameters to skip while looking up exchanges. """ 61 62 headers_to_skip: typing.List[str] = [] 63 """ Headers to skip while looking up exchanges. """ 64 65 backend: typing.Union[lms.model.backend.APIBackend, None] = None 66 """ 67 The backend for this test. 68 Will be created during setup_server(). 69 """ 70 71 backend_args: typing.Dict[str, typing.Any] = { 72 'testing': True, 73 } 74 """ Any additional arguments to send to get_backend(). """ 75 76 skip_base_request_test: bool = False 77 """ Skip any base request tests. """ 78 79 allowed_backend: typing.Union[lms.model.constants.BackendType, None] = None 80 """ If set, skip any backend tests that do not match this filter. """ 81 82 def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: 83 super().__init__(*args, **kwargs) 84 85 self._user_email: typing.Union[str, None] = None 86 """ 87 The email of the current user for this backend. 88 Setting the user allows child classes to fetch specific information (like authentication information). 89 """ 90 91 # Most backends have to modify exchanges in some way that makes these tests unreliable. 92 # Instead, exchanges are tested enough through normal testing usage. 93 self.skip_test_exchanges_base = True 94 95 @classmethod 96 def setUpClass(cls) -> None: 97 super().setUpClass() 98 99 # Disable KaTeX for testing. 100 quizcomp.parser.math._katex_available = False 101 102 @classmethod 103 def tearDownClass(cls) -> None: 104 super().tearDownClass() 105 106 quizcomp.parser.math._katex_available = None 107 108 @classmethod 109 def setup_server(cls, server: edq.net.exchangeserver.HTTPExchangeServer) -> None: 110 if (cls.server_key == ''): 111 raise ValueError("BackendTest subclass did not set server key properly.") 112 113 edq.testing.httpserver.HTTPServerTest.setup_server(server) 114 115 @classmethod 116 def create_server(cls) -> edq.net.exchangeserver.HTTPExchangeServer: 117 return LMSHTTPExchangeServer() 118 119 @classmethod 120 def post_start_server(cls, server: edq.net.exchangeserver.HTTPExchangeServer) -> None: 121 if (cls.backend_type is None): 122 raise ValueError("BackendTest subclass did not set backend type properly.") 123 124 if (cls.exchanges_dir is None): 125 raise ValueError("BackendTest subclass did not set exchanges dir properly.") 126 127 context = edq.util.serial.SerializationContext(json_options = { 128 'strict': True, 129 }) 130 server.load_exchanges_dir(cls.exchanges_dir, context = context, finalize_func = cls._finalize_exchange) 131 132 # Update match options. 133 for (key, values) in [('params_to_skip', cls.params_to_skip), ('headers_to_skip', cls.headers_to_skip)]: 134 if (key not in server.match_options): 135 server.match_options[key] = [] 136 137 server.match_options[key] += values 138 139 config_data: typing.Dict[str, typing.Any] = { 140 'server': cls.get_server_url(), 141 'backend_type': cls.backend_type, 142 } 143 config_data.update(cls.backend_args) 144 145 config = lms.model.config.Config.from_dict(config_data) 146 cls.backend = lms.backend.instance.get_backend(config, **cls.backend_args) 147 148 if (cls.server_runner is not None): 149 cls.server_runner.backend = cls.backend 150 151 @classmethod 152 def get_base_args(cls) -> typing.Dict[str, typing.Any]: 153 """ Get a copy of the base arguments for a request (function). """ 154 155 return {} 156 157 def setUp(self) -> None: 158 edq.core.log.init('ERROR') 159 160 self.clear_user() 161 162 def get_backend(self) -> lms.model.backend.APIBackend: 163 """ Get the backend or fail if there is no backend. """ 164 165 if (self.backend is None): 166 self.fail("No backend is set.") 167 168 return self.backend 169 170 def set_user(self, email: str) -> None: 171 """ 172 Set the current user for this test. 173 This can be especially useful for child classes that need to set information based on the user 174 (like authentication headers). 175 """ 176 177 self._user_email = email 178 179 def clear_user(self) -> None: 180 """ 181 Clear the current user for this test. 182 This is automatically called before each test method. 183 """ 184 185 self._user_email = None 186 187 def base_request_test(self, 188 request_function: typing.Callable, 189 test_cases: typing.List[typing.Tuple[typing.Dict[str, typing.Any], typing.Any, typing.Union[str, None]]], 190 stop_on_notimplemented: bool = True, 191 actual_clean_func: typing.Union[typing.Callable, None] = None, 192 expected_clean_func: typing.Union[typing.Callable, None] = None, 193 assertion_func: typing.Union[typing.Callable, None] = None, 194 disable_server_restart: bool = False, 195 ) -> None: 196 """ 197 A common test for the base request functionality. 198 Test cases are passed in as: `[(kwargs (and overrides), expected, error substring), ...]`. 199 """ 200 201 if ((self.backend_type is not None) and (self.allowed_backend is not None) and (self.allowed_backend != self.backend_type)): 202 self.skipTest(f"Backend '{self.backend_type.value}' has been filtered, only allowing '{self.allowed_backend.value}'.") 203 204 skip_reason = None 205 206 for (i, test_case) in enumerate(test_cases): 207 (extra_kwargs, expected, error_substring) = test_case 208 209 with self.subTest(msg = f"Case {i}:"): 210 kwargs = self.get_base_args() 211 kwargs.update(extra_kwargs) 212 213 if (disable_server_restart and (self.server_runner is not None)): 214 self.server_runner.skip_restart = True 215 216 try: 217 actual = request_function(**kwargs) 218 except NotImplementedError as ex: 219 # We must handle this directly since we are in a subtest. 220 if (stop_on_notimplemented): 221 skip_reason = str(ex) 222 break 223 224 self.skipTest(f"Backend component not implemented: {str(ex)}.") 225 except Exception as ex: 226 error_string = self.format_error_string(ex) 227 if (error_substring is None): 228 self.fail(f"Unexpected error: '{error_string}'.") 229 230 self.assertIn(error_substring, error_string, 'Error is not as expected.') 231 continue 232 finally: 233 if (disable_server_restart and (self.server_runner is not None)): 234 self.server_runner.skip_restart = False 235 self.server_runner.restart() 236 237 if (error_substring is not None): 238 self.fail(f"Did not get expected error: '{error_substring}'.") 239 240 if (actual_clean_func is not None): 241 actual = actual_clean_func(actual) 242 243 if (expected_clean_func is not None): 244 expected = expected_clean_func(expected) 245 246 # If we expect a tuple, compare the tuple contents instead of the tuple itself. 247 if (isinstance(expected, tuple)): 248 if (not isinstance(actual, tuple)): 249 raise ValueError(f"Expected results to be a tuple, found '{type(actual)}'.") 250 251 if (len(expected) != len(actual)): 252 raise ValueError(f"Result size mismatch. Expected: {len(expected)}, Actual: {len(actual)}.") 253 else: 254 # Wrap the results in a tuple. 255 expected = (expected, ) 256 actual = (actual, ) 257 258 for i in range(len(expected)): # pylint: disable=consider-using-enumerate 259 expected_value = expected[i] 260 actual_value = actual[i] 261 262 if (assertion_func is not None): 263 assertion_func(expected_value, actual_value) 264 elif (isinstance(expected_value, lms.model.base.BaseType)): 265 self.assertJSONEqual(expected_value, actual_value) 266 elif (isinstance(expected_value, (dict, edq.util.serial.DictConverter))): 267 self.assertJSONDictEqual(expected_value, actual_value) 268 elif (isinstance(expected_value, list)): 269 self.assertJSONListEqual(expected_value, actual_value) 270 elif (isinstance(expected_value, edq.util.serial.PODConverter)): 271 self.assertJSONEqual(expected_value, actual_value) 272 else: 273 self.assertEqual(expected_value, actual_value) 274 275 if (skip_reason is not None): 276 self.skipTest(f"Backend component not implemented: {skip_reason}.") 277 278 def modify_cli_test_info(self, test_info: edq.testing.cli.CLITestInfo) -> None: 279 """ Adjust the CLI test info to include core info (like server information). """ 280 281 if ((self.backend_type is not None) and (self.backend_type.value in test_info.extra_options.get('skip_backends', []))): 282 test_info.skip_reasons.append(f"CLI test backend '{self.backend_type.value}' has been skipped by test info.") 283 return 284 285 test_info.arguments += [ 286 '--config-global', CLI_GLOBAL_CONFG_PATH, 287 '--server', self.get_server_url(), 288 '--config', 'testing=true', 289 ] 290 291 if (self.backend_type is not None): 292 test_info.arguments += ['--server-type', self.backend_type.value] 293 294 # Mark this CLI test for skipping based on the backend filter. 295 if ((self.backend_type is not None) and (self.allowed_backend is not None) and (self.allowed_backend != self.backend_type)): 296 test_info.skip_reasons.append( 297 f"CLI test backend '{self.backend_type.value}' has been filtered, only allowing '{self.allowed_backend.value}'.") 298 299 @classmethod 300 def get_test_basename(cls, path: str) -> str: 301 """ Get the test's name based off of its filename and location. """ 302 303 return edq.testing.cli.compute_ancestor_basename(path, CLI_TESTS_DIR) 304 305 @classmethod 306 def _finalize_exchange(cls, exchange: edq.net.exchange.HTTPExchange) -> edq.net.exchange.HTTPExchange: 307 """ 308 Finalize an exchange before loading it into the test server. 309 """ 310 311 # Check for redirect locations with a slug. 312 if ('location' in exchange.response_headers): 313 location = exchange.response_headers['location'].replace(lms.model.constants.SERVER_SLUG, cls.get_server_url()) 314 exchange.response_headers['location'] = location 315 316 return exchange
A special test suite that is common across all LMS backends.
This is an HTTP test that will start a test server with exchanges specific to the target backend.
A common directory (BACKEND_TESTS_DIR) will be searched for any file that starts with TEST_FILENAME_GLOB_PATTERN. Then, that file will be checked for any function that starts with TEST_FUNC_NAME_PREFIX and matches BackendTestFunction.
82 def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: 83 super().__init__(*args, **kwargs) 84 85 self._user_email: typing.Union[str, None] = None 86 """ 87 The email of the current user for this backend. 88 Setting the user allows child classes to fetch specific information (like authentication information). 89 """ 90 91 # Most backends have to modify exchanges in some way that makes these tests unreliable. 92 # Instead, exchanges are tested enough through normal testing usage. 93 self.skip_test_exchanges_base = True
Create an instance of the class that will use the named test method when executed. Raises a ValueError if the instance does not have a method with the specified name.
The directory to load HTTP exchanges from. Must be set by the child class.
95 @classmethod 96 def setUpClass(cls) -> None: 97 super().setUpClass() 98 99 # Disable KaTeX for testing. 100 quizcomp.parser.math._katex_available = False
Hook method for setting up class fixture before running tests in the class.
102 @classmethod 103 def tearDownClass(cls) -> None: 104 super().tearDownClass() 105 106 quizcomp.parser.math._katex_available = None
Hook method for deconstructing the class fixture after running all tests in the class.
108 @classmethod 109 def setup_server(cls, server: edq.net.exchangeserver.HTTPExchangeServer) -> None: 110 if (cls.server_key == ''): 111 raise ValueError("BackendTest subclass did not set server key properly.") 112 113 edq.testing.httpserver.HTTPServerTest.setup_server(server)
An opportunity for child classes to configure the test server before starting it.
115 @classmethod 116 def create_server(cls) -> edq.net.exchangeserver.HTTPExchangeServer: 117 return LMSHTTPExchangeServer()
Create the actual exchange server.
119 @classmethod 120 def post_start_server(cls, server: edq.net.exchangeserver.HTTPExchangeServer) -> None: 121 if (cls.backend_type is None): 122 raise ValueError("BackendTest subclass did not set backend type properly.") 123 124 if (cls.exchanges_dir is None): 125 raise ValueError("BackendTest subclass did not set exchanges dir properly.") 126 127 context = edq.util.serial.SerializationContext(json_options = { 128 'strict': True, 129 }) 130 server.load_exchanges_dir(cls.exchanges_dir, context = context, finalize_func = cls._finalize_exchange) 131 132 # Update match options. 133 for (key, values) in [('params_to_skip', cls.params_to_skip), ('headers_to_skip', cls.headers_to_skip)]: 134 if (key not in server.match_options): 135 server.match_options[key] = [] 136 137 server.match_options[key] += values 138 139 config_data: typing.Dict[str, typing.Any] = { 140 'server': cls.get_server_url(), 141 'backend_type': cls.backend_type, 142 } 143 config_data.update(cls.backend_args) 144 145 config = lms.model.config.Config.from_dict(config_data) 146 cls.backend = lms.backend.instance.get_backend(config, **cls.backend_args) 147 148 if (cls.server_runner is not None): 149 cls.server_runner.backend = cls.backend
An opportunity for child classes to work with the server after it has been started, but before any tests.
151 @classmethod 152 def get_base_args(cls) -> typing.Dict[str, typing.Any]: 153 """ Get a copy of the base arguments for a request (function). """ 154 155 return {}
Get a copy of the base arguments for a request (function).
162 def get_backend(self) -> lms.model.backend.APIBackend: 163 """ Get the backend or fail if there is no backend. """ 164 165 if (self.backend is None): 166 self.fail("No backend is set.") 167 168 return self.backend
Get the backend or fail if there is no backend.
170 def set_user(self, email: str) -> None: 171 """ 172 Set the current user for this test. 173 This can be especially useful for child classes that need to set information based on the user 174 (like authentication headers). 175 """ 176 177 self._user_email = email
Set the current user for this test. This can be especially useful for child classes that need to set information based on the user (like authentication headers).
179 def clear_user(self) -> None: 180 """ 181 Clear the current user for this test. 182 This is automatically called before each test method. 183 """ 184 185 self._user_email = None
Clear the current user for this test. This is automatically called before each test method.
187 def base_request_test(self, 188 request_function: typing.Callable, 189 test_cases: typing.List[typing.Tuple[typing.Dict[str, typing.Any], typing.Any, typing.Union[str, None]]], 190 stop_on_notimplemented: bool = True, 191 actual_clean_func: typing.Union[typing.Callable, None] = None, 192 expected_clean_func: typing.Union[typing.Callable, None] = None, 193 assertion_func: typing.Union[typing.Callable, None] = None, 194 disable_server_restart: bool = False, 195 ) -> None: 196 """ 197 A common test for the base request functionality. 198 Test cases are passed in as: `[(kwargs (and overrides), expected, error substring), ...]`. 199 """ 200 201 if ((self.backend_type is not None) and (self.allowed_backend is not None) and (self.allowed_backend != self.backend_type)): 202 self.skipTest(f"Backend '{self.backend_type.value}' has been filtered, only allowing '{self.allowed_backend.value}'.") 203 204 skip_reason = None 205 206 for (i, test_case) in enumerate(test_cases): 207 (extra_kwargs, expected, error_substring) = test_case 208 209 with self.subTest(msg = f"Case {i}:"): 210 kwargs = self.get_base_args() 211 kwargs.update(extra_kwargs) 212 213 if (disable_server_restart and (self.server_runner is not None)): 214 self.server_runner.skip_restart = True 215 216 try: 217 actual = request_function(**kwargs) 218 except NotImplementedError as ex: 219 # We must handle this directly since we are in a subtest. 220 if (stop_on_notimplemented): 221 skip_reason = str(ex) 222 break 223 224 self.skipTest(f"Backend component not implemented: {str(ex)}.") 225 except Exception as ex: 226 error_string = self.format_error_string(ex) 227 if (error_substring is None): 228 self.fail(f"Unexpected error: '{error_string}'.") 229 230 self.assertIn(error_substring, error_string, 'Error is not as expected.') 231 continue 232 finally: 233 if (disable_server_restart and (self.server_runner is not None)): 234 self.server_runner.skip_restart = False 235 self.server_runner.restart() 236 237 if (error_substring is not None): 238 self.fail(f"Did not get expected error: '{error_substring}'.") 239 240 if (actual_clean_func is not None): 241 actual = actual_clean_func(actual) 242 243 if (expected_clean_func is not None): 244 expected = expected_clean_func(expected) 245 246 # If we expect a tuple, compare the tuple contents instead of the tuple itself. 247 if (isinstance(expected, tuple)): 248 if (not isinstance(actual, tuple)): 249 raise ValueError(f"Expected results to be a tuple, found '{type(actual)}'.") 250 251 if (len(expected) != len(actual)): 252 raise ValueError(f"Result size mismatch. Expected: {len(expected)}, Actual: {len(actual)}.") 253 else: 254 # Wrap the results in a tuple. 255 expected = (expected, ) 256 actual = (actual, ) 257 258 for i in range(len(expected)): # pylint: disable=consider-using-enumerate 259 expected_value = expected[i] 260 actual_value = actual[i] 261 262 if (assertion_func is not None): 263 assertion_func(expected_value, actual_value) 264 elif (isinstance(expected_value, lms.model.base.BaseType)): 265 self.assertJSONEqual(expected_value, actual_value) 266 elif (isinstance(expected_value, (dict, edq.util.serial.DictConverter))): 267 self.assertJSONDictEqual(expected_value, actual_value) 268 elif (isinstance(expected_value, list)): 269 self.assertJSONListEqual(expected_value, actual_value) 270 elif (isinstance(expected_value, edq.util.serial.PODConverter)): 271 self.assertJSONEqual(expected_value, actual_value) 272 else: 273 self.assertEqual(expected_value, actual_value) 274 275 if (skip_reason is not None): 276 self.skipTest(f"Backend component not implemented: {skip_reason}.")
A common test for the base request functionality.
Test cases are passed in as: [(kwargs (and overrides), expected, error substring), ...].
278 def modify_cli_test_info(self, test_info: edq.testing.cli.CLITestInfo) -> None: 279 """ Adjust the CLI test info to include core info (like server information). """ 280 281 if ((self.backend_type is not None) and (self.backend_type.value in test_info.extra_options.get('skip_backends', []))): 282 test_info.skip_reasons.append(f"CLI test backend '{self.backend_type.value}' has been skipped by test info.") 283 return 284 285 test_info.arguments += [ 286 '--config-global', CLI_GLOBAL_CONFG_PATH, 287 '--server', self.get_server_url(), 288 '--config', 'testing=true', 289 ] 290 291 if (self.backend_type is not None): 292 test_info.arguments += ['--server-type', self.backend_type.value] 293 294 # Mark this CLI test for skipping based on the backend filter. 295 if ((self.backend_type is not None) and (self.allowed_backend is not None) and (self.allowed_backend != self.backend_type)): 296 test_info.skip_reasons.append( 297 f"CLI test backend '{self.backend_type.value}' has been filtered, only allowing '{self.allowed_backend.value}'.")
Adjust the CLI test info to include core info (like server information).
299 @classmethod 300 def get_test_basename(cls, path: str) -> str: 301 """ Get the test's name based off of its filename and location. """ 302 303 return edq.testing.cli.compute_ancestor_basename(path, CLI_TESTS_DIR)
Get the test's name based off of its filename and location.
318class LMSHTTPExchangeServer(edq.net.exchangeserver.HTTPExchangeServer): 319 """ A custom exchange server for our tests. """ 320 321 def missing_request(self, query: edq.net.exchange.HTTPExchange) -> typing.Union[edq.net.exchange.HTTPExchange, None]: 322 # Specal Canvas patch to handle a multi-stage file upload (which uses redirects). 323 if (query.url_path == 'files_api'): 324 query.parameters = {'filename': query.parameters['filename']} 325 query.files = [] 326 327 exchange, _ = self.lookup_exchange(query) 328 return exchange 329 330 return None
A custom exchange server for our tests.
321 def missing_request(self, query: edq.net.exchange.HTTPExchange) -> typing.Union[edq.net.exchange.HTTPExchange, None]: 322 # Specal Canvas patch to handle a multi-stage file upload (which uses redirects). 323 if (query.url_path == 'files_api'): 324 query.parameters = {'filename': query.parameters['filename']} 325 query.files = [] 326 327 exchange, _ = self.lookup_exchange(query) 328 return exchange 329 330 return None
Provide the server (specifically, child classes) one last chance to resolve an incoming HTTP request before the server raises an exception. Usually exchanges are loaded from disk, but technically a server can resolve all requests with this method.
Exchanges returned from this method are not cached/saved.
332@typing.runtime_checkable 333class BackendTestFunction(typing.Protocol): 334 """ 335 A test function for backend tests. 336 A copy of this function will be attached to a test class created for each backend. 337 Therefore, `self` will be an instance of BackendTest. 338 """ 339 340 def __call__(self, test: BackendTest) -> None: 341 """ 342 A unit test for a BackendTest. 343 """
A test function for backend tests.
A copy of this function will be attached to a test class created for each backend.
Therefore, self will be an instance of BackendTest.
1953def _no_init_or_replace_init(self, *args, **kwargs): 1954 cls = type(self) 1955 1956 if cls._is_protocol: 1957 raise TypeError('Protocols cannot be instantiated') 1958 1959 # Already using a custom `__init__`. No need to calculate correct 1960 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1961 if cls.__init__ is not _no_init_or_replace_init: 1962 return 1963 1964 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1965 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1966 # searches for a proper new `__init__` in the MRO. The new `__init__` 1967 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1968 # instantiation of the protocol subclass will thus use the new 1969 # `__init__` and no longer call `_no_init_or_replace_init`. 1970 for base in cls.__mro__: 1971 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1972 if init is not _no_init_or_replace_init: 1973 cls.__init__ = init 1974 break 1975 else: 1976 # should not happen 1977 cls.__init__ = object.__init__ 1978 1979 cls.__init__(self, *args, **kwargs)
357def add_test_path(target_class: type, path: str) -> None: 358 """ Add tests from the given test files. """ 359 360 test_module = edq.util.pyimport.import_path(path) 361 362 for attr_name in sorted(dir(test_module)): 363 if (not attr_name.startswith(TEST_FUNC_NAME_PREFIX)): 364 continue 365 366 test_function = getattr(test_module, attr_name) 367 setattr(target_class, attr_name, _wrap_test_function(test_function))
Add tests from the given test files.
369def discover_test_cases(target_class: type) -> None: 370 """ Look in the text cases directory for any test cases and add them as test methods to the test class. """ 371 372 paths = list(sorted(glob.glob(os.path.join(BACKEND_TESTS_DIR, "**", TEST_FILENAME_GLOB_PATTERN), recursive = True))) 373 for path in sorted(paths): 374 add_test_path(target_class, path)
Look in the text cases directory for any test cases and add them as test methods to the test class.
376def attach_test_cases(target_class: type) -> None: 377 """ Attach all the standard test cases to the given class. """ 378 379 # Attach backend tests. 380 discover_test_cases(target_class) 381 382 # Attach CLI tests. 383 edq.testing.cli.discover_test_cases(target_class, CLI_TESTS_DIR, CLI_DATA_DIR, test_method_wrapper = _wrap_cli_test_method)
Attach all the standard test cases to the given class.