Tuesday 29 August 2017

A context manager for testing whether an object has changed

Firstly an apology for the break of well over a year in this blog; I was busy with other projects and took my eye off this series.

In my current python project, I have a class which will under some conditions create a clone of an instance of itself and then make changes to the cloned instance.

It is critical to the future operation that the the changes are only made to the clone, and that the original is not changed; and I wanted to find a simple way to test this.

My first try was to do this (as an example) :

from copy import copy

class ClassUnderTest:
    def __init__(self, value):
        """Example Class to be tested"""
        self.x = value

    def operation(self,value):
        """Create a new instance with the x attributed incremented"""
        self.x += value
        clone = copy(self)
        return clone

def test1():

    inst1 = ClassUnderTest( 5 )
    inst_copy = copy(inst1)

    inst2 = inst1.operation(1)

    assert inst1 == inst_copy

    assert inst2.x == 6

By taking a copy of the instance being tested, we can compare a before and after version of the instance. In this case inst1 is reference to the instance being tested, so if the operation changes the instance (as it does in this case), then inst1 will change. The copy though wont change at all, so comparing the two objects (as in the first assert statement), will confirm if the operation has changed the original instance rather than the new instance.

This approach has a number of issues :
  1. It is a pain to do this across 20 or 30 test cases
  2. It isn't that readable as to what this test is doing or why
  3. It only works if the class under test implements the __eq__ and __hash__ methods
I realized that a solution with a context manager would at least in part solve the with first issue;  if I could write something like this :

def test1():

    inst1 = ClassUnderTest( 5 )

    with Invariant(inst1):
       inst2 = inst1.operation(1)

    assert inst2.x == 6

Assuming the context manager works - more on that in a bit - this is eminently more readable. It is clear that the expectation that within the with block, is that the inst1 is invariant (i.e. it doesn't change). There is far less boiler plate, and the test function is clearly executing tests, rather doing stuff to make the test possible.

My 1st version of the invariant context manager was :

class Invariant():
    def __init__(self, obj):
        """Context manager to confirm that an object doesn't change within the context"""
        self._obj = obj
        self._old_obj = copy(obj)

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        # Don't suppress exceptions
        if exc_type:
            return False

        if self.obj != self._old_obj:
            raise ValueError(
                    'Objects are different :\n{original!r}\n     now : {now!r}'.format(
                        self._obj, self._old_obj)) from None

The issue with this version is that it still relies on the class implementing __eq__ and __hash__ methods; and the exception that is raised will only inform you that the instance has changed, not which attributes have changed.

A better approach would be to compare each attribute individually and report any changes - thus this version :

class Invariant():
    def __init__(self, obj):
        """Conext manager to confirm that an object doesn't change within the context

           Confirms that each attribute is bound to the same value or to a object
           which tests equal this is a shallow comparison only - it doesn't test
           attributes of attributes.
        """
        self._obj = obj
        self._old_obj = copy(obj)

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        # Don't suppress any exceptions
        if exc_type:
            return False
        
        # Compare each attribute
        for att in self._obj.__dict__:
            if getattr(self._obj, att, None) == getattr(self._old_obj, att, None):
                continue
            else:
                raise ValueError(
                    '{name} attribute is different : \n'
                    'original : {original!r}\n'
                    '     now : {now!r}'.format(
                            name=att,
                            now=getattr(self._obj, att,None),
                            original=getattr(self._old_obj, att,None) ) ) from None

This is far better but it does have a few issues still - left for the reader :
  • It requires attributes of the class to have implemented __eq__ and __hash__; not an issue if all attributes are simple builtin classes (int, float, list etc ..)
  • It will report each changed attribute one at at time, not all at once
  • While it will spot any attributes that have either changed or have been added to the instance, it wont detect any attributes which have been deleted.
  • It won't work with classes which use __slots__
I hope you find this useful.