Sunday, 4 November 2007

Painless module monkey-patching

One of Python's great advantages is its absolutely dynamic nature, which enables you to alter many objects (including modules) in runtime. This is particularily useful for testing since you can monkey-patch your module before exercising the SUT and make your tests way more granular.

However, the process has drawbacks, the biggest of which is its tendency towards verbosity. In order to not break other tests in a suite, you need to store the original attributes of the module and set them again after excercising the SUT. And to make sure the module _will_ be fixed you need a try-finally clause. With more than one modules this can lead to a several headache.

So, driven mad by a need to write 40-line test method excercising 4-liner, I imagined a dream-interface for that. What I came up with, besides the module-mocking power offered a mocha-like interface for mocking other objects as well. I have to say, I'm pretty damn proud of the result.



class Automaton(object):

...

def save_in_png(self, filename):
f = open('/tmp/graph.dot', "w")
f.write(self.as_dot())
f.close()
subprocess.check_call(['dot', '-Tpng', '-o', filename, '/tmp/graph.dot'])



class AutomatonTest(unittest.TestCase):

...


def test_save_in_png(self):

a = Automaton({(1, 'a'): [2, 3], (2, 'b'): [3]}, 1, [2, 3])
mock = Mocker(self)
mockfile = mock.object('file').expects('write', args=['beetle']).expects('close')
mock.function_in_module('subprocess.check_call',
args=[['dot', '-Tpng', '-o', 'ladybird', '/tmp/graph.dot']])
mock.function_in_module('__builtin__.open',
args=['/tmp/graph.dot', 'w'],
returns=mockfile)
mock.method(a, 'as_dot', args=[], returns='beetle')

mock.run(a.save_in_png, 'ladybird')







If the test fails, the mocker tries to be helpful again:


Traceback (most recent call last):
File "automaton_test.py", line 77, in test_save_in_png
mock.run(a.save_in_png, 'biedronka')
File "/Users/konrad/dev/sandbox/automata/mocking.py", line 73, in run
'Calls listing of %s fails: \nactual: %s\nexpected: %s' % (key, pretty(invoke_dict['actual_calls']), pretty(invoke_dict['expected_calls'])))
AssertionError: Calls listing of file.close fails:
actual: <No calls>
expected: file.close()

No comments: