Skip to content

unittesting_inheritance

Carter Tinney edited this page Sep 9, 2019 · 1 revision

Advanced pattern: Shared tests through inheritance

Situation

There is an overlap between the functionality of two or more classes (generally due to a common parent).

Pattern

Consider the following file, client.py:

@six.add_metaclass(abc.ABCMeta)
class GenericClient(object):

    def __init__(self):
        self.method_b_call_count = 0

    def method_a(self):
        return "buzz"

    @abc.abstractmethod
    def method_b(self):
        self.method_b_call_count += 1


class FooClient(GenericClient):
    def method_b(self):
        super(self, FooClient).method_b()
        return "foo"


class BarClient(GenericClient):
    def method_b(self):
        super(self, BarClient).method_b()
        return "bar"

Since GenericClient is a partial abstract class, we don't want to test it directly. After all, a GenericClient cannot be directly instantiated, and any methods it may provide could be extended or modified by it's child classes (the ones actually instantiated and used), so there's no guarantee that even if you could instantiate and test GenericClient that your results would be in any way valid.

We can see from looking at the code right now that neither FooClient nor BarClient extend method_a, but if someone extended them later, changing their behavior, no tests would catch it if we only tested on the parent. Thus, the goal is to always test inherited methods on objects that are actually directly used.

But we also don't want to write a test for method_a twice - after all, it's the same function inherited on both clients. Writing the same test twice is bad for maintenance, and adds extra points of failure.

Further complicating things is that method_b has some overlapping functionality, but each class extends it in a unique way. Once again, we don't want to duplicate any requirements.

In order to solve these problems due to inhertiance, we can actually use... MORE INHERITANCE - this time on our tests themselves.

# Note that this test class does NOT begin with the word Test.
# This prevents it from being collected by pytest.
class SharedClientMethodATests(object):
    @pytest.mark.it("Returns the string 'buzz'")
    def test_returns_buzz(self, client):
        assert client.method_b() == "buzz"


class SharedClientMethodBTests(object):
    # Note that we only write tests for the requirement that is shared
    @pytest.mark.it("Increments the .method_b_call_count instance variable by 1")
    def test_increments_call_counter(self, client):
        before_call_count = client.method_b_call_count
        client.method_a()
        assert client.method_b_call_count == before_call_count + 1


class FooClientTestConfig(object):
    # Define the fixtures to be used in the FooClient tests
    # They must have a generic name (e.g. client) because
    # they will be used in the shared tests - which are not
    # client specific!
    @pytest.fixture
    def client(self):
        return FooClient()


class BarClientTestConfig(object):
    @pytest.fixture
    def client(self):
        return BarClient()


# This class does start with Test and will be gathered by pytest.
# Note that it inherits from the shared test class
# thus implicitly importing all its test methods.
# Note that it also imports from the specific test config class
# thus defining the fixutres to be used by the shared tests
@pytest.mark.describe("FooClient - .method_a()")
class TestFooClientMethodA(FooClientTestConfig, SharedClientMethodATests):
    # No implementation required! The shared .method_a() tests will be inherited into this class. Since they don't need to be extended there is no more to do!
    pass


@pytest.mark.describe("FooClient - .method_b()")
class TestFooClientMethodB(FooClientTestConfig, SharedClientMethodBTests):
    # The shared .method_b() tests will be inherited, but this time we want to 
    # extend to test FooClient specific extensions of the .method_b() implementation
    @pytest.mark.it("Returns the string 'foo'")
    def test_returns_foo(self, client):
        assert client.method_b() == "foo"


@pytest.mark.describe("BarClient - .method_a()")
class TestBarClientMethodA(BarClientTestConfig, SharedClientMethodATests):
    pass


@pytest.mark.describe("BarClient - .method_b()")
class TestBarClientMethodB(BarClientTestConfig, SharedClientMethodBTests):
    @pytest.mark.it("Returns the string 'bar'")
    def test_returns_bar(self, client):
        assert client.method_b() == "bar"

This code results in the following test output:

FooClient - .method_a()
 [x] Returns the string 'buzz'

FooClient - .method_b()
 [x] Increments the .method_b_call_count instance variable by 1
 [x] Returns the string 'foo'

BarClient - .method_a()
 [x] Returns the string 'buzz'

BarClient - .method_b()
 [x] Increments the .method_b_call_count instance variable by 1
 [x] Returns the string 'bar'

We have now, with no test duplication, provided shared tests that assure us of the functionality of inherited and extended methods!