Unit testing during the development process

We're going to walk through the development of one class, treating it as a complete programming project and integrating unit testing at each step of the process. For something as small as a single standalone class, this may seem silly, but it illustrates the practices that keep larger projects from getting bogged down in a tangle of bugs.

We're going to create a PID controller class. A PID controller is a tool from control theory, a way of controlling machines so that they move smoothly and efficiently. The robot arms that assemble cars in factories are controlled by PID controllers. We'll be using a PID controller for this demonstration because it's a very useful, and a very real-world idea. Many programmers have been asked to implement PID controllers at some point in their careers. This example is meant to be read as if we are contractors and are being paid to produce results.

Note

If you find that the PID controllers are more interesting than simply an example in a programming book, wikipedia's article is a good place to begin learning about this: http://en.wikipedia.org/wiki/PID_controller.

Design

Our imaginary client gives us the following specification:

We want a class that implements a PID controller for a single variable. The measurement, setpoint and output should all be real numbers.

We need to be able to adjust the setpoint at runtime, but we want it to have a memory, so we can easily return to the previous setpoint.

We'll take this and make it more formal, not to mention complete, by writing a set of acceptance tests as unit tests that describe the behavior. This way we'll at least have it set down precisely as what we believe the client intended.

We need to write a set of tests that describe the constructor. After looking up what a PID controller actually is, we have learned that they are defined by three gains and a setpoint. The controller has three components: proportional, integral, and derivative (this is where the name PID comes from). Each gain is a number that determines how much effect one of the three parts of the controller has on the final result. The setpoint determines what the goal of the controller is; in other words, to where it's trying to move the controlled variable. Looking at all this, we decide that the constructor should just store the gains and the setpoint along with initializing some internal state that we know we'll need because we read about PID controllers. With this, we know enough to write some constructor tests:

>>> import pid

>>> controller = pid.PID(P=0.5, I=0.5, D=0.5, setpoint=0)

>>> controller.gains
(0.5, 0.5, 0.5)
>>> controller.setpoint
[0.0]
>>> controller.previous_time is None
True
>>> controller.previous_error
0.0
>>> controller.integrated_error
0.0

We also need tests that describe measurement processing. This means testing the actual use of the controller, taking a measured value as its input, and producing a control signal that should smoothly move the measured variable toward the setpoint.

The behavior of a PID controller is based on time; we know that, so we're going to need to be able to feed the controller time values that we choose if we expect the tests to produce predictable results. We do this by replacing time.time with a different function of the same signature, which produces predictable results.

Once we have that taken care of, we plug our test input values into the math that defines a PID controller along with the gains to figure out what the correct outputs will be, and use these numbers to write the tests:

Replace time.time with a predictable fake
>>> import time
>>> real_time = time.time
>>> time.time = (float(x) for x in range(1, 1000)).__next__

Make sure we're not inheriting old state from the constructor tests
>>> import imp
>>> pid = imp.reload(pid)

Actual tests. These test values are nearly arbitrary, having been chosen for no reason other than that they should produce easily recognized values.
>>> controller = pid.PID(P=0.5, I=0.5, D=0.5, setpoint=0)
>>> controller.calculate_response(12)
-6.0
>>> controller.calculate_response(6)
-3.0
>>> controller.calculate_response(3)
-4.5
>>> controller.calculate_response(-1.5)
-0.75
>>> controller.calculate_response(-2.25)
-1.125

Undo the fake
>>> time.time = real_time

We need to write tests that describe setpoint handling. Our client asked for a "memory" for setpoints, which we'll interpret as a stack, so we write tests that ensure that the setpoint stack works. Writing code that uses this stack behavior brings to our attention the fact that a PID controller with no setpoint is not a meaningful entity, so we add a test that checks that the PID class rejects this situation by raising an exception:

>>> pid = imp.reload(pid)
>>> controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 0)

>>> controller.push_setpoint(7)
>>> controller.setpoint
[0.0, 7.0]

>>> controller.push_setpoint(8.5)
>>> controller.setpoint
[0.0, 7.0, 8.5]

>>> controller.pop_setpoint()
8.5
>>> controller.setpoint
[0.0, 7.0]

>>> controller.pop_setpoint()
7.0
>>> controller.setpoint
[0.0]

>>> controller.pop_setpoint()
Traceback (most recent call last):
ValueError: PID controller must have a setpoint

PID controllers are well-defined elsewhere, so the sparse specification that our client gave us works pretty well over all. Still, we had to codify several assumptions when we wrote our acceptance tests; it would probably be wise to check with the client and make sure that we didn't go astray, which means that, before we even ran the tests, they already helped us by pointing out questions we needed to ask them.

