In this chapter, we're not going to talk about new techniques of testing in Python, and we're not going to spend much time talking about the philosophy of testing. Instead, what we're going to do is a step-by-step walk-through of an actual development process. Your humble and sadly fallible author has commemorated his mistakes—and the ways that testing helped him fix them—while developing part of a personal scheduling program.
In this chapter, we'll cover the following topics:
You'll be prompted to design and build your own module as you read through this chapter, so that you can walk through your own process as well.
As usual, the process starts with a written specification. The specification is a doctest
that we learned in Chapter 2, Working with doctest, and Chapter 3, Unit Testing with doctest, so the computer can use it to check the implementation. The specification isn't strictly a set of unit tests, though the discipline of unit testing has been sacrificed (for the moment) in exchange for making the document more accessible to a human reader. That's a common trade-off, and it's fine as long as you make up for it by also writing unit tests covering the code.
The goal of the project in this chapter is to make a Python package capable of representing personal time management information.
The following code goes in a file called docs/outline.txt
:
This project is a personal scheduling system intended to keep track of a single person's schedule and activities. The system will store and display two kinds of schedule information: activities and statuses. Activities and statuses both support a protocol which allows them to be checked for overlap with another object supporting the protocol. >>> from planner.data import Activity, Status >>> from datetime import datetime Activities and statuses are stored in schedules, to which they can be added and removed. >>> from planner.data import Schedule >>> activity = Activity('test activity', .. datetime(year = 2014, month = 6, day = 1, .. hour = 10, minute = 15), .. datetime(year = 2014, month = 6, day = 1, .. hour = 12, minute = 30)) >>> duplicate_activity = Activity('test activity', .. datetime(year = 2014, month = 6, day = 1, .. hour = 10, minute = 15), .. datetime(year = 2014, month = 6, day = 1, .. hour = 12, minute = 30)) >>> status = Status('test status', ... datetime(year = 2014, month = 7, day = 1, ... hour = 10, minute = 15), ... datetime(year = 2014, month = 7, day = 1, ... hour = 12, minute = 30)) >>> schedule = Schedule() >>> schedule.add(activity) >>> schedule.add(status) >>> status in schedule True >>> activity in schedule True >>> duplicate_activity in schedule True >>> schedule.remove(activity) >>> schedule.remove(status) >>> status in schedule False >>> activity in schedule False Activities represent tasks that the person must actively engage in, and they are therefore mutually exclusive: no person can have two activities that overlap the same period of time. >>> activity1 = Activity('test activity 1', ... datetime(year = 2014, month = 6, day = 1, ... hour = 9, minute = 5), ... datetime(year = 2014, month = 6, day = 1, ... hour = 12, minute = 30)) >>> activity2 = Activity('test activity 2', ... datetime(year = 2014, month = 6, day = 1, ... hour = 10, minute = 15), ... datetime(year = 2014, month = 6, day = 1, ... hour = 13, minute = 30)) >>> schedule = Schedule() >>> schedule.add(activity1) >>> schedule.add(activity2) Traceback (most recent call last): ScheduleError: "test activity 2" overlaps with "test activity 1" Statuses represent tasks that a person engages in passively, and so can overlap with each other and with activities. >>> activity1 = Activity('test activity 1', ... datetime(year = 2014, month = 6, day = 1, ... hour = 9, minute = 5), ... datetime(year = 2014, month = 6, day = 1, ... hour = 12, minute = 30)) >>> status1 = Status('test status 1', ... datetime(year = 2014, month = 6, day = 1, ... hour = 10, minute = 15), ... datetime(year = 2014, month = 6, day = 1, ... hour = 13, minute = 30)) >>> status2 = Status('test status 2', ... datetime(year = 2014, month = 6, day = 1, ... hour = 8, minute = 45), ... datetime(year = 2014, month = 6, day = 1, ... hour = 15, minute = 30)) >>> schedule = Schedule() >>> schedule.add(activity1) >>> schedule.add(status1) >>> schedule.add(status2) >>> activity1 in schedule True >>> status1 in schedule True >>> status2 in schedule True Schedules can be saved to a sqlite database, and they can be reloaded from that stored state. >>> from planner.persistence import file >>> storage = File(':memory:') >>> schedule.store(storage) >>> newsched = Schedule.load(storage) >>> schedule == newsched True
This doctest
will serve as a testable specification for my project, which means that it will be the foundation stone for all of my tests and my program code that will be built on. Let's look at each section in more detail:
This project is a personal scheduling system intended to keep track of a single person's schedule and activities. The system will store and display two kinds of schedule information: activities and statuses. Activities and statuses both support a protocol which allows them to be checked for overlap with another object supporting the protocol. >>> from planner.data import Activity, Status >>> from datetime import datetime
The preceding code consists of some introductory English text, and a couple of import
statements that bring in code that we need for these tests. By doing so, they also tell us about some of the structure of the planner
package. It contains a module called data
that defines Activity
and Status
.
Activities and statuses are stored in schedules, to which they can be added and removed. >>> from planner.data import Schedule >>> activity = Activity('test activity', .. datetime(year = 2014, month = 6, day = 1, .. hour = 10, minute = 15), .. datetime(year = 2014, month = 6, day = 1, .. hour = 12, minute = 30)) >>> duplicate_activity = Activity('test activity', .. datetime(year = 2014, month = 6, day = 1, .. hour = 10, minute = 15), .. datetime(year = 2014, month = 6, day = 1, .. hour = 12, minute = 30)) >>> status = Status('test status', ... datetime(year = 2014, month = 7, day = 1, ... hour = 10, minute = 15), ... datetime(year = 2014, month = 7, day = 1, ... hour = 12, minute = 30)) >>> schedule = Schedule() >>> schedule.add(activity) >>> schedule.add(status) >>> status in schedule True >>> activity in schedule True >>> duplicate_activity in schedule True >>> schedule.remove(activity) >>> schedule.remove(status) >>> status in schedule False >>> activity in schedule False
The preceding tests describe some of the desired behavior of the Schedule
instances when interacting with the Activity
and Status
objects. According to these tests, a Schedule
instance must accept an Activity
or Status
object as the parameter of its add
and remove
methods; once added, the in
operator must return True
for an object until it is removed. Furthermore, the two Activity
instances that have the same parameters must be treated as the same object by Schedule
:
Activities represent tasks that the person must actively engage in, and they are therefore mutually exclusive: no person can have two activities that overlap the same period of time. >>> activity1 = Activity('test activity 1', ... datetime(year = 2014, month = 6, day = 1, ... hour = 9, minute = 5), ... datetime(year = 2014, month = 6, day = 1, ... hour = 12, minute = 30)) >>> activity2 = Activity('test activity 2', ... datetime(year = 2014, month = 6, day = 1, ... hour = 10, minute = 15), ... datetime(year = 2014, month = 6, day = 1, ... hour = 13, minute = 30)) >>> schedule = Schedule() >>> schedule.add(activity1) >>> schedule.add(activity2) Traceback (most recent call last): ScheduleError: "test activity 2" overlaps with "test activity 1"
The preceding test code describes what should happen when overlapping activities are added to a schedule. Specifically, a ScheduleError
exception should be raised:
Statuses represent tasks that a person engages in passively, and so can overlap with each other and with activities. >>> activity1 = Activity('test activity 1', ... datetime(year = 2014, month = 6, day = 1, ... hour = 9, minute = 5), ... datetime(year = 2014, month = 6, day = 1, ... hour = 12, minute = 30)) >>> status1 = Status('test status 1', ... datetime(year = 2014, month = 6, day = 1, ... hour = 10, minute = 15), ... datetime(year = 2014, month = 6, day = 1, ... hour = 13, minute = 30)) >>> status2 = Status('test status 2', ... datetime(year = 2014, month = 6, day = 1, ... hour = 8, minute = 45), ... datetime(year = 2014, month = 6, day = 1, ... hour = 15, minute = 30)) >>> schedule = Schedule() >>> schedule.add(activity1) >>> schedule.add(status1) >>> schedule.add(status2) >>> activity1 in schedule True >>> status1 in schedule True >>> status2 in schedule True
The preceding test code describes what should happen when overlapping statuses are added to a schedule: the schedule should accept them. Furthermore, if a status and an activity overlap, they can still both be added:
Schedules can be saved to a sqlite database, and they can be reloaded from that stored state. >>> from planner.persistence import file >>> storage = File(':memory:') >>> schedule.store(storage) >>> newsched = Schedule.load(storage) >>> schedule == newsched True
The preceding code describes how schedule storage should work. It also tells us that the planner
package needs to contain a persistence
module that, in turn, should contain File
. It also tells us that Schedule
instances should have load
and store
methods, and that the ==
operator should return True
when they contain the same data.
It's time for you to come up with a project of your own, something you can work on for yourself. We step through the development process:
Imagine that the project is already done, and you need to write a description of what you've done, along with a little bit of demonstration code. Then go ahead and write your description and demo code in the form of a doctest
file.
doctest
file, watch out for places where your original idea has to change a little bit to make the demo easier to write or work better. When you find such cases, pay attention to them! At this stage, it's better to change the idea a little bit and save yourself effort all through the process.We've now got testable specifications for a couple of moderately-sized projects—yours and mine. These will help us to write unit tests and code, and they'll give us a sense of how complete each project is as a whole.
In addition, the process of writing code into the doctest
gave us a chance to test-drive our ideas. We've probably improved on our projects a little bit by using them in a concrete manner, even though the project implementation is still merely imaginary.
Once again, it's important that we have these tests written before writing the code that they will test. By writing the tests first, we give ourselves a touchstone that we can use in order to judge how well our code conforms to what we intended. If we write the code first, and then the tests, all we end up doing is enshrining what the code actually does—as opposed to what we meant for it to do—into the tests.
3.14.131.212