Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make it easy to define default or global settings (such as user credentials) #24

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
59 changes: 59 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
--------
Expand Down
80 changes: 61 additions & 19 deletions skd_smoke/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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.'
Expand Down Expand Up @@ -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)):
Expand Down Expand Up @@ -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 %
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
112 changes: 109 additions & 3 deletions skd_smoke_tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)