We took extra steps in the tests to help isolate them from each other, by forcing the pid module to reimport before each group of test statements. This has the effect of resetting anything that might have changed in the module, and causes it to reimport any modules that it depends on. This is particularly important, since we replaced time.time with a dummy function. We want to be sure that the pid module uses the dummy time function, so we reload the pid module. If the real-time function is used instead of the dummy, the test won't be useful because it will succeed only once. Tests need to be repeatable.

The stand-in time function was created by making an iterator that counts through the integers from 1 to 999 (as floating point values), and binding time.time to that iterator's __next__ method. Once we were done with the time-dependent tests, we replaced the original time.time.

We did get a little bit lazy, though, because we didn't bother to isolate the assorted tests from the PID constructor. If there's a bug in the constructor, it might cause a false error report in any of the tests that are dependent on it. We could have been more rigorous by using a mock object instead of an actual PID object, and thus even skipped invoking the constructor during the tests of other units but, as we aren't talking about mock objects until the next chapter, we'll allow ourselves a bit of laziness here.

Right now, we have tests for a module that doesn't exist. That's good! Writing the tests was easier than writing the module, and this gives us a stepping stone towards getting the module right, quickly and easily. As a general rule, you always want to have tests ready before the code that the test is written for.

Tip

Note that I said "you want to have tests ready," not "you want to have all of the tests ready." You don't want, or need, to have every test in place before you start writing code. What you want is to have the tests in place that define the things you already know at the start of the process.

Development

Now that we have some tests, we can begin writing code to satisfy the tests, and thus also the specification.

Tip

What if the code is already written? We can still write tests for its units. This isn't as productive as writing the tests in parallel with the code, but this at least gives us a way to check our assumptions and make sure that we don't introduce regressions. A test suite written late is better than no test suite at all.

The first step is to run the tests because this is always the first thing you do when you need to decide what to do next. If all the tests pass, either you're done with the program or you need to write more tests. If one or more tests fail, you pick one and make it pass.

So, we run the tests as follows:

python3 -m doctest PID.txt

The first time they tell us that we don't have a pid module. Let's create one and fill it with a first attempt at a PID class:

from time import time

class PID:
    def __init__(self, P, I, D, setpoint):
        self.gains = (float(P), float(I), float(D))
        self.setpoint = [float(setpoint)]
        self.previous_time = None
        self.previous_error = 0.0
        self.integrated_error = 0.0

    def push_setpoint(self, target):
        self.setpoint.append(float(target))

    def pop_setpoint(self):
        if len(self.setpoint) > 1:
            return self.setpoint.pop()
        raise ValueError('PID controller must have a setpoint')

    def calculate_response(self, value):
        now = time()
        P, I, D = self.gains

        err = value - self.setpoint[-1]

        result = P * err
        if self.previous_time is not None:
            delta = now - self.previous_time
            self.integrated_error += err * delta
            result += I * self.integrated_error
            result += D * (err - self.previous_error) / delta

        self.previous_error = err
        self.previous_time = now

        return result

Now, we'll run the tests again, and see how we did as follows:

python3 -m doctest PIDflawed.txt

This immediately tells us that there's a bug in the calculate_response method:

Development

There are more error reports in the same vein. There should be five in total. It seems that the calculate_response method is working backwards, producing negatives when it should give us positives, and vice-versa.

We know that we need to look for a sign error in calculate_response, and we find it on the fourth line, where the input value should be subtracted from the setpoint and not the other way around. Things should work better if we change this line to the following:

err = self.setpoint[-1] - value

As expected, that change fixes things. The tests all pass, now.

We used our tests to tell us what was needed to be done, and to tell us when our code was complete. Our first run of the tests gave us a list of things that needed to be written; a to-do list of sorts. After we wrote some code, we ran the tests again to see if it was doing what we expected, which gave us a new to-do list. We kept on alternately running the tests and writing code to make one of the tests pass until they all did. When all the tests pass, either we're done, or we need to write more tests.

Whenever we find a bug that isn't already caught by a test, the right thing to do is to add a test that catches it, and then we need to fix the bug. This gives a fixed bug, but also a test that covers some part of the program that wasn't tested before. Your new test might well catch more bugs that you weren't even aware of, and it will help you avoid recreating the fixed bug.

This "test a little, code a little" style of programming is called Test-driven Development, and you'll find that it's very productive.

Notice that the pattern in the way the tests failed was immediately apparent. There's no guarantee that will be the case, of course, but it often is. Combined with the ability to narrow your attention to the specific units that are having problems, debugging is usually a snap.

Feedback

So, we have a PID controller, it passes our tests... are we done? Maybe. Let's ask the client.

The good news is that they mostly like it. They have a few things they'd like to be changed, though. They want us to be able to optionally specify the current time as a parameter to calculate_response, so that the specified time is used instead of the current system time. They also want us to change the signature of the constructor so that it accepts an initial measurement and optionally a measurement time as parameters.

So, the program passes all of our tests, but the tests don't correctly describe the requirements anymore. What to do?

First, we'll add the initial value parameter to the constructor tests, and update the expected results as follows:

>>> import time
>>> real_time = time.time
>>> time.time = (float(x) for x in range(1, 1000)).__next__
>>> import pid
>>> controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 0,...                      initial = 12)
>>> controller.gains
(0.5, 0.5, 0.5)
>>> controller.setpoint
[0.0]
>>> controller.previous_time
1.0
>>> controller.previous_error
-12.0
>>> controller.integrated_error
0.0
>>> time.time = real_time

Now, we'll add another test of the constructor, a test that checks the correct behavior when the optional initial time parameter is provided:

>>> import imp
>>> pid = imp.reload(pid)
>>> controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 1,
...                      initial = 12, when = 43)
>>> controller.gains
(0.5, 0.5, 0.5)
>>> controller.setpoint
[1.0]
>>> controller.previous_time
43.0
>>> controller.previous_error
-11.0
>>> controller.integrated_error
0.0

Next, we change the calculate_response tests to use the new signature for the constructor:

>>> controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 0,...                      initial = 12)

We need to add a second calculate_response test that checks whether the function behaves properly when the optional time parameter is passed to it:

>>> pid = imp.reload(pid)
>>> controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 0,
...                      initial = 12, when = 1)
>>> controller.calculate_response(6, 2)
-3.0
>>> controller.calculate_response(3, 3)
-4.5
>>> controller.calculate_response(-1.5, 4)
-0.75
>>> controller.calculate_response(-2.25, 5)
-1.125

Finally, we adjust the constructor call in the setpoint method tests. This change looks the same as the constructor call changes in the other tests.

When we're adjusting the tests, we discover that the behavior of the calculate_response method has changed due to the addition of the initial value and initial time parameters to the constructor. The tests will report this as an error but it's not clear that if it really is wrong, so we check this with the client. After talking it over, the client decides that this is actually correct behavior, so we change our tests to reflect that.

Our complete specification and test document now looks like this (new or changed lines are highlighted):

We want a class that implements a PID controller for a single
variable. The measurement, setpoint, and output should all be real
numbers. The constructor should accept an initial measurement value in
addition to the gains and setpoint.

>>> import time
>>> real_time = time.time
>>> time.time = (float(x) for x in range(1, 1000)).__next__
>>> import pid
>>> controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 0,...                      initial = 12)
>>> controller.gains
(0.5, 0.5, 0.5)
>>> controller.setpoint
[0.0]
>>> controller.previous_time
1.0
>>> controller.previous_error
-12.0
>>> controller.integrated_error
0.0
>>> time.time = real_time

The constructor should also optionally accept a parameter specifying
when the initial measurement was taken.

>>> import imp
>>> pid = imp.reload(pid)
>>> controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 1,
...                      initial = 12, when = 43)
>>> controller.gains
(0.5, 0.5, 0.5)
>>> controller.setpoint
[1.0]
>>> controller.previous_time
43.0
>>> controller.previous_error
-11.0
>>> controller.integrated_error
0.0

The calculate response method receives the measured value as input,
and returns the control signal.

>>> import time
>>> real_time = time.time
>>> time.time = (float(x) for x in range(1, 1000)).__next__
>>> pid = imp.reload(pid)
>>> controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 0,...                      initial = 12)
>>> controller.calculate_response(6)
-3.0
>>> controller.calculate_response(3)
-4.5
>>> controller.calculate_response(-1.5)
-0.75
>>> controller.calculate_response(-2.25)
-1.125
>>> time.time = real_time

The calculate_response method should be willing to accept a parameter
specifying at what time the call is happening.

>>> pid = imp.reload(pid)
>>> controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 0,
...                      initial = 12, when = 1)
>>> controller.calculate_response(6, 2)
-3.0
>>> controller.calculate_response(3, 3)
-4.5
>>> controller.calculate_response(-1.5, 4)
-0.75
>>> controller.calculate_response(-2.25, 5)
-1.125

We need to be able to adjust the setpoint at runtime, but we want it to have a memory, so that we can easily return to the previous
setpoint.

