diff --git a/README.rst b/README.rst index a7d0374..c2302b2 100644 --- a/README.rst +++ b/README.rst @@ -40,6 +40,13 @@ django project. Configuration ------------- + +Sub-class **SmokeTestCase** and configure your test using the *TESTS_CONFIGURATION*. Further configure by defining +optional *default_* attributes (or class methods). + +Basic Configuration +******** + ``TESTS_CONFIGURATION`` of your ``TestCase`` should contain tuple/list of tuples for every request with the next structure: @@ -97,6 +104,58 @@ you can use it to transfer state between them. But take into account that #. ``user_credentials`` #. ``request_data`` +Extra Configuration +******** + +For convenience, default values can be provided for some url configuration items. If a URL test is defined which is +missing a definition for one of these values, the class definition is searched for a default value and that is used, if +present. + +For example, you may define *user_credentials* in a URL test. In cases where several URL tests have the same user +credentials, it is more convenient to define the credentials one time, as a class attribute (or method). So in the case +of user credentials, you can also define a class attribute like this, which will take effect for all test urls which +dont have user_credentials defined. Like this: + +.. code-block:: python + + class TestDemo(SmokeTestCase): + TESTS_CONFIGURATION = ( + ('page1', 200, 'GET',), + ('page2', 200, 'GET',), + ('page3', 200, 'GET',), + ('page4', 200, 'GET', { + 'user_credentials': { + 'username': 'other_user', + 'password': 'other_password', + } + }), + ('page5', 200, 'GET', { + 'user_credentials': None, + }), + ) + + default_user_credentials = { + 'username': 'my_user', + 'password': 'my_password', + } + + +In the example above, tests for page1, page2, and page3 will be run using the default credentials (*my_user*). The test +for page4 will run with special credentials (*other_user*). The test for page5 will run with no credentials, no logged +in user. + +**Note:** these default values can be either class attributes *or* class methods (taking a ``self`` parameter). + +The full list of possible, default, attributes is: + +* default_comment +* default_initialize +* default_url_args +* default_url_kwargs +* default_request_data +* default_user_credentials +* default_redirect_to + Examples -------- diff --git a/skd_smoke/__init__.py b/skd_smoke/__init__.py index 8f53e4e..67b69ff 100644 --- a/skd_smoke/__init__.py +++ b/skd_smoke/__init__.py @@ -3,6 +3,7 @@ import traceback from uuid import uuid4 +from django import VERSION from six import string_types from django.core.exceptions import ImproperlyConfigured @@ -45,6 +46,10 @@ def list_or_callable(l): return isinstance(l, (list, tuple)) or callable(l) +def string_or_callable(v): + return isinstance(v, string_types) or callable(v) + + def dict_or_callable(d): return isinstance(d, dict) or callable(d) @@ -57,9 +62,15 @@ def dict_or_callable(d): 'url_kwargs': {'type': 'dict or callable', 'func': dict_or_callable}, 'request_data': {'type': 'dict or callable', 'func': dict_or_callable}, 'user_credentials': {'type': 'dict or callable', 'func': dict_or_callable}, - 'redirect_to': {'type': 'string', 'func': check_type(string_types)}, + 'redirect_to': {'type': 'string or callable', 'func': string_or_callable}, } + +DEFAULTABLE_PARAMS = ( + 'comment', 'initialize', 'url_args', 'url_kwargs', 'request_data', 'user_credentials', 'redirect_to', +) + + INCORRECT_REQUIRED_PARAM_TYPE_MSG = \ 'django-skd-smoke: Configuration parameter "%s" with index=%s should be ' \ '%s but is %s with next value: %s.' @@ -89,17 +100,21 @@ def append_doc_link(error_message): return error_message + '\n' + LINK_TO_DOCUMENTATION -def prepare_configuration(tests_configuration): +def prepare_configuration(tests_configuration, defaults=None): """ Prepares initial tests configuration. Raises exception if there is any problem with it. :param tests_configuration: initial tests configuration as tuple or list \ with predefined structure + :param defaults: pass in a dict of defined default functions attached to \ + the class. key should be full function name and value should be \ + attribute value (either a callable or a plain value) :return: adjusted configuration which should be used further :raises: ``django.core.exceptions.ImproperlyConfigured`` if there is any \ problem with supplied ``tests_configuration`` """ + defaults = defaults or {} confs = [] if isinstance(tests_configuration, (tuple, list)): @@ -147,6 +162,15 @@ def prepare_configuration(tests_configuration): for key, value in test_config[-1].items(): type_info = NOT_REQUIRED_PARAM_TYPE_CHECK[key] function = type_info['func'] + # If they passed in a None then it can be a signal to not use + # the default value/callable that may have been defined in the + # test class. Permit this None only if the config item is 'defaultable' + # and a default has been defined on the test class + if key in DEFAULTABLE_PARAMS and value is None: + default_attr_name = "default_" + key + if default_attr_name in defaults: + value = defaults[default_attr_name] + if not function(value): type_errors.append( INCORRECT_NOT_REQUIRED_PARAM_TYPE_MSG % @@ -206,7 +230,7 @@ def generate_test_method(urlname, status, method='GET', initialize=None, into http method request :param user_credentials: dict or callable object which returns dict to \ login user using ``TestCase.client.login`` - :param redirect_to: plain url which is checked if only expected status \ + :param redirect_to: plain url or callable which is checked if only expected status \ code is one of the [301, 302, 303, 307] :return: new test method @@ -220,6 +244,11 @@ def new_test_method(self): else: prepared_url_args = url_args or [] + if callable(redirect_to): + redirect_url = redirect_to(self) + else: + redirect_url = redirect_to + if callable(url_kwargs): prepared_url_kwargs = url_kwargs(self) else: @@ -243,9 +272,9 @@ def new_test_method(self): prepared_data = request_data or {} response = function(resolved_url, data=prepared_data) self.assertEqual(response.status_code, status) - if status in (301, 302, 303, 307) and redirect_to: - self.assertRedirects(response, redirect_to, - fetch_redirect_response=False) + if status in (301, 302, 303, 307) and redirect_url: + extra_args = {"fetch_redirect_response": False} if VERSION >= (1, 7) else {} + self.assertRedirects(response, redirect_url, **extra_args) return new_test_method @@ -313,16 +342,25 @@ def __new__(mcs, name, bases, attrs): cls = super(GenerateTestMethodsMeta, mcs).__new__( mcs, name, bases, attrs) - # Ensure test method generation is only performed for subclasses of - # GenerateTestMethodsMeta (excluding GenerateTestMethodsMeta class - # itself). - parents = [b for b in bases if isinstance(b, GenerateTestMethodsMeta)] - if not parents: + # If this metaclass is instantiating something found in one of these modules + # then we skip generation of test cases, etc. Without this, we will erroneously + # detect error case of TESTS_CONFIGURATION being None + # Also, skip certain classes based on class name + skip_modules = ['skd_smoke', ] + skip_names = ['NewBase', 'SmokeTestCase', ] + if attrs.get('__module__') in skip_modules or name in skip_names: return cls + # Build a dict of all the 'defaults' they defined to go with their test class + defined_defaults = {} + for defaultable in DEFAULTABLE_PARAMS: + attr_name = "default_" + defaultable + if hasattr(cls, attr_name): + defined_defaults[attr_name] = getattr(cls, attr_name) + # noinspection PyBroadException try: - config = prepare_configuration(cls.TESTS_CONFIGURATION) + config = prepare_configuration(cls.TESTS_CONFIGURATION, defined_defaults) except Exception: fail_method = generate_fail_test_method(traceback.format_exc()) fail_method_name = cls.FAIL_METHOD_NAME @@ -331,13 +369,17 @@ def __new__(mcs, name, bases, attrs): setattr(cls, fail_method_name, fail_method) else: for urlname, status, method, data in config: - comment = data.get('comment', None) - initialize = data.get('initialize', None) - url_args = data.get('url_args', None) - url_kwargs = data.get('url_kwargs', None) - request_data = data.get('request_data', None) - get_user_credentials = data.get('user_credentials', None) - redirect_to = data.get('redirect_to', None) + # For each config item, if it doesnt exist in config tuple's "data" then + # see if user has defined a default on the test class + find_conf = lambda name: data.get(name, getattr(cls, "default_" + name, None)) + + comment = find_conf('comment') + initialize = find_conf('initialize') + url_args = find_conf('url_args') + url_kwargs = find_conf('url_kwargs') + request_data = find_conf('request_data') + get_user_credentials = find_conf('user_credentials') + redirect_to = find_conf('redirect_to') status_text = STATUS_CODE_TEXT.get(status, 'UNKNOWN') test_method_name = prepare_test_name(urlname, method, status) diff --git a/skd_smoke_tests/tests.py b/skd_smoke_tests/tests.py index b73c52d..07f7d8d 100644 --- a/skd_smoke_tests/tests.py +++ b/skd_smoke_tests/tests.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals, print_function import types from unittest import TestCase +from django import VERSION from mock import Mock, patch from django.core.exceptions import ImproperlyConfigured @@ -236,9 +237,14 @@ def assert_generated_test_method(self, cls, name, configuration, doc, url, login_mock.assert_called_once_with(**user_credentials) if redirect_to: - testcase_mock.assertRedirects.assert_called_once_with( - response, redirect_to, fetch_redirect_response=False - ) + if VERSION >= (1, 7): + testcase_mock.assertRedirects.assert_called_once_with( + response, redirect_to, fetch_redirect_response=False + ) + else: + testcase_mock.assertRedirects.assert_called_once_with( + response, redirect_to + ) else: testcase_mock.assertRedirects.assert_not_called() @@ -449,6 +455,52 @@ def test_generated_test_method_with_redirect_to_setting( CorrectConfig, expected_test_method_names[0], conf[0], expected_docs[0], url, redirect_to=redirect_url) + @patch('skd_smoke.uuid4') + @patch('skd_smoke.resolve_url') + def test_generated_test_method_with_redirect_as_callable( + self, mock_django_resolve_url, mock_uuid4): + + redirect_url = '/redirect_url' + redirect_call = Mock(return_value=redirect_url) + + conf = ( + ('urlname', 302, 'GET', {'redirect_to': redirect_call}), + ) + + expected_test_method_names = [ + 'test_smoke_urlname_get_302_ffffffff', + ] + + expected_docs = self.generate_docs_from_configuration(conf) + + mock_django_resolve_url.return_value = url = '/url/' + mock_uuid4.return_value = Mock(hex='ffffffff') + + CorrectConfig = type( + str('CorrectConfig'), + (SmokeTestCase,), + {'TESTS_CONFIGURATION': conf}) + + if self.check_if_class_contains_fail_test_method(CorrectConfig): + mock = self.call_cls_method_by_name(CorrectConfig, + 'FAIL_METHOD_NAME') + self.fail( + 'Generated TestCase contains fail test method but should not ' + 'cause its configuration is correct. Error stacktrace:\n%s' % + mock.fail.call_args_list[0][0][0] + ) + + self.assertTrue( + self.check_if_class_contains_test_methods(CorrectConfig), + 'TestCase should contain at least one generated test method.' + ) + + self.assert_generated_test_method( + CorrectConfig, expected_test_method_names[0], conf[0], + expected_docs[0], url, redirect_to=redirect_url) + + self.assertEqual(redirect_call.call_count, 1) + @patch('skd_smoke.uuid4') @patch('skd_smoke.resolve_url') def test_generated_test_method_with_initialize_callable( @@ -889,3 +941,57 @@ def get_request_data(testcase): for i, name in enumerate(expected_test_method_names): self.assert_generated_test_method(CorrectConfig, name, conf[i], expected_docs[i], url) + + @patch('skd_smoke.uuid4') + @patch('skd_smoke.resolve_url') + def test_generated_test_method_with_request_data_as_default( + self, mock_django_resolve_url, mock_uuid4): + + request_data = {'message': 'new comment'} + + def get_request_data(testcase): + return request_data + + explicit_no_request_data = { 'request_data': None } + conf = ( + ('/some_url/', 200, 'GET', explicit_no_request_data), + ('/comments/', 201, 'POST', + {'request_data': get_request_data}), + ('namespace:url', 200, 'GET', explicit_no_request_data), + ('some_url2', 200, 'GET', explicit_no_request_data), + ) + + expected_test_method_names = [ + 'test_smoke_some_url_get_200_ffffffff', + 'test_smoke_comments_post_201_ffffffff', + 'test_smoke_namespace_url_get_200_ffffffff', + 'test_smoke_some_url2_get_200_ffffffff', + ] + + expected_docs = self.generate_docs_from_configuration(conf) + + mock_django_resolve_url.return_value = url = '/url/' + mock_uuid4.return_value = Mock(hex='ffffffff') + + CorrectConfig = type( + str('CorrectConfig'), + (SmokeTestCase,), + {'TESTS_CONFIGURATION': conf, 'default_request_data': lambda x: 'bla'}) + + if self.check_if_class_contains_fail_test_method(CorrectConfig): + mock = self.call_cls_method_by_name(CorrectConfig, + 'FAIL_METHOD_NAME') + self.fail( + 'Generated TestCase contains fail test method but should not ' + 'cause its configuration is correct. Error stacktrace:\n%s' % + mock.fail.call_args_list[0][0][0] + ) + + self.assertTrue( + self.check_if_class_contains_test_methods(CorrectConfig), + 'TestCase should contain at least one generated test method.' + ) + + for i, name in enumerate(expected_test_method_names): + self.assert_generated_test_method(CorrectConfig, name, conf[i], + expected_docs[i], url)