Testing interface contracts in Python
I've been writing some interface heavy code in Python recently and have developed a pattern that I really like and have not seen before. I think this is original but I doubt it's the first. I'd like to hear about any similar approaches that have been documented.
Interfaces in Python
They aren't used as widely as they could be, owing to the dynamic nature of Python and it's tendency towards duck typing.
Still, there's some very good support for them in community tooling. I make very good use of pylint for detecting interface completion.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | # src/interface/knight.py
class Knight(object):
def say_ni(self):
raise NotImplementedError
def set_able(self, is_able):
raise NotImplementedError
def drink(self):
raise NotImplementedError
class NotAbleToDrinkError(Exception):
pass
|
pylint will detect that this is an abstract method and if your subclasses fail to implement it then it says so. This is nice but isn't nearly enough to guarantee correct behaviour.
Interface contracts
The contract is our specification for how an interface must behave.
In the case of our Knight, the knights must be able to say "ni" and they must drink whenever they are able.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | # src/implementation1/test_knight.py
from unittest import TestCase
from implementation_module import Knight
class TestKnightContract(TestCase):
def test_can_say_ni_without_issues(self):
knight = Knight()
knight.say_ni()
def test_drinks_when_able(self):
knight = Knight()
knight.set_able(True)
knight.drink()
def test_does_not_drink_when_unable(self):
knight = Knight()
knight.set_able(False)
with self.assertRaises(NotAbleToDrinkError):
knight.drink()
|
That's a simple test for a knight's interface contract. All good so far but it falls down when we have multiple implementations of our interface. We end up writing the same contract out again and again.
DRY - one contract better than repeated tests
Normally if someone recommends deduplicating test code "because it's more DRY" I feel compelled to remind them that DRY is about ideas, not about lines of code. In this case we are dealing with the same idea so we should re-use the same code. There is only one contract that all must obey.
My emerging pattern for implementation of this looks a little unusual - it's defining a class inside a method in order to bind the knight_factory into the closure.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | # src/interfaces/knight_contract.py
from unittest import TestCase
from .knight import NotAbleToDrinkError
def contract_test(knight_factory):
class TestKnightContract(TestCase):
def test_can_say_ni_without_issues(self):
knight = knight_factory()
knight.say_ni()
def test_drinks_when_able(self):
knight = knight_factory()
knight.set_able(True)
knight.drink()
def test_does_not_drink_when_unable(self):
knight = knight_factory()
knight.set_able(False)
with self.assertRaises(NotAbleToDrinkError):
knight.drink()
return TestKnightContract
# src/implementation1/test_knight.py
from ..interface.knight_contract import contract_test
from .knight import Knight
class TestKnight(contract_test(Knight)):
pass
|
This pattern can now be used to implement the same contract in multiple places. When the contract needs clarifying with another test you can be sure that all implementations follow it.
Note that we subclass to create this test instead of simply TestKnight = contract_test(Knight).
That simple assignment would run the tests but it would not report which implementation was failing when something broke.
A slight modification for Django
When writing these contracts for interfaces that can be backed by Django models I found a need to use Django's own TestCase implementation in order to have the test database set up correctly.
It's a small tweak:
1 2 3 4 5 6 7 8 9 10 11 | # src/interface/knight_contract.py
def contract_test(knight_generator, test_class=TestCase):
class KnightContractTest(test_class):
# On with the tests.
# django/tests/test_knight.py
class DjangoKnightTest(contract_test(Knight, test_class=django.test.TestCase)):
pass
|
Problems?
As I said as the top, I haven't seen this done before. If there's an issue with this approach that I can't see then I'd love to hear about it.