Source code for tests.datadriventestingdecorators

# -*- coding: utf-8 -*-
#
# New BSD license
#
# Copyright (c) DR0ID
# This file is part of HG_pyweek_games
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#     * Redistributions of source code must retain the above copyright
#       notice, this list of conditions and the following disclaimer.
#     * Redistributions in binary form must reproduce the above copyright
#       notice, this list of conditions and the following disclaimer in the
#       documentation and/or other materials provided with the distribution.
#     * Neither the name of the <organization> nor the
#       names of its contributors may be used to endorse or promote products
#       derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL DR0ID BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

"""
TODO: module description

Inspired by ddt::

    http://ddt.readthedocs.org/en/latest/index.html
    https://github.com/txels/ddt


Versioning scheme based on: http://en.wikipedia.org/wiki/Versioning#Designating_development_stage

::

      +-- api change, probably incompatible with older versions
      |     +-- enhancements but no api change
      |     |
    major.minor[.build[.revision]]
                   |        |
                   |        +-|* x for x bugfixes
                   |
                   +-|* 0 for alpha (status)
                     |* 1 for beta (status)
                     |* 2 for release candidate
                     |* 3 for (public) release

.. versionchanged:: 0.9.0.0
    initial version

"""

import string
import re
from functools import wraps
import collections


__version__ = '1.0.0.0'

# for easy comparison as in sys.version_info but digits only
__version_info__ = tuple([int(d) for d in __version__.split('.')])

__author__ = 'DR0ID'
__email__ = 'dr0iddr0id [at] googlemail [dot] com'
__copyright__ = "DR0ID @ 2014"
__credits__ = ["DR0ID"]  # list of contributors
__maintainer__ = "DR0ID"
__license__ = "New BSD license"

# list of public visible parts of this module
__all__ = ["use_data_driven_testing_decorators", "test_case", "test_case_from_generator", "MatchType", "RESULT",
           "EXPECTED_EXCEPTION", "TEST_NAME", "DESCRIPTION", "STRICT", "EXPECTED_MESSAGE", "MATCH_TYPE",
           "FUNCTION_KWARGS"]


