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
THIS_DIR: str = '/home/runner/work/lms-toolkit/lms-toolkit/lms/backend'
TESTDATA_DIR: str = '/home/runner/work/lms-toolkit/lms-toolkit/lms/backend/testdata'
BACKEND_TESTS_DIR: str = '/home/runner/work/lms-toolkit/lms-toolkit/lms/backend/testdata/backendtests'
CLI_TESTDATA_DIR: str = '/home/runner/work/lms-toolkit/lms-toolkit/lms/backend/testdata/cli'
CLI_TESTS_DIR: str = '/home/runner/work/lms-toolkit/lms-toolkit/lms/backend/testdata/cli/tests'
CLI_DATA_DIR: str = '/home/runner/work/lms-toolkit/lms-toolkit/lms/backend/testdata/cli/data'
CLI_GLOBAL_CONFG_PATH: str = '/home/runner/work/lms-toolkit/lms-toolkit/lms/backend/testdata/cli/data/testing-edq-lms.json'
TEST_FUNC_NAME_PREFIX: str = 'test_'
TEST_FILENAME_GLOB_PATTERN: str = '*_backendtest.py'
class BackendTest(edq.testing.httpserver.HTTPServerTest):
 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.

BackendTest(*args: Any, **kwargs: Any)
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.

backend_type: Optional[lms.model.constants.BackendType] = None

The backend type for this test. Must be set by the child class.

server_runner: Optional[lms.testing.serverrunner.LMSServerRunner] = None

If a current server runner for this test (if there is one).

exchanges_dir: Optional[str] = None

The directory to load HTTP exchanges from. Must be set by the child class.

params_to_skip: List[str] = []

Parameters to skip while looking up exchanges.

headers_to_skip: List[str] = []

Headers to skip while looking up exchanges.

backend: Optional[lms.model.backend.APIBackend] = None

The backend for this test. Will be created during setup_server().

backend_args: Dict[str, Any] = {'testing': True}

Any additional arguments to send to get_backend().

skip_base_request_test: bool = False

Skip any base request tests.

allowed_backend: Optional[lms.model.constants.BackendType] = None

If set, skip any backend tests that do not match this filter.

skip_test_exchanges_base = False

Skip test_exchanges_base.

@classmethod
def setUpClass(cls) -> None:
 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.

@classmethod
def tearDownClass(cls) -> None:
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.

@classmethod
def setup_server(cls, server: edq.net.exchangeserver.HTTPExchangeServer) -> None:
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.

@classmethod
def create_server(cls) -> edq.net.exchangeserver.HTTPExchangeServer:
115    @classmethod
116    def create_server(cls) -> edq.net.exchangeserver.HTTPExchangeServer:
117        return LMSHTTPExchangeServer()

Create the actual exchange server.

@classmethod
def post_start_server(cls, server: edq.net.exchangeserver.HTTPExchangeServer) -> None:
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.

@classmethod
def get_base_args(cls) -> Dict[str, Any]:
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).

def setUp(self) -> None:
157    def setUp(self) -> None:
158        edq.core.log.init('ERROR')
159
160        self.clear_user()

Hook method for setting up the test fixture before exercising it.

def get_backend(self) -> lms.model.backend.APIBackend:
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.

def set_user(self, email: str) -> None:
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).

def clear_user(self) -> None:
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.

def base_request_test( self, request_function: Callable, test_cases: List[Tuple[Dict[str, Any], Any, Optional[str]]], stop_on_notimplemented: bool = True, actual_clean_func: Optional[Callable] = None, expected_clean_func: Optional[Callable] = None, assertion_func: Optional[Callable] = None, disable_server_restart: bool = False) -> None:
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), ...].

def modify_cli_test_info(self, test_info: edq.testing.cli.CLITestInfo) -> None:
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).

@classmethod
def get_test_basename(cls, path: str) -> str:
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.

class LMSHTTPExchangeServer(edq.net.exchangeserver.HTTPExchangeServer):
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.

def missing_request( self, query: edq.net.exchange.HTTPExchange) -> Optional[edq.net.exchange.HTTPExchange]:
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.

@typing.runtime_checkable
class BackendTestFunction(typing.Protocol):
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.

BackendTestFunction(*args, **kwargs)
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)
def add_test_path(target_class: type, path: str) -> None:
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.

def discover_test_cases(target_class: type) -> None:
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.

def attach_test_cases(target_class: type) -> None:
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.