Skip to content

Commit

Permalink
Improve reset attribute error message & docs
Browse files Browse the repository at this point in the history
  • Loading branch information
fornellas committed Feb 5, 2021
1 parent 8776741 commit 8b7895a
Show file tree
Hide file tree
Showing 3 changed files with 81 additions and 42 deletions.
105 changes: 72 additions & 33 deletions docs/testslide_dsl/context_attributes_and_functions/index.rst
Expand Up @@ -22,62 +22,99 @@ and refer it later on:
def is_a_calculaor(self):
assert type(self.calculator) == Calculator
Attributes and sub-contexts
^^^^^^^^^^^^^^^^^^^^^^^^^^^

While it is very intuitive to do ``self.attr = "value"``, when used with sub-contexts there's potential for confusion:

.. code-block:: python
from testslide.dsl import context
@context
def top_context(context):
@context.before
def set_attr(self):
self.attr = "top context value"
self.top_context_dict = {}
self.top_context_dict["attr"] = self.attr
@context.example
def attr_is_the_same(self):
self.assertEqual(self.attr, self.top_context_dict["attr"])
@context.sub_context
def sub_context(context):
@context.before
def reset_attr(self):
self.attr = "sub context value"
self.sub_context_dict = {}
self.sub_context_dict["attr"] = self.attr
@context.example
def attr_is_the_same(self):
self.assertEqual(self.attr, self.sub_context_dict["attr"]) # OK
self.assertEqual(self.attr, self.top_context_dict["attr"]) # Boom!
In this example ``self.attr`` will have different values at ``top_context`` and ``sub_context`` resulting in some confusion in the assertions. These can be hard to spot in more complex scenarios, so TestSlide prevents attributes from being reset and the example above actually fails with ``AttributeError: Attribute 'attr' is already set.``.

The solution to this problem are **memoized attributes**.

Memoized Attributes
-------------------
^^^^^^^^^^^^^^^^^^^

Memoized attributes are similar to a ``@property`` but with 2 key differences:

* Its value is materialized and cached on the first access.
* When multiple contexts define the same memoized attribute the inner-most overrides the outer-most definitions.

Memoized attributes allow for lazy construction of attributes needed during a test. The attribute value will be constructed and remembered only at the first attribute access:
Let's see it in action:

.. code-block:: python
from testslide.dsl import context
@context
def Memoized_attributes(context):
def memoized_attributes(context):
# This function will be used to lazily set a memoized attribute with the same name
@context.memoize
def memoized_value(self):
def memoized_list(self):
return []
# Lambdas are also OK
context.memoize('another_memoized_value', lambda self: [])
# Or in bulk
context.memoize(
yet_another=lambda self: 'one',
and_one_more=lambda self: 'attr',
)
@context.example
def can_access_memoized_attributes(self):
# memoized_value
assert len(self.memoized_value) == 0
self.memoized_value.append(True)
assert len(self.memoized_value) == 1
assert len(self.memoized_list) == 0 # list is materialized
self.memoized_list.append(True)
assert len(self.memoized_list) == 1 # same list is refereed
# another_memoized_value
assert len(self.another_memoized_value) == 0
self.another_memoized_value.append(True)
assert len(self.another_memoized_value) == 1
For the sake of convenience, memoized attributes can also be defined using lambdas:

# these were declared in bulk
assert self.yet_anoter == 'one'
assert self.and_one_more == 'attr'
.. code-block:: python
Note in the example that the list built by ``memoized_value()``, is memoized, and is the same object for every access.
context.memoize('memoized_list', lambda self: [])
Another option is to force memoization to happen at a before hook, instead of at the moment the attribute is accessed:
or in bulk:

.. code-block:: python
context.memoize(
memoized_list=lambda self: [],
yet_another_memoized_list=lambda self: [],
)
In some cases, delaying the materialization of the attribute is not desired and it can be forced to happen unconditionally from within a before hook:

.. code-block:: python
@context.memoize_before
def attribute_name(self):
def memoized_list(self):
return []
In this case, the attribute will be set, regardless if it is used or not.

Composition
^^^^^^^^^^^
Overriding Memoized Attributes
""""""""""""""""""""""""""""""

The big value of using memoized attributes as opposed to a regular attribute, is that you can easily do composition:
As memoized attributes from parent contexts can be overridden by defining a new value from an inner context, it not only gives consistency on the attribute value, but also allows for some powerful composition:

.. code-block:: python
Expand Down Expand Up @@ -108,6 +145,8 @@ The big value of using memoized attributes as opposed to a regular attribute, is
def sees_different_value(self):
self.assertEqual(self.mock.attr, 'different value')
This means, sub-contexts can be used to "tweak" values from a parent context.

Functions
---------

Expand Down
2 changes: 1 addition & 1 deletion tests/dsl_unittest.py
Expand Up @@ -1952,7 +1952,7 @@ def example(self):
self.assertEqual(self.derived, "derived: new base")

with self.assertRaisesRegex(
AttributeError, "^Attribute 'base' is already set.*"
AttributeError, "^Attribute 'base' can not be reset.*"
):
self.run_all_examples()

Expand Down
16 changes: 8 additions & 8 deletions testslide/__init__.py
Expand Up @@ -185,14 +185,14 @@ def _all_memoizable_attributes(self) -> Dict[str, Callable]:
def __setattr__(self, name: str, value: Any) -> None:
if self.__dict__.get(name) and self.__dict__[name] != value:
raise AttributeError(
f"Attribute {repr(name)} is already set.\n"
"Changing the value of attributes after they have been set "
"can lead to unexpected test results. Eg: when a sub context "
"resets an attribute after a parent context has set and used "
"it, they will have different objects for the same attribute.\n"
"You can safely override attributes from parent contexts by "
"using @context.before, @context.memoize_before or "
"@context.function, so the inner-most definition is used."
f"Attribute {repr(name)} can not be reset.\n"
"Resetting attribute values is not permitted as it can create "
"confusion and taint test signal.\n"
"You can use memoize/memoize_before instead, as they allow "
"attributes from parent contexs to be overridden consistently "
"by sub-contexts.\n"
"Details and examples at the documentation: "
"https://testslide.readthedocs.io/en/master/testslide_dsl/context_attributes_and_functions/index.html"
)
else:
super(_ContextData, self).__setattr__(name, value)
Expand Down

0 comments on commit 8b7895a

Please sign in to comment.