Sponsored Link •
Testing APIs that use the current date and time are a pain because those values are variable. In Mailman 3 I hit upon a really dumb, simple way to do this that doesn't suck.
So here's the problem: Let's say you have a database class that is unit tested. I like doctests, but maybe you like Python unittest. Your class has a column (exposed in your ORM class) for the date that the row was last touched, which is initialized from today's date. How do you test this?
Your test could just ensure that some date was stuffed in the attribute/column, but you can't really tell if it's the date you care about because that's going to be different every day you run the test. So this solution isn't very good.
You could change your API to allow you to pass a known date into it, and your test could call this extended API, but that's not great for several reasons. You'd rather not clutter your API up with extra parameters only used in testing, and it means your ORM class now has different logic for testing environments and production environments. So I don't like this much either.
I'm sure you've thought of your own approaches, including hacking Python's standard datetime module to stuff in instances that allow you to override datetime.datetime.now() and datetime.date.today(). I thought of that too! Because these are built-in types, you can't just replace those methods, but Python does make it fairly easy to subclass built-in types. So that would seem like a good approach except...
I use the Storm ORM in Mailman 3 and it has very strict type checking on its column input values. Generally this is a good thing, but in this case it prevents the subclassing approach because the DateTime column type won't accept subclasses of the built-in datetime type.
The approach I settled on seems the least sucky to me. It's also stupid simple, so I kind of like it for that reason too! I wrote a simple wrapper class that only returns now() and today() and I make sure all the testable call sites call these methods instead of the datetime module's versions. The now() and today() in my utility module then are really instance methods of a class which can be told whether it's in testing mode or not. When it's not in testing mode, it simply returns datetime.datetime.now() and datetime.date.today() as usual. When it is in testing mode, it returns instead a known date and time, which of course, you can test!
The class itself has two additional class methods. One resets the current date and time (in testing mode) to the original known values. The other allows you to fast forward the known date and time. Here's the (stripped down) code. Note that because of the conditional expressions, Python 2.5 is required:
from __future__ import absolute_import, unicode_literals __metaclass__ = type import datetime class DateFactory: """A factory for today() and now() that works with testing.""" # Set to True to produce predictable dates and times. testing_mode = False # The predictable time. predictable_now = None predictable_today = None def now(self, tz=None): return (self.predictable_now if self.testing_mode else datetime.datetime.now(tz)) def today(self): return (self.predictable_today if self.testing_mode else datetime.date.today()) @classmethod def reset(cls): cls.predictable_now = datetime.datetime(2005, 8, 1, 7, 49, 23) cls.predictable_today = cls.predictable_now.date() @classmethod def fast_forward(cls, days=1): cls.predictable_now += datetime.timedelta(days=days) cls.predictable_today = cls.predictable_now.date() factory = DateFactory() factory.reset() today = factory.today now = factory.now
Now, at the call sites, say in your database class, instead of something like:
import datetime self.date_created = datetime.date.today()
from ... import today self.date_created = today()
And your test would do something like this:
>>> from ... import factory >>> factory.testing_mode = True >>> thing1 = Thing() >>> thing1.date_created datetime.datetime(2005, 8, 1, 7, 49, 23) >>> factory.fast_forward(days=3) >>> thing2 = Thing() >>> thing2.date_created datetime.datetime(2005, 8, 4, 7, 49, 23)
I know there are mocking libraries out there that might help you with this, but I also think this is a nice simple approach where you don't want a lot of extra mocking scaffolding. The cost is that you have to be disciplined not to use the standard datetime module at your call sites.
|Barry Warsaw has been developing software and playing music for more than 25 years. Since 1995 he has worked in Guido van Rossum's Pythonlabs team. He has been the lead developer of the JPython system, and is now the lead developer of GNU Mailman, a mailing list management system written primarily in Python. He's also a semi-professional musician. Python and the bass are his main axes. Music and software are both at their best when enjoyed, participated in, and shared by their enthusiastic fans and creators.|