Using mock objects to eliminate dependencies

In order to test Deck, we have the following two choices for handling the dependencies in the Card class hierarchy:

  • Mocking: We can create a mock (or stand-in) class for the Card class and a mock card() factory function that produces instances of the mock class. The advantage of using mock objects is that we create real confidence that the unit under test is free from workarounds in one class, which make up for bugs in another class. A rare potential disadvantage is that we may have to debug the behavior of a super-complex mock class to be sure it's a valid stand-in for a real class. A complex mock object suggests the real object is too complex and needs to be refactored.
  • Integrating: If we have a degree of trust that the Card class hierarchy works, and the card() factory function works, we can leverage these to test Deck. This strays from the high road of pure unit testing, in which all dependencies are omitted for test purposes. The disadvantage of this is that a broken foundational class will cause a large number of testing failures in all the classes that depend on it. Also, it's difficult to make detailed tests of API conformance with non-mock classes. Mock classes can track the call history, making it possible to track the details of calls to mock objects.

The unittest package includes the unittest.mock module which can be used to patch the existing classes for test purposes. It can also be used to provide complete mock class definitions. Later, when we look at pytest, we'll combine unittest.mock objects with the pytest test framework.

The examples in this section do not use extensive type hinting in the tests. For the most part, the tests should pass through mypy without difficulty. As we noted earlier, pytest version 3.8.2. doesn't have a complete set of type stubs, so the --ignore-missing-imports option must be used when running mypy. The mock objects, for the most part, provide type hints that permit mypy to confirm that they are being used properly.

When we design a class, we must consider the dependencies that must be mocked for unit testing. In the case of Deck, we have the following three dependencies to mock:

  • The Card class: This class is so simple that we could create a mock for this class without basing it on an existing implementation. As the Deck class behavior doesn't depend on any specific feature of Card, our mock object can be simple.
  • The card() factory: This function needs to be replaced with a mock that we can use to determine whether Deck makes proper calls to this function.
  • The random.Random.shuffle() method: To determine whether the method was called with proper argument values, we can provide a mock that will track usage rather than actually doing any shuffling.

Here's a version of Deck that uses the card() factory function:

class DeckEmpty(Exception):
pass

class Deck3(list):

def __init__(
self,
size: int=1,
random: random.Random=random.Random(),
card_factory: Callable[[int, Suit], Card]=card
) -> None:
super().__init__()
self.rng = random
for d in range(size):
super().extend(
[card_factory(r, s)
for r in range(1, 14)
for s in iter(Suit)])
self.rng.shuffle(self)

def deal(self) -> Card:
try:
return self.pop(0)
except IndexError:
raise DeckEmpty()

This definition has two dependencies that are specifically called out as arguments to the __init__() method. It requires a random number generator, random, and a card factory, card_factory. It has suitable default values so that it can be used in an application very simply. It can also be tested by providing mock objects instead of the default objects.

We've included a deal() method that makes a change to the object by using pop() to remove an instance of Card from the collection. If the deck is empty, the deal() method will raise a DeckEmpty exception.

Here's a test case to show you that the deck is built properly:

import unittest
import unittest.mock

class TestDeckBuild(unittest.TestCase):

def setUp(self) -> None:
self.mock_card = unittest.mock.Mock(return_value=unittest.mock.sentinel.card)
self.mock_rng = unittest.mock.Mock(wraps=random.Random())
self.mock_rng.shuffle = unittest.mock.Mock()

def test_Deck3_should_build(self) -> None:
d = Deck3(size=1, random=self.mock_rng, card_factory=self.mock_card)
self.assertEqual(52 * [unittest.mock.sentinel.card], d)
self.mock_rng.shuffle.assert_called_with(d)
self.assertEqual(52, len(self.mock_card.mock_calls))
expected = [
unittest.mock.call(r, s)
for r in range(1, 14)
for s in (Suit.CLUB, Suit.DIAMOND, Suit.HEART, Suit.SPADE)
]
self.assertEqual(expected, self.mock_card.mock_calls)

We created two mocks in the setUp() method of this test case. The mock card factory function, mock_card, is a Mock function. The defined return value is a single mock.sentinel,card object instead of a distinct Card instances. When we refer to an attribute of the mock.sentinel object, with an expression like mock.sentinel.card, this expression creates a new object if necessary, or retrieves an existing object. It implements a Singleton design pattern; there's only one sentinel object with a given name. This will be a unique object that allows us to confirm that the right number of instances were created. Because the sentinel is distinct from all other Python objects, we can distinguish functions without proper return statements that return None.

We created a mock object, mock_rng, to wrap an instance of the random.Random() generator. This Mock object will behave as a proper random object, with one difference. We replaced the shuffle() method with a Mock behaving as a function that returns None. This provides us with an appropriate return value for the method and allows us to determine that the shuffle() method was called with the proper argument values.

Our test creates a Deck3 instance with our two mock objects. We can then make the following assertions about the Deck3 instance, d:

  • 52 objects were created. These are expected to be 52 copies of mock.sentinel, showing us that only the factory function was used to create objects; all of the objects are sentinels, created by the mock.
  • The shuffle() method was called with the Deck instance as the argument. This shows us how a mock object tracks its calls. We can use assert_called_with() to confirm that the argument values were as required when shuffle() was called.
  • The factory function was called 52 times. The mock_calls attribute of a mock object is the entire history of the object's use. This assertion is, technically, redundant, since the next test will imply this condition.
  • The factory function was called with a specific list of expected rank and suit values.

The mock objects will record the sequence of methods that were invoked. In the next section, we'll look at ways to examine a mock object to ensure that other units are using the mock object correctly.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset