Time traveling in Swift unit tests
Discover page available: Unit TestingA lot of code that we write relies on the current date in some way. Whether it’s cache invalidation, handling time sensitive data, or keeping track of durations, we usually simply perform comparisons against Date()
— for example using Date().timeIntervalSince(otherDate)
.
However, writing tests against code that uses such date comparisons can sometimes be a bit tricky. If the intervals are small enough, you could simply add waiting time to your tests (although that’s really not recommended, since it’ll slow them down, and is a common source of flakiness) — but if we’re talking about hours or days in between our dates, that’s simply not possible.
This week, let’s take a look at how to test code that relies on dates in a simple and fun way — using “time traveling” 😀
Consider the following Cache
class, that simply provides APIs for caching and retrieving cached objects:
class Cache<T> {
private var registrations = [String : Registration<T>]()
func cache(_ object: T, forKey key: String) {
let endDate = Date().addingTimeInterval(60 * 60 * 24)
registrations[key] = Registration(object: object, endDate: endDate)
}
func object(forKey key: String) -> T? {
guard let registration = registrations[key] else {
return nil
}
guard registration.endDate >= Date() else {
registrations[key] = nil
return nil
}
return registration.object
}
}
As you can see above, we calculate an endDate
for each cache entry, based on the current date, offset by 24 hours (in seconds). So how do we test this class in a predictable and efficient way?
Let’s start by applying a technique from “Simple Swift dependency injection with functions” and make it possible to inject a function (we’ll call it dateGenerator
) that generates the current date, instead of calling Date()
directly. This will enable us to easily mock the current date in our tests.
The implementation of Cache
now looks like this:
class Cache<T> {
private let dateGenerator: () -> Date
private var registrations = [String : Registration<T>]()
init(dateGenerator: @escaping () -> Date = Date.init) {
self.dateGenerator = dateGenerator
}
func cache(_ object: T, forKey key: String) {
let currentDate = dateGenerator()
let endDate = currentDate.addingTimeInterval(60 * 60 * 24)
registrations[key] = Registration(object: object, endDate: endDate)
}
func object(forKey key: String) -> T? {
guard let registration = registrations[key] else {
return nil
}
let currentDate = dateGenerator()
guard registration.endDate >= currentDate else {
registrations[key] = nil
return nil
}
return registration.object
}
}
One thing to note above is that we keep Date.init
as the default date generator, to enable Cache
to be initialized without any arguments in our production code — just like before 👍
Now, let’s do some time traveling! In order to verify that our Cache
correctly discards outdated entries, we’re going to need to:
- Add an object to the cache.
- Verify that the object is indeed cached.
- Time travel 24 hours into the future.
- Verify that the object is no longer cached.
To enable the time traveling part, let’s implement a TimeTraveler
class (that will only be part of our testing target), which will enable us to move in time using a given time interval:
class TimeTraveler {
private var date = Date()
func travel(by timeInterval: TimeInterval) {
date = date.addingTimeInterval(timeInterval)
}
func generateDate() -> Date {
return date
}
}
Finally, let’s write a test for Cache
that uses the generateDate()
method of a TimeTraveler
instance as its date generator:
// Setup a simple object class that we can insert into the cache
class Object: Equatable {
static func ==(lhs: Object, rhs: Object) -> Bool {
// For equality, check that two objects are the same instance
return lhs === rhs
}
}
class CacheTests: XCTestCase {
func testInvalidation() {
// Setup time traveler, cache and object instances
let timeTraveler = TimeTraveler()
let cache = Cache<Object>(dateGenerator: timeTraveler.generateDate)
let object = Object()
// Verify that the object is indeed cached when inserted
cache.cache(object, forKey: "key")
XCTAssertEqual(cache.object(forKey: "key"), object)
// Time travel 24 hours (+ 1 second) into the future
timeTraveler.travel(by: 60 * 60 * 24 + 1)
// Verify that the object is now discarded
XCTAssertNil(cache.object(forKey: "key"))
}
}
That’s it! 🎉 We now have a fast & predictable date-dependent test, without having to invent a lot of infrastructure or resort to hacky solutions like swizzling the system date.
Do you have questions, comments or suggestions for upcoming posts? I’d love to hear from you! 😊 Contact me on Twitter @johnsundell.
Thanks for reading! 🚀