diff --git a/pyproject.toml b/pyproject.toml index bdbc0e2..b8a6a91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ build-backend = "setuptools.build_meta" [project] name = "unittest-extensions" -version = "0.2.5" +version = "0.3.0" authors = [ { name="Maximos Nikiforakis", email="nikiforos@live.co.uk" }, ] diff --git a/src/unittest_extensions/case.py b/src/unittest_extensions/case.py index 2f804b9..19556db 100644 --- a/src/unittest_extensions/case.py +++ b/src/unittest_extensions/case.py @@ -1,5 +1,5 @@ from unittest import TestCase as BaseTestCase -from typing import Any, Dict +from typing import Any, Dict, Tuple from abc import abstractmethod from warnings import warn from copy import deepcopy @@ -12,37 +12,42 @@ class TestCase(BaseTestCase): Extends unittest.TestCase with methods that assert the result of a defined `subject` method. - ``` - from unittest_extensions import TestCase, args + Inherit from this class for your test-case classes and decorate test methods + with the @args decorator. + Examples: + >>> from unittest_extensions import TestCase, args - class MyClass: - def my_method(self, a, b): - return a + b + >>> class MyClass: + ... def my_method(self, a, b): + ... return a + b + >>> class TestMyMethod(TestCase): + ... def subject(self, a, b): + ... return MyClass().my_method(a, b) - class TestMyMethod(TestCase): - def subject(self, a, b): - return MyClass().my_method(a, b) + ... @args(None, 2) + ... def test_none_plus_int(self): + ... self.assertResultRaises(TypeError) - @args({"a": None, "b": 2}) - def test_none_plus_int(self): - self.assertResultRaises(TypeError) + ... @args(a=10, b=22.1) + ... def test_int_plus_float(self): + ... self.assertResult(32.1) - @args({"a": 10, "b": 22.1}) - def test_int_plus_float(self): - self.assertResult(32.1) - ``` + ... @args("1", b="2") + ... def test_str_plus_str(self): + ... self.assertResult("12") """ @abstractmethod - def subject(self, **kwargs) -> Any: ... + def subject(self, *args, **kwargs) -> Any: + raise TestError("No 'subject' method found; perhaps you mispelled it?") def subjectKwargs(self) -> Dict[str, Any]: """ Return the keyword arguments of the subject. - The dictionary returned is a copy of the original arguments. Thus, + The dictionary returned is a deep copy of the original arguments. Thus, the arguments that the subject receives cannot be mutated by mutating the returned object of this method. """ @@ -50,13 +55,26 @@ def subjectKwargs(self) -> Dict[str, Any]: # issues with memory. return deepcopy(self._subjectKwargs) + def subjectArgs(self) -> Tuple: + """ + Return the positional arguments of the subject. + + The tuple returned is a deep copy of the original arguments. Thus, + the arguments that the subject receives cannot be mutated by mutating + the returned object of this method. + """ + # NOTE: deepcopy keeps a reference of the copied object. This can cause + # issues with memory. + return deepcopy(self._subjectArgs) + def result(self) -> Any: """ - Result of the `subject` called with arguments defined by the `args` - decorator. + Result of the `subject` called with arguments defined by the `args` decorator. """ try: - self._subjectResult = self.subject(**self._subjectKwargs) + self._subjectResult = self.subject( + *self._subjectArgs, **self._subjectKwargs + ) return self._subjectResult except Exception as e: if len(e.args) == 0: @@ -328,6 +346,11 @@ def assertResultDict(self, dct): self.assertDictEqual(self.result(), dct) def _callTestMethod(self, method): + if hasattr(method, "_subjectArgs"): + self._subjectArgs = method._subjectArgs + else: + self._subjectArgs = tuple() + if hasattr(method, "_subjectKwargs"): self._subjectKwargs = method._subjectKwargs else: @@ -341,3 +364,4 @@ def _callTestMethod(self, method): stacklevel=3, ) self._subjectKwargs = {} + self._subjectArgs = tuple() diff --git a/src/unittest_extensions/decorator.py b/src/unittest_extensions/decorator.py index 8e12e66..5e08ad6 100644 --- a/src/unittest_extensions/decorator.py +++ b/src/unittest_extensions/decorator.py @@ -1,10 +1,43 @@ -def args(kwargs): +import functools + + +def args(*args, **kwargs): """ - Decorate test methods to define arguments for your subject. + Decorate test methods to define positional and/or keyword arguments for your + `subject` method. + + Examples: + >>> from unittest_extensions import TestCase, args + + >>> class MyClass: + ... def my_method(self, a, b): + ... return a + b + + >>> class TestMyMethod(TestCase): + ... def subject(self, a, b): + ... return MyClass().my_method(a, b) + + ... @args(None, 2) + ... def test_none_plus_int(self): + ... self.assertResultRaises(TypeError) + + ... @args(a=10, b=22.1) + ... def test_int_plus_float(self): + ... self.assertResult(32.1) + + ... @args("1", b="2") + ... def test_str_plus_str(self): + ... self.assertResult("12") """ - def wrapper(method): - method._subjectKwargs = kwargs - return method + def args_decorator(test_method): + test_method._subjectArgs = args + test_method._subjectKwargs = kwargs + + @functools.wraps(test_method) + def wrapped_test_method(*_args, **_kwargs): + return test_method(*_args, **_kwargs) + + return wrapped_test_method - return wrapper + return args_decorator diff --git a/src/unittest_extensions/tests/test_use_case.py b/src/unittest_extensions/tests/test_use_case.py index 7940803..932a0bf 100644 --- a/src/unittest_extensions/tests/test_use_case.py +++ b/src/unittest_extensions/tests/test_use_case.py @@ -26,44 +26,124 @@ def instance(self) -> TestClass: def subject(self, a, b): return self.instance().add(a, b) - @args({"a": None, "b": 2}) - def test_add_none_to_int_raises(self): + @args(a=None, b=2) + def test_kwargs_add_none_to_int_raises(self): self.assertResultRaises(TypeError) - @args({"a": "somthing", "b": 5}) - def test_add_str_to_int_raises(self): + @args(a="somthing", b=5) + def test_kwargs_add_str_to_int_raises(self): self.assertResultRaises(TypeError) - @args({"a": "adgsa", "b": None}) - def test_add_str_to_none_raises(self): + @args(a="adgsa", b=None) + def test_kwargs_add_str_to_none_raises(self): self.assertResultRaises(TypeError) - @args({"a": 2, "b": -6}) - def test_add_int_to_int(self): + @args(a=2, b=-6) + def test_kwargs_add_int_to_int(self): self.assertResult(-4) - @args({"a": 2.5, "b": 29.0367}) - def test_add_float_to_float(self): + @args(a=2.5, b=29.0367) + def test_kwargs_add_float_to_float(self): self.assertResultAlmost(31.5367) - @args({"a": "1-", "b": "3-"}) - def test_add_str_to_str(self): + @args(a="1-", b="3-") + def test_kwargs_add_str_to_str(self): self.assertResult("1-3-") - @args({"a": 1, "c": 2}) - def test_wrong_kwargs_raises(self): + @args(a=1, c=2) + def test_kwargs_wrong_kwargs_raises(self): self.assertResultRaisesRegex( TestError, "Subject received an unexpected keyword argument." ) - @args({"a": 1}) - def test_missing_arg_raises(self): + @args(a=1) + def test_kwargs_missing_arg_raises(self): self.assertResultRaisesRegex( TestError, "Subject misses 1 required positional argument." ) - @args({"a": 1, "b": 2}) - def test_cachedResult_raises(self): + @args(a=1, b=2) + def test_kwargs_cachedResult_raises(self): + with self.assertRaisesRegex( + TestError, "Cannot call 'cachedResult' before calling 'result'" + ): + self.cachedResult() + + @args(None, 2) + def test_args_add_none_to_int_raises(self): + self.assertResultRaises(TypeError) + + @args("somthing", 5) + def test_args_add_str_to_int_raises(self): + self.assertResultRaises(TypeError) + + @args("adgsa", None) + def test_args_add_str_to_none_raises(self): + self.assertResultRaises(TypeError) + + @args(2, -6) + def test_args_add_int_to_int(self): + self.assertResult(-4) + + @args(2.5, 29.0367) + def test_args_add_float_to_float(self): + self.assertResultAlmost(31.5367) + + @args("1-", "3-") + def test_args_add_str_to_str(self): + self.assertResult("1-3-") + + @args(1) + def test_args_missing_arg_raises(self): + self.assertResultRaisesRegex( + TestError, "Subject misses 1 required positional argument." + ) + + @args(1, 2) + def test_args_cachedResult_raises(self): + with self.assertRaisesRegex( + TestError, "Cannot call 'cachedResult' before calling 'result'" + ): + self.cachedResult() + + @args(None, b=2) + def test_args_kwargs_add_none_to_int_raises(self): + self.assertResultRaises(TypeError) + + @args("something", b=5) + def test_args_kwargs_add_str_to_int_raises(self): + self.assertResultRaises(TypeError) + + @args("asda", b=None) + def test_args_kwargs_add_str_to_none_raises(self): + self.assertResultRaises(TypeError) + + @args(2, b=-6) + def test_args_kwargs_add_int_to_int(self): + self.assertResult(-4) + + @args(2.5, b=29.0367) + def test_args_kwargs_add_float_to_float(self): + self.assertResultAlmost(31.5367) + + @args("1-", b="3-") + def test_args_kwargs_add_str_to_str(self): + self.assertResult("1-3-") + + @args(1, c=2) + def test_args_kwargs_wrong_kwargs_raises(self): + self.assertResultRaisesRegex( + TestError, "Subject received an unexpected keyword argument." + ) + + @args(1) + def test_args_kwargs_missing_arg_raises(self): + self.assertResultRaisesRegex( + TestError, "Subject misses 1 required positional argument." + ) + + @args(1, b=2) + def test_args_kwargs_cachedResult_raises(self): with self.assertRaisesRegex( TestError, "Cannot call 'cachedResult' before calling 'result'" ): @@ -74,12 +154,24 @@ class TestSubjectMissingRequiredPositionalArguments(TestCase): def subject(self, a, b, c, d): return 1 - @args({"a": 1, "b": 2}) - def test_raises_test_error(self): + @args(a=1, b=2) + def test_kwargs_raises_test_error(self): self.assertResultRaisesRegex( TestError, "Subject misses 2 required positional arguments" ) + @args(1, 2) + def test_args_raises_test_error(self): + self.assertResultRaisesRegex( + TestError, "Subject misses 2 required positional arguments" + ) + + @args(1, b=2, d=1) + def test_args_kwargs_raises_test_error(self): + self.assertResultRaisesRegex( + TestError, "Subject misses 1 required positional argument" + ) + class TestAppend(TestCase): def instance(self) -> TestClass: @@ -89,21 +181,55 @@ def subject(self, lst, a): self.instance().append(lst, a) return lst - @args({"lst": [], "a": None}) - def test_append_to_empty_list(self): + @args(lst=[], a=None) + def test_kwargs_append_to_empty_list(self): self.assertResultList([None]) - @args({"lst": ["1"], "a": 2}) - def test_append(self): + @args(lst=["1"], a=2) + def test_kwargs_append(self): self.assertResultList(["1", 2]) - @args({"lst": [-0.2, 1], "a": 3}) - def test_append_twice(self): + @args(lst=[-0.2, 1], a=3) + def test_kwargs_append_twice(self): self.assertResultList([-0.2, 1, 3]) self.assertResultList([-0.2, 1, 3, 3]) - @args({"lst": None, "a": None}) - def test_append_to_none_raises(self): + @args(lst=None, a=None) + def test_kwargs_append_to_none_raises(self): + self.assertResultRaises(AttributeError) + + @args([], None) + def test_args_append_to_empty_list(self): + self.assertResultList([None]) + + @args(["1"], 2) + def test_args_append(self): + self.assertResultList(["1", 2]) + + @args([-0.2, 1], 3) + def test_args_append_twice(self): + self.assertResultList([-0.2, 1, 3]) + self.assertResultList([-0.2, 1, 3, 3]) + + @args(None, None) + def test_args_append_to_none_raises(self): + self.assertResultRaises(AttributeError) + + @args([], a=None) + def test_args_kwargs_append_to_empty_list(self): + self.assertResultList([None]) + + @args(["1"], a=2) + def test_args_kwargs_append(self): + self.assertResultList(["1", 2]) + + @args([-0.2, 1], a=3) + def test_args_kwargs_append_twice(self): + self.assertResultList([-0.2, 1, 3]) + self.assertResultList([-0.2, 1, 3, 3]) + + @args(None, a=None) + def test_args_kwargs_append_to_none_raises(self): self.assertResultRaises(AttributeError) @@ -114,12 +240,20 @@ def instance(self) -> TestClass: def subject(self, exc): self.instance().raises(exc) - @args({"exc": TypeError}) - def raises_type_error(self): + @args(exc=TypeError) + def test_kwargs_raises_type_error(self): + self.assertResultRaises(TypeError) + + @args(exc=Exception) + def test_kwargs_raises_exception(self): + self.assertResultRaises(Exception) + + @args(TypeError) + def test_args_raises_type_error(self): self.assertResultRaises(TypeError) - @args({"exc": Exception}) - def raises_exception(self): + @args(Exception) + def test_args_raises_exception(self): self.assertResultRaises(Exception) @@ -158,37 +292,78 @@ class TestSubjectKwargs(TestCase): def subject(self, a, b): return a + b - @args({"a": 1, "b": 2}) + @args(a=1, b=2) def test_kwarg_values(self): self.assertDictEqual(self.subjectKwargs(), {"a": 1, "b": 2}) + self.assertTupleEqual(self.subjectArgs(), tuple()) - @args({"a": 3, "b": 4}) + @args(a=3, b=4) def test_different_kwarg_values(self): self.assertDictEqual(self.subjectKwargs(), {"a": 3, "b": 4}) + self.assertTupleEqual(self.subjectArgs(), tuple()) - @args({"a": 1, "b": 2}) + @args(a=1, b=2) def test_kwargs_are_not_mutated(self): self.subjectKwargs()["b"] = None + self.assertDictEqual(self._subjectKwargs, {"a": 1, "b": 2}) self.assertDictEqual(self.subjectKwargs(), {"a": 1, "b": 2}) + @args(1, b=2) + def test_args_kwargs_values(self): + self.assertTupleEqual(self.subjectArgs(), (1,)) + self.assertDictEqual(self.subjectKwargs(), {"b": 2}) + + +class TestSubjectArgs(TestCase): + def subject(self, a, b): + return a + b + + @args(1, 2) + def test_arg_values(self): + self.assertTupleEqual(self.subjectArgs(), (1, 2)) + self.assertDictEqual(self.subjectKwargs(), {}) + + @args([1], [2]) + def test_args_are_not_mutated(self): + self.subjectArgs()[0].append(None) + + self.assertTupleEqual(self._subjectArgs, ([1], [2])) + self.assertTupleEqual(self.subjectArgs(), ([1], [2])) + class TestSubjectMutableKwargs(TestCase): def subject(self, lst): return lst - @args({"lst": [1, 2]}) + @args(lst=[1, 2]) def test_mutable_kwargs(self): self.subjectKwargs()["lst"].append(3) self.assertDictEqual(self._subjectKwargs, {"lst": [1, 2]}) +class TestSubjectMutableArgs(TestCase): + def subject(self, lst): + return lst + + @args([1, 2]) + def test_mutable_args(self): + self.subjectArgs()[0].append(3) + self.assertTupleEqual(self._subjectArgs, ([1, 2],)) + + class TestCachedResult(TestCase): def subject(self, lst): return lst - @args({"lst": [1, 2]}) - def test_mutate_cached_result(self): + @args(lst=[1, 2]) + def test_kwargs_mutate_cached_result(self): + self.assertResult([1, 2]) + self.cachedResult().append(3) + self.assertListEqual(self.cachedResult(), [1, 2]) + + @args([1, 2]) + def test_args_mutate_cached_result(self): self.assertResult([1, 2]) self.cachedResult().append(3) self.assertListEqual(self.cachedResult(), [1, 2]) @@ -211,3 +386,20 @@ def subject(self): def test_reraises_error(self): self.assertResultRaises(KeyError) + + +class TestSubjectMethodMisspelling(TestCase): + def subj(self, a): + return a + + @args(a=1) + def test_kwargs_raises_test_error(self): + self.assertResultRaisesRegex( + TestError, "No 'subject' method found; perhaps you mispelled it?" + ) + + @args(1) + def test_args_raises_test_error(self): + self.assertResultRaisesRegex( + TestError, "No 'subject' method found; perhaps you mispelled it?" + )