Introducing time-machine, a New Python Library for Mocking the Current Time
Whilst writing Speed Up Your Django Tests, I wanted to add a section about mocking the current time. I knew of two libraries for such mocking, but I found it hard to pick one to recommend due to the trade-offs in each. So I delayed adding that section and shaved a rather large yak by writing a third library.
This post is my introduction to the problem, the trade-offs with the other libraries, and how my new library, time-machine, tries to solve them. The next version of Speed Up Your Django Tests can now include a section on mocking time.
It’s especially common in web projects to have features that rely on changes to the current date and time. For example, you might have a feature where each customer may only make an order once per day.
For testing such features, it’s often necessary to mock the functions that return the current date and time. In Python’s standard library, these live in the
time modules. (Wrappers, such as arrow and Delorean, still use these under the hood.)
There are various ways of doing this mocking: it can be done generically with
unittest.mock, or in a more targeted fashion with a time-mocking library.
unittest.mock works, but it’s often inaccurate as each patcher can only mock a single function reference. This inaccuracy is exacerbated when mocking the current time, as there are many different functions that return the current time, in different formats. Due to the way Python’s imports work, it takes a lot of mocks to replace every instance of functions from
time in a code path, and is sometimes impossible. (See: Why Your Mock Doesn’t Work.)
I know of two existing Python libraries that have tried to provide a better way to mock the current time: freezegun and libfaketime. Let’s look at them now, before I introduce time-machine.
freezegun is a very popular library for mocking the current time. It has a great, clear API, and “does what it says on the tin.”
For example, you can write a time-mocking test like so:
import datetime as dt import freezegun @freezegun.freeze_time("1955-11-05 01:22") def test_delorean(): assert dt.date.today().isoformat() == "1955-11-05"
The main drawback is its slow implementation. It essentially does a find-and-replace mock of all the places that the relevant functions from the
time modules have been imported. This gets around the problems with using
unittest.mock, but it means the time it takes to do the mocking is proportional to the number of loaded modules. In large projects, this can take a second or two, an impractical overhead for each individual test.
It’s also not a perfect search, since it searches only module-level imports. Such imports are definitely the most common way projects use date and time functions, but they’re not the only way. freezegun won’t find functions that have been “hidden” inside arbitrary objects, such as class-level attributes.
It also can’t affect C extensions that call the standard library functions, including (I believe) Cython-ized Python code.
python-libfaketime is a much less popular library, but it is much faster. It wraps the
LD_PRELOAD library libfaketime, which replaces all the C-level system calls for the current time with its own wrappers. It’s therefore a “perfect” mock, affecting every single point the current time might be fetched from the current process.
It also has much the same API:
import datetime as dt import libfaketime @libfaketime.fake_time("1955-11-05 01:22") def test_delorean(): assert dt.date.today().isoformat() == "1955-11-05"
The approach is much faster since starting the mock only requires changing an environment variable that libfaketime reads. The python-libfaketime README has a benchmark showing it working 300 times faster than freezegun. This benchmark is even favourable to freezegun, since the environment has no extra dependencies, and freezegun’s runtime is proportional to the number of imported modules.
I learnt about python-libfaketime at YPlan, where we were first using freezegun. Moving to python-libfaketime took our Django project’s test suite (of several thousand tests) from 5 minutes to 3 minutes.
Unfortunately python-libfaketime comes with the limitations of
LD_PRELOAD. This is a mechanism to replace system libraries for a program as it loads (explanation). This causes two issues in particular when you use python-libfaketime.
LD_PRELOAD is only available on Unix platforms, which prevents you from using it on Windows. This can be a blocker for many teams.
Second, you have to help manage
LD_PRELOAD. You either use python-libfaketime’s
reexec_if_needed() function, which restarts (re-execs) your test process while loading, or manually manage the
LD_PRELOAD environment variable. Neither is ideal. Re-execing breaks anything that might wrap your test process, such as profilers, debuggers, and IDE test runners. Manually managing the environment variable is a bit of overhead, and must be done for each environment you run your tests in, including each developer’s machine.
My new library, time-machine, is intended to combine the advantages of freezegun and libfaketime. It works without
LD_PRELOAD but still mocks the standard library functions everywhere they may be referenced. It does so by modifying the built-in functions at the C level, to point them through wrappers that return different values when mocking. Normally in Python, built-in functions are immutable, but time-machine overcomes this by using C code to replace their function pointers.
Again, it has much the same API as freezegun, except from the names:
import datetime as dt import time_machine @time_machine.travel("1955-11-05 01:22") def test_delorean(): assert dt.date.today().isoformat() == "1955-11-05"
Its weak point is that libraries making their own system calls won’t be mocked. (Cython use of the
time modules should be mocked, although I haven’t tested it yet). However I believe such usage is rare in Python programs - freezegun also shares this weakness, but that hasn’t stopped it becoming popular.
If you have time, please try out time-machine in your tests! It’s available now for Python 3.6+. Because of its implementation, it only works with CPython, not PyPy or any other interpreters.
It’s my first open source project using a C extension. Let me know how it works, and if you’re switching from freezegun, how much it speeds up your tests. If it is found to work well, it may be possible to merge its technique into freezegun, to share the speed boost without causing churn.
Improve your Django develompent experience with my new book.
One summary email a week, no spam, I pinky promise.
- time-machine versus freezegun, a benchmark
- The Fast Way to Test Django transaction.on_commit() Callbacks
- Django’s Test Case Classes and a Three Times Speed-Up
Tags: django, python