[docs]class MatchType(object): """ This is the MatchType enum. * EXACT: matches the expected exception message exactly against the expected message * CONTAINS: the expected exception message contains the expected message * REGEX: the expected message is a regex expression that has to match with the expected exception message * STARTS_WITH: the expected exception message starts with the expected message """ EXACT = 0 CONTAINS = 1 REGEX = 2 STARTS_WITH = 3
RESULT = 'result' EXPECTED_EXCEPTION = 'expected_exception' TEST_NAME = 'test_name' DESCRIPTION = 'description' STRICT = 'strict' EXPECTED_MESSAGE = 'expected_message' MATCH_TYPE = 'match_type' FUNCTION_KWARGS = 'function_kwargs' _DATA_TEST_CASE_ATTRIBUTE = "DDT_decorators_data_test_cases" _GENERATOR_TEST_CASE_ATTRIBUTE = "DDT_decorators_generator_test_cases"
[docs]def use_data_driven_testing_decorators(cls): """ The class decorator. Enables to use the data driven test cases. Used on subclasses of ``unittest.TestCase``. :param cls: :return: :raise AttributeError: """ for func_name, func in list(cls.__dict__.items()): if hasattr(func, _DATA_TEST_CASE_ATTRIBUTE): data_points = getattr(func, _DATA_TEST_CASE_ATTRIBUTE) _add_new_methods(cls, func, func_name, data_points, _DATA_TEST_CASE_ATTRIBUTE) elif hasattr(func, _GENERATOR_TEST_CASE_ATTRIBUTE): for generator, g_args, g_kwargs in getattr(func, _GENERATOR_TEST_CASE_ATTRIBUTE): _add_new_methods(cls, func, func_name, generator(*g_args, **g_kwargs), _GENERATOR_TEST_CASE_ATTRIBUTE) return cls
[docs]def test_case(*args, **kwargs): """ The test case decorator. :param args: arguments of the test method. :param kwargs: additional parameters:: 'result': 2 'description': the __doc__ string 'expected_exception': ZeroDivisionError 'expected_message': the expected message, e.g. from an exception 'match_type': MatchType.EXACT (default) 'test_name': name for test method, otherwise constructed from args e.g. def test_xxx() and test_name = YYY then the following method name is constructed: test_xxx_DDT_yyy() 'strict': if true, does not allow for other keywords in kwargs as the defined ones 'function_kwargs': the kwargs that are passed to the function, use like this: function_kwargs={'1':1, ...} :return: the wrapper method (this is a decorator function) :raise ValueError: if no arguments are provided. """ if not args: raise ValueError("should have arguments") _check_kwargs_if_strict(kwargs) def wrapper(func): """ The wrapper function that adds the attribute with the arguments. :param func: :return: """ if not hasattr(func, _DATA_TEST_CASE_ATTRIBUTE): setattr(func, _DATA_TEST_CASE_ATTRIBUTE, []) test_cases = getattr(func, _DATA_TEST_CASE_ATTRIBUTE) test_cases.append((args, kwargs)) return func return wrapper
[docs]def test_case_from_generator(generator_function, *generator_args, **generator_kwargs): """ Data driven test cases from a generator function. This can be used to read values from a file. :param generator_function: a generator function. It should be iterable. It should generate a iterable sequence like (same arguments as for a test_case): [(args, kwargs), (args, kwargs), ....] :param generator_args: optional arguments for the generator function :param generator_kwargs: optional kwargs for the generator function :return: wrapped method :raise ValueError: if generator function is not given """ if generator_function is None or not isinstance(generator_function, collections.Callable): raise ValueError("generator_function should be callable and not None") def wrapper(func): """ The wrapper function. Adds the _DATA_FILE_INFO attribute with its parameters to the wrapped function. :param func: The function to be wrapped :return: The same function with additional test case data. """ if not hasattr(func, _GENERATOR_TEST_CASE_ATTRIBUTE): setattr(func, _GENERATOR_TEST_CASE_ATTRIBUTE, []) generator_attr = getattr(func, _GENERATOR_TEST_CASE_ATTRIBUTE) generator_attr.append((generator_function, generator_args, generator_kwargs)) return func return wrapper
def _check_kwargs_if_strict(kwargs): strict = kwargs.get(STRICT, True) for kwarg in list(kwargs.keys()): if strict and kwarg not in [RESULT, EXPECTED_EXCEPTION, TEST_NAME, DESCRIPTION, STRICT, EXPECTED_MESSAGE, MATCH_TYPE, FUNCTION_KWARGS]: raise AttributeError("strict mode: unknown kwargs '{0}', should be one of {1}".format(kwarg, [RESULT, EXPECTED_EXCEPTION, TEST_NAME, DESCRIPTION, STRICT, EXPECTED_MESSAGE, MATCH_TYPE, FUNCTION_KWARGS])) def _feed_data(function, arguments, kwargs): def _verify_methods_return_value(function_kwargs, self): expected_result = kwargs[RESULT] actual = function(self, *arguments, **function_kwargs) # assertEqual if not expected_result == actual: raise AssertionError('expected: %r != actual: %r' % (expected_result, actual)) def _verify_method_call(function_kwargs, self): # TODO: extract to method try: actual = function(self, *arguments, **function_kwargs) except Exception as ex: raise if actual is not None: raise UserWarning( "method {0} returned a value but no 'result' keyword has been provided".format( function.__name__)) def _check_expected_exception_message(e): # assertions if EXPECTED_MESSAGE in kwargs: exp_msg = kwargs[EXPECTED_MESSAGE] match_type = kwargs.get(MATCH_TYPE, MatchType.EXACT) if match_type == MatchType.EXACT: if exp_msg != str(e): raise AssertionError("exception message '{0}' does not match exactly by expected message '{1}'", str(e), exp_msg) elif match_type == MatchType.CONTAINS: if exp_msg not in str(e): raise AssertionError("exception message '{0}' does not contain expected '{1}'", str(e), exp_msg) elif match_type == MatchType.STARTS_WITH: if not str(e).startswith(exp_msg): raise AssertionError("exception message '{0}' does not start with '{1}'", str(e), exp_msg) elif match_type == MatchType.REGEX: flags = None if len(exp_msg) == 2: exp_msg, flags = exp_msg elif len(exp_msg) > 2: raise AssertionError( "too many regex arguments, should be either regex string or (regex_string, flags)") if flags is None: m = re.search(exp_msg, str(e)) else: m = re.search(exp_msg, str(e), flags) if m is None: raise AssertionError( "exception message '{0}' is not matched by regex '{1}'".format(str(e), exp_msg)) else: raise TypeError("Unknown match type {0}".format(match_type)) @wraps(function) def wrapped_parametrized_test_method(self): """ The actual test method that is executed. :param self: instance of the unittest class :return: :raise AssertionError: """ function_kwargs = kwargs.get(FUNCTION_KWARGS, {}) expected_exception = kwargs.get(EXPECTED_EXCEPTION, None) if expected_exception is None: if RESULT in kwargs: _verify_methods_return_value(function_kwargs, self) else: _verify_method_call(function_kwargs, self) else: try: function(self, *arguments, **function_kwargs) except expected_exception as ex: _check_expected_exception_message(ex) return except Exception as ex: raise AssertionError( "{0} not raised by {1}, instead {2} was raised".format(expected_exception, function, ex)) return wrapped_parametrized_test_method def _add_new_methods(_cls, _func, _name, _data_points, _attribute_name): for the_arguments, kwargs in _data_points: _check_kwargs_if_strict(kwargs) method_name_extension = _create_method_name(kwargs.get(TEST_NAME, ""), the_arguments, kwargs) test_method_name = "{0}_{1}".format(_name, method_name_extension) if hasattr(_cls, test_method_name): raise AttributeError("a method with name '{0}' already exists (use test_name='...' to override this".format( test_method_name)) new_method = _feed_data(_func, the_arguments, kwargs) setattr(_cls, test_method_name, new_method) setattr(new_method, "__doc__", kwargs.get(DESCRIPTION, "")) # remove attribute from function delattr(_func, _attribute_name) # remove replaced method from class delattr(_cls, _name) def _create_method_name(name, arguments, kwargs): name_extension = "DDT_" if name is "": args_name = "args_{0}".format(arguments) if kwargs: args_name = "{0}_{1}".format(args_name, kwargs) separator = "" if name is "" else "_" generated_name = "{0}{1}{2}{3}".format(name_extension, name, separator, args_name) else: generated_name = name_extension + name return _convert_to_valid_method_name(generated_name) def _convert_to_valid_method_name(in_string): valid_letters = string.ascii_letters + string.digits + '_' replacements = '' for c in in_string: if c in valid_letters: continue replacements += c out_string = in_string for c in replacements: out_string = out_string.replace(c, '_') return out_string