Review of Testability

This chapter presents some design rework and implementation rework for testability purposes. While testability is very important, new programmers can be slowed to a crawl by the mechanics of building test drivers and test cases. We prefer to emphasize the basic design considerations first, and address testability as a feature to be added to a working class.

In Test Scaffolding we’ll look at the basic software components required to build unit tests.

One approach is to write tests first, then create software that passes the tests. We’ll look at this in Test-Driven Design.

The application works with random numbers. This is awkward for testing purposes. We’ll show one approach to solving this problem in Capturing Pseudo-Random Data.

We’ll touch on a few additional topics in Testability Questions and Answers.

In Testability Deliverables we’ll enumerate some deliverables that will improve the overall quality of our application.

We’ll look a little more deeply at random numbers in Appendix: On Random Numbers.

Test Scaffolding

Without pausing, we charged past an elephant standing in the saloon. It’s time to pause a moment a take a quick glance back at the pachyderm we ignored.

In the Roulette Game Class we encouraged creating a stub Player class and building a test that integrated the Game, Table, Wheel, and the stub Player class into a kind of working application. This is an integration test, not a proper unit test. We’ve integrated our various classes into a working whole.

While this integration test reflects our overall goals, it’s not always the best way to assure that the individual classes work in isolation. We need to refine our approach somewhat.

Back in Wheel Class we touched on the problem of testing an application that includes a random number generator (RNG). There are two questions raised:

  1. How can we develop formalized unit tests when we can’t predict the random outcomes? This is a serious testability issue in randomized simulations. This question also arises when considering interactive applications, particularly for performance tests of web applications where requests are received at random intervals.

  2. Are the numbers really random? This is a more subtle issue, and is only relevant for more serious applications. Cryptographic applications may care more deeply about the randomness of random numbers. This is a large subject, and well beyond the scope of this book. We’ll just assume that our random number generator is good enough for statistical work. It must be consistently difficult to predict, but also as fair as the real world.

To address the testing issue, we need to develop some scaffolding that permits more controlled testing. We want to isolate each class so that our testing reveals problems in the class under test.

There are two approaches to replacing the random behavior with something more controlled.

  • One approach is to create a mocked implementation of random.Random that returns specific outcomes that are appropriate for a given test.

  • A second approach is to record the sequence of random numbers actually generated from a particular seed value and use this to define the expected test results. We suggested forcing the seed to be 42 with wheel.rng.seed(42).

Test-Driven Design

Good testability is achieved when classes are tested in isolation and there are no changes to the class being tested. We have to be careful that our design for the Wheel class works with a real random number generator as well as a mocked version of a random number generator.

To facilitate this, we suggested making the random number generator in the Wheel class visible. Rather than have a Wheel instance use the random module directly, we suggesting creating an instance of the random.Random class as an attribute of each Wheel instance.

This design choice reveals a tension between the encapsulation principle and the testability principle.

By Encapsulation we mean the design strategy where we define a class to encapsulate the details of it’s implementation. It’s unclear if the random number generator is an implementation detail or an explicit part of the Wheel class implementation.

By Testability we mean a design strategy where we can easily isolate each class for unit testing. This is sometimes achieved by using complex dependency injection. For testing, mock classes are injected; for real use, the real classes are injected. The dependency injection machinery in other languages is designed around the requirements of the compiler. Python doesn’t really need complex injection tools.

Generally, for must of the normal use cases, the random number generator inside a Wheel object is an invisible implementation detail. However, for testing purposes, the random number generator needs to be a configurable feature of the Wheel instance.

One approach to making something more visible is to provide default values in the constructor for the object. The following example provides an rng parameter to permit inserting a mocked random number generator.

Wheel with Complex Initialization

class Wheel_RNG:
    def __init__(self, bins: List[Bin], rng: random.Random=None) -> None:
        self.bins = bins
        self.rng = rng or random.Random()

    def choose(self) -> Bin:
        return self.rng.choice(self.bins)

For this particular situation, this technique is noisy. It introduces a feature that we’ll never use outside writing tests. The choice of a random number generator is made infrequently; often the choice is made only once when a generator with desired statistical properties is identified.

Because Python type checking happens at run time, it’s easier to patch a class as part of the unit test.

Here’s a simpler Wheel class definition with simpler initialization.

Wheel with Simpler Initialization

class Wheel:
    def __init__(self, bins: List[Bin]) -> None:
        self.bins = bins
        self.rng = random.Random()

    def choose(self) -> Bin:
        return self.rng.choice(self.bins)

Since we can inject anything as the random number generator in a Wheel instance, our unit tests can look like this:

Mock Object Testing

def test_wheel_isolation():
    mock_rng = Mock(
        choice=Mock(return_value="bin1")
    )

    bins = ["bin1", "bin2"]
    wheel = Wheel(bins)
    wheel.rng = mock_rng  # Replaces random.Random
    value = wheel.choose()

    assert value == "bin1"
    mock_rng.choice.assert_called_with(bins)

This function creates a mocked instance of the random.Random class. The mock defines a choice() method; this method always returns the same value.

The test case builds a number of mocked Bin instances. In this case, we don’t even use the Bin class definition, we can use a simple string object.