>>> pid = imp.reload(pid)
>>> controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 0,...                      initial = 12)
>>> controller.push_setpoint(7)
>>> controller.setpoint
[0.0, 7.0]
>>> controller.push_setpoint(8.5)
>>> controller.setpoint
[0.0, 7.0, 8.5]
>>> controller.pop_setpoint()
8.5
>>> controller.setpoint
[0.0, 7.0]
>>> controller.pop_setpoint()
7.0
>>> controller.setpoint
[0.0]
>>> controller.pop_setpoint()
Traceback (most recent call last):
ValueError: PID controller must have a setpoint

Our tests didn't match the requirements and so we needed to change them. That's fine, but we don't want to change them too much because the tests we have already help us to avoid some problems that we've previously spotted or had to fix. The last thing we want for the computer is to stop checking for known problems. Because of this, we very much prefer adding new tests, instead of changing old ones.

This is one reason why we added new tests to check the behavior when the optional time parameters were supplied. The other reason is that, if we added these parameters to the existing tests, we wouldn't have any tests of what happens when you don't use these parameters. We always want to check every code path through each unit.

The addition of the initial parameter to the constructor is a big deal. It not only changes the way the constructor should behave, it also changes the way the calculate_response method should behave in a rather dramatic way. Since there is a change in the correct behavior (a fact that we didn't realize until the tests pointed it out to us, which in turn allowed us to get a confirmation of what the correct behavior should be from our clients before we started writing the code), we have no choice but to go through and change the tests, recalculating the expected outputs and all. Doing all that work has a benefit, though, over and above the future ability to check whether the function is working correctly: this makes it much easier to comprehend how the function should work when we actually write it.

When we change a test to reflect new correct behavior, we still try to change it as little as possible. After all, we don't want the test to stop checking for old behavior that's still correct, and we don't want to introduce a bug in the test itself.

Tip

To a certain extent, the code being tested acts as a test of the test, so even bugs in your tests don't survive very long when you use good testing discipline.

Development, again

Time to do some more coding. In real life, we might cycle between development and feedback any number of times, depending on how well we're able to communicate with our clients. In fact, it might be a good thing to increase the number of times we go back and forth, even if this means that each cycle is short. Keeping the clients in the loop and up-to-date is a good thing.

The first step, as always, is to run the tests and get an updated list of the things that need to be done:

Python3 -m doctest PID.txt
Development, again

There are actually a lot more errors that are reported, but the very first one gives us a good hint about what we need to fix right off. The constructor needs to change to match the tests' expectations.

Using the doctest error report to guide us, and rerunning the tests frequently, we can quickly get our PID class into shape. In practice, this works best using short development cycles where you make only a few changes to the code, and then run the tests again. Fix one thing, and then test again.

Once we've gone back and forth between coding and testing enough times, we'll end up with something like this:

from time import time

class PID:
    def __init__(self, P, I, D, setpoint, initial, when = None):
        self.gains = (float(P), float(I), float(D))

        if P < 0 or I < 0 or D < 0:
            raise ValueError('PID controller gains must be non-negative')

        if not isinstance(setpoint, complex):
            setpoint = float(setpoint)

        if not isinstance(initial, complex):
            initial = float(initial)

        self.setpoint = [setpoint]

        if when is None:
            self.previous_time = time()
        else:
            self.previous_time = float(when)

        self.previous_error = self.setpoint[-1] - initial
        self.integrated_error = 0.0

    def push_setpoint(self, target):
        self.setpoint.append(float(target))

    def pop_setpoint(self):
        if len(self.setpoint) > 1:
            return self.setpoint.pop()
        raise ValueError('PID controller must have a setpoint')

    def calculate_response(self, value, now = None):
        if now is None:
            now = time()
        else:
            now = float(now)

        P, I, D = self.gains

        err = self.setpoint[-1] - value

        result = P * err
        delta = now - self.previous_time
        self.integrated_error += err * delta
        result += I * self.integrated_error
        result += D * (err - self.previous_error) / delta

        self.previous_error = err
        self.previous_time = now

        return result

Once again, all of the tests pass including all of the revised tests from the client, and it's remarkable how rewarding that lack of an error report can be. We're ready to see whether the client is willing to accept delivery of the code yet.

Later stages of the process

There are later phases of development when it's your job to maintain the code, or to integrate it into another product. Functionally, they work in the same way as the development phase. If you're handling pre-existing code and are asked to maintain or integrate it, you'll be much happier if it comes to you with a test suite already written because, until you've mastered the intricacies of the code, the test suite is the only way in which you'll be able to modify the code with confidence.

If you're unfortunate enough to be handed a pile of code with no tests, writing tests is a good first step. Each test you write is one more unit of the code that you can honestly say you understand, and know what to expect from. And, of course, each test you write is one more unit that you can count on to tell you if you introduce a bug.

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

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