Spring 2008 CSci 553 Lab 5
Lab 05
Unit Testing, Classes and Design Patterns
Goals
Test-driven development is not about testing. Test-driven development is about development (and design), specifically improving the quality and design of code. The resulting unit tests are just an extremely useful by-product.
That's all I'm going to tell you about test-driven development. The rest of this lab will show you how it works. In this lab we'll build a very simple tool together. We'll make mistakes, fix them, and change designs in response to what the tests tell us. Along the way, we'll throw in a few refactorings, design patterns, and object-oriented design principles. To make this project fun, we'll do it in Python.
Python
is an excellent language for test-driven development because it
(usually) does exactly what you want it to without getting in your
way. The standard library even comes with everything you need in
order to start developing TDD-style. I assume that you're familiar
with Python but not necessarily familiar with test-driven development
or Python's unittest
module. You need to know only a little in order to start testing.
Instructions
For this and all future labs, all of your work will be done and submitted through your own student repository on the nisl class server. If you have not done so already, you need to check out a working copy of your personal repository before you can begin this lab.
Use the in-class lab time not only to find the answers to the questions and complete the given tasks, but to practice your skills in using the Python programming language, executing scripts and commands from the command line, and working with Python classes, libraries and the unittest framework. The first portion of the lab is to be completed in class so that you may ask the instructor for help and suggestions for any problems you may be experiencing.
Exercise 5.0: Preliminaries
Check out a working copy of your students repository if you haven't already done so. Your student repository will have a url designation of the form:
http://nisl.tamu-commerce.edu/repo/csci553/username
Where you need to substitute your own nisl account username for the last portion above.
In your working copy, create and add to your repository a directory called lab05 (make sure you name it exactly as shown, make sure you not only create the directory, but you run the appropriate command to add the directory to be under version control). All work for this lab is to be added and checked into the lab05 directory of your repository.
After creating your lab05 directory untar and uncompress the file /home/csci553/classfiles/lab05.tgz to your new lab05 directory. Several python scripts and other files are in this archive. Add all of these new files and commit them to your repository.
Exercise 5.1: Python's unittest Module
Since
version 2.1, Python's standard library has included a unittest
module, based on JUnit (by Kent Beck and Erich Gamma), the de facto
standard unit test framework for Java developers. Formerly known as
PyUnit, it also runs on Python versions prior to 2.1 with a separate
download.
Examine the TestOdd.py script in lab05 directory. TestOdd.py consists of a single “unit”, which in this case is a simple function that should return True if its integer parameter is odd, and False otherwise. You can run the tests by invoking the script:
[harry@nisl lab05]$ ./TestOdd.py
.F
======================================================================
FAIL: testTwo (__main__.IsOddTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "./TestOdd.py", line 16, in testTwo
self.failIf(IsOdd(2))
AssertionError
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)
The test is currently failing while executing testTwo. Go ahead and fix/implement the isOdd function. When you have correctly implemented the function your invocation of the test module should now succeed for all tests.
[harry@nisl lab05]$ ./TestOdd.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
Methods
whose names start with the string test
with one argument (self)
in classes derived from unittest.TestCase
are test cases. In our example, testOne
and testTwo
are test cases.
Grouping
related test cases together, test fixtures are classes that derive
from unittest.TestCase.
In the above example, IsOddTests
is a test fixture. This is true even though IsOddTests
derives from a class called TestCase,
not TestFixture.
Trust me on this.
Test
fixtures can contain setUp
and tearDown
methods, which the test runner will call before and after every test
case, respectively. Having a setUp
method is the real justification for fixtures, because it allows us
to extract common setup code from multiple test cases into the one
setUp
method.
In
Python we typically don't need a tearDown
method, because we can usually rely on Python's garbage collection
facilities to clean up our objects for us. When testing against a
database, however, tearDown
could be useful for closing connections, deleting tables, and so on.
Looking
back at our example, the main
function defined in the unittest
module makes it possible to execute the tests in the same manner as
executing any other script. This function examines sys.argv,
making it possible to supply command-line arguments to customize the
test output or to run only specific fixtures or cases (use --help
when you invoke ./TestOdd.py to see the arguments). The default
behavior is to run all test cases in all test fixtures found in the
file containing the call to unittest.main.
Typically, we wouldn't have the tests and the unit being tested in
the same file, but it doesn't hurt to start out that way and then
extract the code or the tests later.
At this point, once you have your isOdd function working for the two tests, add test for negative numbers. You should add two separate tests. Test -1 (isOdd should return True for -1) and -2 (isOdd should return False for -2). Does your function still work for negative numbers? If not fix it. Also add a fifth test testing the result of your isOdd function on zero (0). Zero is usually considered an even number, so your isOdd function should return False if passed zero as a parameter.
Once your isOdd function works for all 5 tests you are complete with part 1. You should commit your additions to the TestOdd.py script to the repository before moving to the next part of the lab.
Exercise 5.2: Motivation for the Project
Guess what I have trouble remembering to do:
0 0 * * * [ `date +\%m` -ne `date -d +4days +\%m` ] \&& mail -s 'Pay the mortgage!' me-and-my-wife@example.org < /dev/null
That little puzzle is a line out of my crontab that emails me a reminder to pay the rent on the last four days of each month. Pathetic? Probably. It works, though. I haven't been late sending my mortgage payment since I started using it.
As clever as I thought I was for coming up with this, it wasn't practical for everything--especially for events that occur only once. Also, there's no way I could teach my wife enough bash scripting techniques in order to add a reminder to our calendar.
Most people use a good old-fashioned wall calendar for this type of thing. That's not techno-geeky enough for me.
I could use Outlook or Evolution or some productivity application, but that would open up a whole new can of worms. We don't use just one computer. We both use multiple computers and operating systems at home and at work. How could we easily synchronize all of those machines?
It was after realizing that our email is available to us no matter where we were that I hit upon the motivation for my project. The email reminding me to pay the mortgage was with me no matter what machine I'm on because I always check my email via IMAP, so my email is accessible from everywhere.
Why not email the upcoming events in my calendar to me just like my reminder to pay the rent? Brilliant, I thought. I know just the tools that can do this, too: the BSD calendar application and the new kid on the block, pal.
My
wife and I have a private wiki that we use for keeping track of
notes. It's great. Despite the fact that my wife's a psychology major
and not a geek, she has no trouble using it. I figured we could use
the wiki to edit our calendar file. I would write a little cron job
to fetch the calendar file--probably using wget--from
the wiki and pipe that into whatever tool best fit our needs.
Unfortunately,
after looking at both calendar
and pal,
I discovered that neither was what I was looking for.
The
calendar file format requires a <tab> character between dates
and descriptions. Since I wanted to use our personal wiki to edit the
calendar file, inserting <tab> characters would be an issue
(upon hitting <tab>, focus jumps out of the text area to the
next form control). calendar
also doesn't support any of the fancy output options that pal
does.
The
pal
format was much too geeky for even me to want to use, and it didn't
support the one really important use case I had so far: setting a
reminder for the last day of the month.
Sample Input
My wife and I sat down and came up with something both of us would want to use. Here are some examples:
30 Nov 2007: Dinner with the Saffers.August 16: Happy Anniversary!Wednesday: Piano lesson.Last Thursday: Goody night at book study. Yum.-1: Pay the rent!
Unlike the calendar format, a colon separates dates from descriptions. How Pythonic.
Like
the calendar format, omitted certain fields are wildcards. The April
10
event happens every year. The Last
Thursday
event happens on the final Thursday of every month of every year.
The
-1
event happens on the last day of every month of every year too. I
took this idea from Python's array subscript syntax, where foo[-1]
selects the last element in the foo array. I thought it was a little
geeky, but my wife understood it right away.
Our goal is to write a small application that can run from cron to read a file in this format and email my wife and me the events we have scheduled for the next seven days. That shouldn't be too hard, should it?
Being test infected means that we must write this tool by writing all of the unit tests before writing the code we expect the tests to exercise.
The first thing to do when starting a new project is to create an empty fixture that fails. Examine the TestFoo.py script. This is simply an empty test fixture that always fails. I do this out of habit, just to make sure I have everything typed in correctly and to test that the test runner can find the fixture.
Notice
the class named FooTests
and its testFoo
method. At this point we have no idea what we're going to test first.
We just want to make sure that everything is ready once things get
going.
Let's start out easy and test the first example from above with the full day, month, and year specified for the event. In order to create this test, we need to know what to test. Am I testing a class? A function?
This is where we put on our designer hats for a brief moment and try to use our experience and intuition to come up with some piece to the puzzle that will help us reach our goal. It's OK if we make a mistake here; the tests will reveal that right away, before we invest too much in this design. We certainly don't want to draft any documents filled with diagrams. Save those for later, after we have a clue about what will actually work.
For this project, we should probably create objects that can say whether they "match" a given date. These objects will act as a "pattern" for dates. (I'm using regular expressions as a metaphor here.)
Eventually, we'll have to write a parser that will read in a file line by line (similar to the filters you have seen previously) and create these pattern objects, but we'll do that later. These pattern objects are probably an easier place to start.
There might be multiple types of patterns--but we won't think about that now, because we could be wrong. Instead, we'll start coding so we can let it tell us what it wants to become. Modify the testFoo method, and change it to look like this:
def testMatches(self):
p = DatePattern(2007, 9, 28)
d = datetime.date(2007, 9, 28)
self.failUnless(p.matches(d))
Notice
that we changed the name of the method from testFoo
to something more appropriate, because we now have an idea about what
to test. We've also invented a class name, DatePattern,
and a method name, matches.
(The datetime
module is part of Python 2.3 and up-- you need to add an import
statement at the top of your TestFoo file in order to use it. Add
the import statement now.)
This
test, of course, fails miserably--the DatePattern
class doesn't even exist yet! You should try running the TestFoo.py
script now to see what happens. It should fail the test with an
(expected) DatePattern is not defined exception. But we at least
know now the name of the class we need to implement. We also know the
name and signature of one of its methods and the signature for its
__init__
method. Here's what we can do with this knowledge:
class DatePattern:
def __init__(self, year, month, day):
pass
def matches(self, date):
return True
You should add this class stub in your TestFoo.py file, above the TestFoo testing framework class). Now the test passes! It's time to move on to the next test. You probably think I'm joking, don't you? I'm not. If your test is successfully passing, go ahead and commit your changes at this point to the repository.
Test-driven development (or any development/design work in my opinion) is best when you move in the smallest possible increments. You should only be writing code that makes the current failing test case(s) pass. Once the tests pass, you're done writing code. Stop!
The above code is worthless, right? It basically says that every pattern matches every date. How can I justify spending the time to come up with a "real" implementation? By adding another test. Add the following test under your current test:
def testMatchesFalse(self):
p = DatePattern(2007, 9, 28)
d = datetime.date(2007, 9, 29)
self.failIf(p.matches(d))
If
you run the test script you will see we now have one passing test and
one failing test. We could change the matches
method to return False
in order to make this new test case pass, but that would break the
old one! We now have no choice but to implement DatePattern
correctly so that both tests can pass. Here's what we can do:
class DatePattern:
def __init__(self, year, month, day):
self.date = datetime.date(year, month, day)
def matches(self, date):
return self.date == date
If
you run the test script both tests should now pass. Woo-hoo! I'm not
happy with the DatePattern
class, though. So far, it's nothing more than a simple wrapper around
Python's date
class. Why are we not just using date
instances for the "patterns"?
It
might turn out that the DatePattern
class is unnecessary, but we're not going to make that decision on
our own. Instead, we're going to write another test--one that we
think
will confirm the necessity of the DatePattern
class:
def testMatchesYearAsWildCard(self):
p = DatePattern(0, 8, 16)
d = datetime.date(2007, 8, 16)
self.failUnless(p.matches(d))
You should add the above test to your test script and try and run the tests. Voila! This test fails!
Why
am I so happy about a failing test? My reasoning is simple: this
proves
that the current implementation of DatePattern
is insufficient. It can't
be just a simple wrapper around date
and therefore can't be just a date.
While
typing this test, I had to make a decision about how to represent
wildcards. What occurred to me first was to use 0.
After all, there's no year 0 (contrary to popular belief), month 0,
or day 0. This may not have been the best choice, but we're going to
roll with it for now.
It's time to make the new test pass (while making sure not to break the old ones). Modify your DatePattern class to look like the following:
class DatePattern:
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
def matches(self, date):
return ((self.year and self.year == date.year or True) and
self.month == date.month and
self.day == date.day)
To be honest, I'm already starting to feel like we'll need to do some refactoring as we add more wildcard functionality to the class, but I want to write a few more tests first.
Let's add a test where the month is a wildcard:
def testMatchesYearAndMonthAsWildCards(self):
p = DatePattern(0, 0, 1)
d = datetime.date(2007, 10, 1)
self.failUnless(p.matches(d))
At this point your test fixture should fail for the new method unless you fix the matches function of your the DatePattern to handle month wildcards. Go ahead and make the modification to the matches member function to support wildcarded months. If you fix it correctly, then all tests should again pass.
This method is getting uglier every time we touch it--I'm now positive that it will be our first refactoring victim. We now have a test for using wildcards for both years and months. Will we need one for days? A pattern containing nothing but wildcards would match every day. When would that be useful?
At
this point I can't think of a reason to support wildcard days, so we
won't bother writing a test for it. Because of that, we also won't
bother implementing any code to support it in the DatePattern
class. Remember, code gets written only when there's a failing test
that needs the new code in order to pass. This prevents us from
writing code that should not exist in our application, which should
help keep it from becoming unnecessarily complex.
Let's move on. We need to support events that occur on a specified day of every week. So we need to first write a test for that case:
def testMatchesWeekday(self):
p = DatePattern(
Uh, what now?
At
this point, I realized that the DatePattern
class might not be what I want to use for this test. Its __init__
method doesn't accept a weekday. Should I use a different class, or
modify the existing one?
We will modify the existing class for now, as that will require the least amount of work. If this turns out to be a bad idea, we can always refactor later.
def testMatchesWeekday(self):p = DatePattern(0, 0, 0, 2) # 2 is Wednesdayd = datetime.date(2007, 9, 26) # 2007/9/26 is on a Wednesdayself.failUnless(p.matches(d))
This
doesn't pass because DatePattern.__init__
doesn't accept five arguments (counting self).
Modify the __init__
constructor of your DatePattern class to look like this:
def __init__(self, year, month, day, weekday=0):self.year = yearself.month = monthself.day = dayself.weekday = weekday
Question: before you continue do you understand why we gave weekday a default value? If weekday was a required constructor paramater, rather than optional because of the default, would we have to change our existing tests any to use the constructor?
We
gave weekday
a default value so that we wouldn't need to update the other test
cases. Everything compiles and runs, but the new test case doesn't
pass.
The
astute reader has probably already realized that I'm now passing in 0
for the day
argument. There's the wildcard I didn't think I would need--now I
need it!
Here's
our new matches
method:
def matches(self, date):return ((self.year and self.year == date.year or True) and(self.month and self.month == date.month or True) and(self.day and self.day == date.day or True) and(self.weekday and self.weekday == date.weekday() or True))
You should modify the matches method of the DatePattern class to this new version. Now all of the components of a pattern allow for wildcards. How very interesting. What happens now if you run the test suite?
With
this new method, testMatchesWeekday
passes but testMatchesFalse
now fails! What gives?
At this point we have written a significant number of tests and made some interesting discoveries in the course of designing our project. Go ahead and commit your changes at this point before moving on to the next portion of the lab.
I
honestly can't tell why testMatchesFalse
fails by looking at the code. This is going to call for some simple
debugging. Unfortunately, we tried to cram all of the logic for the
matches
method into one expression (spanning four lines!), so there's no
place for us to insert any print statements to help us see which part
is failing, and even in a debugger it will be difficult to follow the
logic of the large boolean statement in the matches function. It's
finally time to do that refactoring we've been needing to do.
The
refactoring I want to apply is the Compose Method from Joshua
Kerievsky's excellent book, Refactoring to Patterns. We did not get a
chance to cover patterns in class, but patterns are a useful design
tool for software development. For the Compose Method Pattern, by
extracting smaller methods from the current matches
method, we can not only make matches
clearer but also make it possible to debug whichever part is
currently causing us grief.
This is the result:
def matches(self, date):return (self.yearMatches(date) andself.monthMatches(date) andself.dayMatches(date) andself.weekdayMatches(date))def yearMatches(self, date):if not self.year:return Truereturn self.year == date.yeardef weekdayMatches(self, date):if not self.weekday:return Truereturn self.weekday == date.weekday()
We have (De)composed the original matching task into separate methods for each part. The monthMatches, and dayMatches methods are not shown, but they should be implemented exactly the same as the yearMatches method shown. (The weekdayMatches method uses a method from the datetime library to get the numerical weekday of a date). Modify your matches method and add and create the four matching composition methods for your DatePattern class.
The
matches
method is now much clearer, don't you agree? It might seem like a
ridiculous thing to do, but writing intention-revealing code is much
more important than being clever. I was trying to be too clever
before and it caused a bug--one that I wouldn't have come across if I
had done this from the beginning.
After
applying this refactoring and rerunning the tests, I expected to see
the testMatchesFalse
test still failing, but it's now passing. If you have implemented the
code correctly to this point, all 5 of your tests in the test suite
should now be passing. Somewhere in the original logic I made an
error, and I have no idea where it was--I'll leave finding it as an
exercise for the reader. In the meantime, not only do we have simpler
code now but it also actually works the way we expect it to. Take
that!
Would we have noticed this bug without tests? I have no doubt that we would, but how long would it have been before we realized that this was a problem? With our unit tests, we noticed it immediately, so we knew exactly what to fix.
At this point you should have a working DatePattern class that can match wildcards for years, months, days and days of the week, as well as 5 unit tests of the class. Go ahead and commit your work so far to the repository before moving to the next portion of the lab.
Wildcards essentially work for all of the components we're testing so far. This is good, but I think the next test will cause trouble. It starts out innocently enough:
def testMatchesLastWeekday(self):p = DatePattern(0, 0, 0, 3
Er,
I'm stuck again. In case it's not obvious (and it's not--why didn't
Python's datetime
module define constants for weekdays?), the 3
represents Thursday.
How
do I indicate that I only want to match the last
Thursday in a month? Do I need to add yet another argument to
DatePattern.__init__?
This
is where that sneaking suspicion in the back of our head should
finally be starting to warrant some closer attention. We might be
trying to cram too much functionality into one class. You should be
starting to think that the single DatePattern
class we have so far is responsible for too much. It's only 30 lines
long at this point, but I don't mean its length when I say "too
much." What I mean is, conceptually, it does four different
things; we even started adding a fifth before we stopped ourself.
What are its four different responsibilities? It has to determine if the year matches. That's one. The month is another, as are the day and the weekday, too. Can we break those responsibilities into four different classes? I'm sure we can, but should we?
Not only that, but for each component of the pattern, we have to check to see if it's a wild card and act appropriately. Maybe we need yet another class to encapsulate that logic.
We can't answer these questions by just thinking about them, so we will explore by writing a few tests:
class NewTests(unittest.TestCase):def testYearMatches(self):yp = YearPattern(2007)d = datetime.date(2007, 9, 26)self.failUnless(yp.matches(d))
Note
that at this point we are creating a whole new fixture for a new
design and a new set of tests. You should have a file called
TestFoo.py that you have been using up to this point. Copy this file
to a new file called NewTests.py. Remove all of the DatePattern
class code from this new test fixture file, and modify the test class
as shown above. If this turns out to be a dead end, we can easily
delete this fixture and continue onward with the old. If this ends up
being a good idea, we can just as easily delete the old fixture
TestFoo.py (and the code it exercised, the DatePattern
class).
This new test case, of course, fails. Here's the code required to make it pass:
class YearPattern:def __init__(self, year):passdef matches(self, date):return True
Notice we are starting along a new design path, looking at creating separate classes for matching different portions of a date.. This is pretty boring so far. How about another test?
def testYearDoesNotMatch(self):yp = YearPattern(2006)d = datetime.date(2007, 9, 26)self.failIf(yp.matches(d))
Of course this new test will fail since our initial implementation of matches always returns true (you should be running the test script each time we add a new test). Now to make the new test pass without breaking the old one:
class YearPattern:def __init__(self, year):self.year = yeardef matches(self, date):return self.year == date.year
Perfect. What's the point? Before I can show you that, we need to write a few more tests (go ahead and add these to your NewTests.py test fixture now):
def testMonthMatches(self):mp = MonthPattern(9)d = datetime.date(2007, 9, 26)self.failUnless(mp.matches(d))def testMonthDoesNotMatch(self):mp = MonthPattern(8)d = datetime.date(2007, 9, 26)self.failIf(mp.matches(d))def testDayMatches(self):dp = DayPattern(26)d = datetime.date(2007, 9, 26)self.failUnless(dp.matches(d))def testDayDoesNotMatch(self):dp = DayPattern(25)d = datetime.date(2007, 9, 26)self.failIf(dp.matches(d))def testWeekdayMatches(self):wp = WeekdayPattern(2) # Wednesdayd = datetime.date(2007, 9, 26)self.failUnless(wp.matches(d))def testWeekdayDoesNotMatch(self):wp = WeekdayPattern(1) # Tuesdayd = datetime.date(2007, 9, 26)self.failIf(wp.matches(d))
Here's the code to make these tests pass (you can also put all of these class definitions, for now in your NewTests.py test fixture):
class MonthPattern:def __init__(self, month):self.month = monthdef matches(self, date):return self.month == date.monthclass DayPattern:def __init__(self, day):self.day = daydef matches(self, date):return self.day == date.dayclass WeekdayPattern:def __init__(self, weekday):self.weekday = weekdaydef matches(self, date):return self.weekday == date.weekday()
If you have done things correctly to this point, you should now have a test script with 4 classes and 8 tests, and all 8 tests should pass successfully. Go ahead and add your NewTests.py script to the repository (if you haven't already done this) and commit your changes.
Is it obvious where we're going with this yet? If not, this test should make it clear:
def testCompositeMatches(self):cp = CompositePattern()cp.add(YearPattern(2007))cp.add(MonthPattern(9))cp.add(DayPattern(26))d = datetime.date(2007, 9, 26)self.failUnless(cp.matches(d))
What we've stumbled across here is an instance of the Composite Pattern. (Interestingly, this is the same name as my class--that wasn't intentional, I promise.) A composite is basically an object that contains other objects, where both the composite object and its contained objects all implement the same interface. Using the interface on the composite should invoke the same methods on all of the contained objects without forcing the external client to do so explicitly. Whew, that was a mouthful.
Here,
that interface is the matches
method, which accepts a date
instance and returns a bool.
Python is a dynamically typed language, so we don't need to define
this interface formally (as in a language like Java, which is fine by
me).
How do we implement the composite? Like this:
class CompositePattern:def __init__(self):self.patterns = []def add(self, pattern):self.patterns.append(pattern)def matches(self, date):for pattern in self.patterns:if not pattern.matches(date):return Falsereturn True
The composite pattern asks each of its contained patterns if it matches the specified date. If any fail to match, the whole composite pattern fails.
I have to confess that we cheated here. We wrote more code than we needed to pass the test! Sometimes I get ahead of myself. Sorry. It turned out OK this time because all of the tests are passing, but we need to create a test that should not match, just to be sure we have everything working correctly:
def testCompositeDoesNotMatch(self):cp = CompositePattern()cp.add(YearPattern(2007))cp.add(MonthPattern(9))cp.add(DayPattern(28))d = datetime.date(2007, 9, 29)self.failIf(cp.matches(d))
Cool. It passes. At least, if you have implemented this correctly, the 10 tests including the tests of the composite date pattern should now pass.
It
might be a little difficult to see this, but the composite contains a
DayPattern
that matches the 28th and I'm matching it against the 29th, which is
why I expect the matches
method to return False.
So we can match dates again. Big deal--we were already doing that. What about wild cards?
We'll write a test to match my anniversary with the new classes:
def testCompositeWithoutYearMatches(self):cp = CompositePattern()cp.add(MonthPattern(8))cp.add(DayPattern(16))d = datetime.date(2007, 8, 16)self.failUnless(cp.matches(d))
There's
no YearPattern
in the composite requiring the passed-in date to match any specific
year. Wild cards now work by not
specifying any pattern for a given component. Remember when I thought
we might need a class to do the wild card matching? I was wrong!
At this point you should have a NewTests.py python test fixture. Your test fixture contains many small pattern matching classes, and 11 successfully passing unit tests. If your test fixture is working as described, go ahead and commit your changes to the repository at this point.
At this point, we should feel really good about the new approach and we will just delete the old tests and code. This is not unusual in a test driven approach to design. Go ahead and remove the TestFoo.py file from your repository and commit your changes.
We'll also refactor the tests a bit, and in the process become more familiar with the concept of a test fixture. Did you notice that every one of the new tests contained a duplicate line? I did. It started to bother me, but that's what test fixtures are for:
class PatternTests(unittest.TestCase):def setUp(self):self.d = datetime.date(2007, 9, 26)def testYearMatches(self):yp = YearPattern(2007)self.failUnless(yp.matches(self.d))def testYearDoesNotMatch(self):yp = YearPattern(2006)self.failIf(yp.matches(self.d))
I've
only shown the first two test cases (in the fixture previously known
as NewTests)
but now all of the test cases refer to the date as self.d
instead of constructing a local date
instance. It's not a huge refactoring, but it makes me feel better.
You do
want us to feel the best we can about our code, don't you? Of course
you do.
I
did have to change testCompositeWithoutYearMatches
to use this date instead of my anniversary. As cute as it was to
throw that date in there, I decided I'd rather have clean code
without duplication than cuteness.
In case you missed that, at this point you need to modify all of your tests to use the self.d fixture. We have also changed the name of the unit test to PatternTests. We are going to change the name of the file as well, to better reflect its contents. You should do an svn rename of your NewTests.py to now be called DatePatterns.py.
We will also take this opportunity to clean up some more of the code by adding some named constants for weekdays:
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY = range(0, 7)(This will go at the top of your script, after any imports). Now we can use these instead of the hard-coded constants we had in the weekday tests, and also delete the comments we had explaining what the constants represented. Intention-revealing code beats comments any day of the week. Do these changes now as. Do all of your modified tests using the new setUp fixture still work?
Where are we now? Before switching gears, we planned to write a test for a pattern that matched the last Thursday of every month (remember). It's time to do that now:
def testLastThursdayMatches(self):cp = CompositePattern()cp.add(LastWeekdayPattern(THURSDAY))self.failUnless(cp.matches(self.d))
Cool. A new class to implement! The implementation for this class is slightly more complicated than the others:
class LastWeekdayPattern:def __init__(self, weekday):self.weekday = weekdaydef matches(self, date):nextWeek = date + datetime.timedelta(7)return self.weekday == date.weekday() and nextWeek.month != date.month
The
date we're trying to match in the test is a Wednesday, not a
Thursday! We need to fix the test (not forgetting to rename it) as
well as add a test where we expect the match to fail (which we should
have done before implementing matches):
def testLastWednesdayMatches(self):cp = CompositePattern()cp.add(LastWeekdayPattern(WEDNESDAY))self.failUnless(cp.matches(self.d))def testLastWednesdayDoesNotMatch(self):cp = CompositePattern()cp.add(LastWeekdayPattern(WEDNESDAY))self.failIf(cp.matches(self.d))
Rats.
The first test passes but the second one fails. The date created in
setUp
is the same for every test case so it will always be a Wednesday, but
we need a date that's not on a Wednesday to make this test pass.
Rather than creating a new date in this test case (and ignoring the
one created in setUp),
We'll move both of these tests into a new fixture--one specific for
testing the LastWeekdayPattern
class:
class LastWeekdayPatternTests(unittest.TestCase):def setUp(self):self.pattern = LastWeekdayPattern(WEDNESDAY)def testLastWednesdayMatches(self):lastWedOfSep2007 = datetime.date(2007, 9, 26)self.failUnless(self.pattern.matches(lastWedOfSep2007))def testLastWednesdayDoesNotMatch(self):firstWedOfSep2007 = datetime.date(2007, 9, 5)self.failIf(self.pattern.matches(firstWedOfSep2007))
Note: this test fixture will still be in the same file, just place it after the original PatternTests test fixture. Also don't forget to remove the last Wednesday tests from the original fixture to move into this new fixture. Now they both pass and the tests use everything they create. Nice. If you have correctly followed the exercise to this point, you should see that 13 tests are being run and passed when you run your test suite.
While
moving these tests into a new fixture, we also noticed that we
created a CompositePattern
that contained only one pattern. That's kind of pointless, so we
stopped doing it.
Should we move the test cases that exercise the various pattern classes into their own fixtures? That's a tendency that many, including me, often have. It's sometimes useful to resist that urge, though. As Dave Astels, author of Test-Driven Development: A Practical Guide, puts it: a fixture is "a way to group tests that need to be set up in exactly the same way." In other words, a fixture is not a container for all of the tests for a single class, or at least, it doesn't have to be.
Having said that, I prefer it when all of the test cases in a fixture exercise the same class. In harmony with Dave's definition of fixtures, I just don't require that all of the test cases that exercise the same class be in the same fixture. Make sense?
Suppose
that I have four test cases for the class Foo
but half require different setUp
code than the other half. I'd split those cases up into two fixtures,
even though they both exercise the same class. If I then started
writing tests for the class Bar
and discovered that some of its tests could use the same setUp
code as one of the class Foo
fixtures, I would not
just shove those tests into one of the existing fixtures.
Wouldn't
that mean I've duplicated the setUp
code for the two fixtures? Duplication is evil! If I thought the
duplication was enough to be a problem, I would Extract
Superclass
the duplicated code out of the two fixtures. Yes, you can--and
should--refactor your tests, too.
When
starting on a new project, I create one fixture with no setUp
method and add all of my test cases to that one fixture. Eventually,
I reach the point where I need to refactor the fixture, and I do it.
Remember: do the simplest thing that could possibly work first. Then
refactor if necessary.
What, though, is the benefit of ensuring that all of the test cases in a fixture only exercise one class? Well, besides making it more cohesive (and obeying the Single Responsibility Principle), think about what might happen when you decide a class is no longer necessary. You'll need to delete the tests for that class, too. It's a lot easier to delete a whole test fixture than to look at each test case in a fixture to see if it exercises the class you just deleted.
Think
you won't delete classes? Think again. You saw we deleted the
DatePattern
class and all of its tests earlier, didn't you? It wasn't hard. We
felt good about it, too.
At this point you should have a test script that contains several date pattern matching classes, and two different testing fixtures. There should be a total of 13 tests among the two fixtures, and when you run your test script all 13 tests should be correctly passing. If you are satisfied that you have things working at this point, you should go ahead an commit your changes to the repository before moving to the next section.
Because it's so easy and fun, we want to add another pattern so we can detect matches to for example the first Wednesday of every month. As usual we will first write our tests for the new pattern:
class NthWeekdayPatternTests(unittest.TestCase):def setUp(self):self.pattern = NthWeekdayPattern(1, WEDNESDAY)def testMatches(self):firstWedOfSep2007 = datetime.date(2007, 9, 5)self.failUnless(self.pattern.matches(firstWedOfSep2007))def testNotMatches(self):secondWedOfSep2007 = datetime.date(2007, 9, 12)self.failIf(self.pattern.matches(secondWedOfSep2007))
I don't have an example of this in my use cases as listed at the beginning of this exercise, but it's a feature that both calendar and pal support, so I expected to add it at some point.
Making these tests pass shouldn't be too hard:
class NthWeekdayPattern:def __init__(self, n, weekday):self.n = nself.weekday = weekdaydef matches(self, date):if self.weekday != date.weekday():return Falsen = 1while True:previousDate = date - datetime.timedelta(7 * n)if previousDate.month == date.month:n += 1else:breakreturn self.n == n
OK, it was harder than I thought. I'm not a huge fan of the way that algorithm looks, but it's OK for now. We really should at least extract it into its own method so we can give it an intention-revealing name (go ahead and make these changes):
def matches(self, date):if self.weekday != date.weekday():return Falsereturn self.n == self.getWeekdayNumber(date)def getWeekdayNumber(self, date):n = 1while True:previousDate = date - datetime.timedelta(7 * n)if previousDate.month == date.month:n += 1else:breakreturn n
The
last example I do have at the beginning of this exercise is the "last
day of the month" case. That should match days "in
reverse." We could modify the existing DayPattern
class, but instead we want to add a new pattern class:
class LastDayInMonthPatternTests(unittest.TestCase):def testMatches(self):lastDayInSep2007 = datetime.date(2007, 9, 30)pattern = LastDayInMonthPattern()self.failUnless(pattern.matches(lastDayInSep2007))
While
typing in that test, I decided that I couldn't think of a reason to
support the second-to-last day in a month, or the third-to-last day,
and so on. Can you? I made it easy on ourselves and decided to
implement a class called LastDayInMonthPattern.
People usually argue that writing tests up front takes too much work,
but writing a test first this time actually saved us from writing
code I would never use!
The implementation of this new pattern is:
class LastDayInMonthPattern:def matches(self, date):tomorrow = date + datetime.timedelta(1)return tomorrow.month != date.month
Nice.
Although I just realized we're cheating again. Here's how the
fixture should have looked (after extracting out the setUp
method) before fully implementing the matches
method:
class LastDayInMonthPatternTests(unittest.TestCase):def setUp(self):self.pattern = LastDayInMonthPattern()def testMatches(self):lastDayInSep2007 = datetime.date(2007, 9, 30)self.failUnless(self.pattern.matches(lastDayInSep2007))def testNotMatches(self):secondToLastDayInSep2007 = datetime.date(2007, 9, 29)self.failIf(self.pattern.matches(secondToLastDayInSep2007))
At this point you should have a test script called DatePatterns.py. You test script contains 8 pattern classes and 4 test fixtures that contain a total of 17 tests, that should all be passing. Go ahead and commit your work to the repository.
I feel pretty good about our code right now. That usually means it's time to refactor. Now I really want to do some renaming.
For
the last pattern we implemented, we were very explicit about what it
did: LastDayInMonthPattern
only matches the last day in a month and there's no further
clarification needed. What about NthWeekdayPattern
and LastWeekdayPattern?
I really want to add InMonth
to the end of both of those class names. Yes, I'm that picky.
I also took this time to rename a few of the test cases and reorder some of the class definitions. I won't bore you with the details, but you can see the final results for yourself in the example solution for the lab (though for extra practice and if you have been following this lab with interest, you could try and do these changes on your own before looking at the solution).
This type of tidying up may seem trivial but it's extremely important. If your code doesn't look clean, you (and others who find themselves working on your code) won't have any incentive to keep it clean. The Pragmatic Programmers call this the Broken Window Theory. If you live with broken windows, don't be surprised when your neighbors start using your lawn as a junk yard.
We have about 60 non-blank lines of code so far, spread across eight classes. That's not too much, but the code is very simple and yet highly flexible. I seriously doubt I would have been able to conceive of this design without writing our tests first.
What's even cooler is that we have about 90 non-blank lines of test code. Yes, we have more test code than we have "real" code. Is that wrong? Absolutely not. That's wonderful! We should feel extremely confident about the quality of the code that we have so far. Is it perfect? I doubt it. When we discover a bug, though, we can add a new test to demonstrate it and fix it so that it never happens again. If we need to perform an optimization, we'll have a suite of tests we can use to verify that we didn't screw anything up while applying the optimization.
What's also interesting to note is the design that emerged from this work. We spent zero time in front of a modeling tool trying to create a design that would both meet our needs today and still be elegant enough to (hopefully) meet all of tomorrow's needs, as well. We didn't intend for this to happen. It just magically happened that way. This isn't rare--this almost always happens when one does test-driven development.
How is this design more flexible than we originally intended? Suppose that we want to create a pattern that matches every Friday the 13th. That wasn't one of our original use cases, and I gave no thought to it while writing the tests. The classes we came up have no trouble representing that pattern:
[harry@nisl lab05]$ pythonPython 2.4 (r25:51908, Nov 6 2007, 16:54:01)[GCC 4.1.2 20070925 (Red Hat 4.1.2-27)] on linux2Type "help", "copyright", "credits" or "license" for more information.>>> import DatePatterns>>> fri13 = DatePatterns.CompositePattern()>>> fri13.add(DatePatterns.WeekdayPattern(DatePatterns.FRIDAY))>>> fri13.add(DatePatterns.DayPattern(13))>>> import datetime>>> jul13 = datetime.date(2007,7,13)>>> jul13.strftime('%A')'Friday'>>> fri13.matches(jul13)True>>> sep13 = datetime.date(2007,9,13)>>> sep13.strftime('%A')'Thursday'>>> fri13.matches(sep13)False>>>
While we're not done with the application yet, we do have a solid foundation to build on. Next, we would need to add some parsing code (probably a filter, as we have done in labs 3 and 4) so that we can read a file containing events in order to construct and use the patterns we implemented above. I'll leave that as an exercise for the student.