From 44538c8f317143cd08358d33e8cffdc5638544e7 Mon Sep 17 00:00:00 2001 From: Bill Huneke Date: Tue, 12 Apr 2016 16:40:02 -0400 Subject: [PATCH 1/6] Make it easy to define default or global settings (such as user credentials) by attaching default values to the class instead of having to define the same values over and over in each test URL definition Update documentation to explain the new 'defaults' feature --- README.rst | 59 +++++++++++++++++++++++++++++++++++++++++++ skd_smoke/__init__.py | 18 ++++++++----- 2 files changed, 70 insertions(+), 7 deletions(-) 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..b316cc1 100644 --- a/skd_smoke/__init__.py +++ b/skd_smoke/__init__.py @@ -331,13 +331,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) From b2d61b191a83df1d08b13ed5545511d177f1defb Mon Sep 17 00:00:00 2001 From: Bill Huneke Date: Wed, 13 Apr 2016 15:00:47 -0400 Subject: [PATCH 2/6] Change the method for checking which class we are building Stop attaching test_fail_bad_config to SmokeTestCase class (This may have been a python 2 vs 3 issue...) --- skd_smoke/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/skd_smoke/__init__.py b/skd_smoke/__init__.py index b316cc1..1a2af70 100644 --- a/skd_smoke/__init__.py +++ b/skd_smoke/__init__.py @@ -313,11 +313,11 @@ 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 + skip_modules = ['skd_smoke', ] + if attrs.get('__module__', skip_modules[0]) in skip_modules: return cls # noinspection PyBroadException From 713d54f573d0e9b5964f2c960931f7a43bcf9ed3 Mon Sep 17 00:00:00 2001 From: Bill Huneke Date: Thu, 14 Apr 2016 18:00:52 -0400 Subject: [PATCH 3/6] Make redirect_to accept a callable as well as a string. Also, fix a problem with DJANGO < 1.7 compatability --- skd_smoke/__init__.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/skd_smoke/__init__.py b/skd_smoke/__init__.py index 1a2af70..a031e6b 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,7 +62,7 @@ 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}, } INCORRECT_REQUIRED_PARAM_TYPE_MSG = \ @@ -220,6 +225,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 +253,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 From 8e41ac06910f66c85ac14e84cc9252ce57abc72f Mon Sep 17 00:00:00 2001 From: Bill Huneke Date: Tue, 19 Apr 2016 13:23:58 -0400 Subject: [PATCH 4/6] Get tests running again to match the recent changes to how we detect which Class we are building and changes to allow compatability with Django < 1.7 --- skd_smoke/__init__.py | 6 ++++-- skd_smoke_tests/tests.py | 12 +++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/skd_smoke/__init__.py b/skd_smoke/__init__.py index a031e6b..d730638 100644 --- a/skd_smoke/__init__.py +++ b/skd_smoke/__init__.py @@ -211,7 +211,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 @@ -326,8 +326,10 @@ def __new__(mcs, name, bases, attrs): # 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', ] - if attrs.get('__module__', skip_modules[0]) in skip_modules: + skip_names = ['NewBase', 'SmokeTestCase', ] + if attrs.get('__module__') in skip_modules or name in skip_names: return cls # noinspection PyBroadException diff --git a/skd_smoke_tests/tests.py b/skd_smoke_tests/tests.py index b73c52d..72519f5 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() From dd2457971f303dc92f20255c6830461aec244688 Mon Sep 17 00:00:00 2001 From: Bill Huneke Date: Tue, 19 Apr 2016 13:31:17 -0400 Subject: [PATCH 5/6] add a unit test to cover redirect_to as a callable --- skd_smoke_tests/tests.py | 46 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/skd_smoke_tests/tests.py b/skd_smoke_tests/tests.py index 72519f5..a2d0a16 100644 --- a/skd_smoke_tests/tests.py +++ b/skd_smoke_tests/tests.py @@ -455,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( From 6cadcadb96a1b43b66729793fcc36daffe269bc4 Mon Sep 17 00:00:00 2001 From: Bill Huneke Date: Fri, 22 Apr 2016 11:54:58 -0400 Subject: [PATCH 6/6] Start updating tests to cover the new defaults feature --- skd_smoke/__init__.py | 30 ++++++++++++++++++++-- skd_smoke_tests/tests.py | 54 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/skd_smoke/__init__.py b/skd_smoke/__init__.py index d730638..67b69ff 100644 --- a/skd_smoke/__init__.py +++ b/skd_smoke/__init__.py @@ -65,6 +65,12 @@ def dict_or_callable(d): '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.' @@ -94,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)): @@ -152,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 % @@ -332,9 +351,16 @@ def __new__(mcs, name, bases, attrs): 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 diff --git a/skd_smoke_tests/tests.py b/skd_smoke_tests/tests.py index a2d0a16..07f7d8d 100644 --- a/skd_smoke_tests/tests.py +++ b/skd_smoke_tests/tests.py @@ -941,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)