The Wheel instance is built from the mocked bins. The rng attribute is then patched to use the mocked random number generator. After the patch is applied, we can exercise the Wheel.choose() method to confirm that it properly uses the random number generator’s choice() method.

Capturing Pseudo-Random Data

The other approach – using a fixed seed – means that we need to build and execute a program that reveals the fixed sequence of spins that are created by the non-random number generator.

We can create an instance of Wheel class. We can set the random number generator seed to a known, boring value, like 42.

When can call the Wheel.choose() method six times, and print the winning Bin instances. This sequence will always be the result for a seed value of 42.

This discovery procedure will reveal results needed to create unit tests for Wheel class and anything that uses it, for example, Game.

Repeatable Random Sequences

"""
Building Skills in Object-Oriented Design V4

Demo of repeatable random tests.
"""

import random
from collections import Counter

def dice():
    return random.randint(1,6), random.randint(1, 6)

def dice_histogram(seed: int=42, samples: int=10_000) -> Counter:
    """
    Generate a lot of random numbers.

    >>> c = dice_histogram()
    >>> c.most_common(5)
    [(7, 1704), (8, 1392), (6, 1359), (9, 1116), (5, 1094)]
    """
    random.seed(seed)
    c = Counter(
        sum(dice()) for _ in range(samples)
    )
    return c

Testability Questions and Answers

Why are we making the random number generator more visible? Isn’t object design about encapsulation?

Encapsulation isn’t exactly the same thing as “information hiding”. For some people, the information hiding concept can be a useful way to begin to learn about encapsulation. However, information hiding is often taken to extremes.

In this case, we want to encapsulate the bins of the wheel and the procedure for selecting the winning bin into a single object. However, the exact random-number generator (RNG) is a separate component, allowing us to bind any suitable RNG.

Consider the situation where we are generating random numbers for a cryptographic application. In this case, the built-in random number generator may not be random enough. In this case, we may have a third-party Super-Random-Generator that should replace the built-in generator. We would prefer to minimize the changes required to introduce this new class.

Our initial design has isolated the changes to the Wheel class, but required us to change the constructor. Since we are changing the source code for a class, we must to unit test that change. Further, we are also obligated unit test all of the classes that depend on this class. Changing the source for a class deep within the application forces us to endure the consequence of retesting every class that depends on this deeply buried class. This is too much work to simply replace one object with another.

We do, however, have an alternative. We can change the top-level main() method, altering the concrete object instances that compose the working application. By making the change at the top of the application, we don’t need to change a deeply buried class and unit test all the classes that depend on the changed class. Instead, we are simply choosing among objects with the same superclass or interface.

This is why we feel that constructors should be made very visible using the various design patterns for Factories and Builders. Further, we look at the main method as a kind of master Builder that assembles the objects that comprise the current execution of our application.

See our Roulette Solution Questions and Answers FAQ for more on this subject.

Looking ahead, we will have additional notes on this topic as we add the SevenReds Player Class subclass of Player class.

If setting the seed works so well, why use a mock object?

While setting the seed is an excellent method for setting up a test, it’s not actually a unit test. The Wheel is not used in isolation from other classes.

Why use 42 for a seed?

It’s mentioned in Douglas Adams’ The Hitchhiker’s Guide to the Galaxy. There are several boring numbers that are good choices because the numbers are otherwise mathematically uninteresting.

Testability Deliverables

There are two deliverables for this exercise. All of these deliverables need Python docstrings.

  • Revised unit tests for Wheel using a proper Mock for the random number generator.

  • Revised unit tests for Game using a proper Mock for the Wheel.

Appendix: On Random Numbers

Random numbers aren’t actually “random.” Since they are generated by an algorithm, they are properly called pseudo-random. The distinction is important. Pseudo-random numbers are generated in a fixed sequence from a given seed value. Computing the next value in the sequence involves a calculation that is expected to overflow the available number of bits of precision leaving apparently random bits as the next value. This leads to results which, while predictable, are arbitrary enough that they pass rigorous statistical tests and are indistinguishable from data created by random processes.

We can make an application more predictable by selecting a specific seed value. This provides reproducible results.

We can make an application less predictable by choosing a very hard to predict seed value. In most operating systems a special “device” is available for producing random values. In Linux this is typically /dev/random. In Python, we can access this through the os.urandom() function as well as the secrets module.

When we need to make an application’s output repeatable, we set a known seed value. For testing purposes, we can note the sequence of numbers generated and use this to assure a repeatable test.

We can also write a short demonstration program to see the effect of setting a fixed seed. This will also give us a set of predictable answers for unit testing.

Looking Forward

We’ve built a simple, prototype Player class definition. This player only places a limited number of bets. The house edge in Roulette assures us that the play will, before long, run out of money.

A bet on black pays winnings as if the probability of winning were \tfrac{1}{2}. The actual probability is \tfrac{18}{38}. The difference, \tfrac{1}{38} = \tfrac{1}{2}-\tfrac{18}{38}, tells us that the expected number of spins is 38 before the player is out of money.

There’s little that can be done in the real world. We can, however, simulate the variety of creative ways people apply fallacious reasoning to try and prevent this inevitable loss. We’ll start with a general Player class and then implement a number of stateful algorithms for betting.