Time traveling in Swift unit tests

A 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 {
    private var registrations = [String : Registration]()

    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 {
    private let dateGenerator: () -> Date
    private var registrations = [String : Registration]()

    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:

  1. Add an object to the cache.

  2. Verify that the object is indeed cached.

  3. Time travel 24 hours into the future.

  4. 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! 🚀

Picking the right way of failing in Swift

Building a command line tool using the Swift Package Manager