I am struggling to get my head around testing class methods "independently".
Say, I have this class:
class Counter:
def __init__(self, start = 0):
self.number = start
def increase(self):
self.number = 1
How do I sensibly test the method increase()
?
Some time ago, a senior developer told me (and maybe I misunderstood) that I should be testing my methods independently, so that, say, if some parts of the class change my method should still test OK.
This led me to test methods in a slightly cumbersome way:
# Using pytest here
def test_increase():
class MockCounter:
def __init__(self):
self.number = 0
x = MockCounter()
Counter.increase(x)
assert x.number == 1
where, basically:
- I mock the class
Counter
withMockCounter
(so the classCounter
is not being a dependency which might break my test); - I call the method to test as it was a static method.
It works, but I have the feeling I have misunderstood quite a lot here.
What am I getting wrong?
CodePudding user response:
You need to think about what kinds of changes would cause your test to fail. For example, taking the current implementation:
class Counter:
def __init__(self, start = 0):
self.number = start
def increase(self):
self.number = 1
you could test the class simply like:
def test_increase():
counter = Counter()
counter.increase()
assert counter.number == 1
What kinds of changes would cause that test to fail with a false negative? How about a change to the default value:
class Counter:
def __init__(self, start = 1):
# ^ pre-increased for your convenience
self.number = start
def increase(self):
self.number = 1
assert counter.number == 1
now fails because it's actually 2
, but the increase
method hasn't changed and still works correctly. Assuming you had another test like:
def test_default_value():
counter = Counter()
assert counter.number == 0
you'd now have two failing tests, one saying the default value is wrong and the other saying the increase method doesn't work. In this simple example it's clear that the latter is caused by the former and is a false negative, but in larger and more complex classes it can be really helpful to have better triangulation of where a given problem actually is, which is what leads to the advice to test methods independently.
However, you don't want to mock parts of the thing you're supposed to be testing, as you currently do with the MockCounter
, because the point of the exercise is to increase your confidence in the actual implementation rather than the test double (keep test doubles for collaborating objects, rather than parts of the object under test). In the worst case you end up with tests that only exercise test doubles, giving a false sense of confidence in an implementation that may or may not actually work.
So let's reframe the desired behaviour in terms of the invariants. After calling increase
, the number
attribute shouldn't always be 1
, it should be one higher than before. Expressing that in a test that's resilient to the above change:
Store the starting value before increasing and compare to that:
def test_increase(): counter = Counter() before = counter.number counter.increase() assert counter.number == before 1 # now 2, but that's irrelevant here
Alternatively you could consider the default value in
__init__
irrelevant to the behaviour of theincrease
method, and use an explicit starting point in your test instead:def test_increase(): counter = Counter(0) counter.increase() assert counter.number == 1
Either way you'd have a more robust test without having to introduce an awkward partial mock.