DEV Community

Ivan Misuno
Ivan Misuno

Posted on • Edited on

Deterministic unit tests for current date-dependent code in Swift.

It's been a while since I published my previous article on Envelope framework - a thin wrapper around Alamofire that makes writing unit tests for the networking code a piece of cake. Probably it was too big of an article to start with, but anyway I'd like to hear more feedback on in. Today I'm going to share a very simple tip I've been using since a few years ago that simplifies another aspect of writing unit-tests: testing the code that uses current date/time.

The problem

So imagine you're writing a method that requests an update for a record if it has expired:

final class Controller {
    private let entityManager: EntityManaging

    func requestUpdateIfNeeded(_ record: Record) {
        let dateOfExpiration = Date().addingTimeInterval(
            -configuration.expirationInterval)
        if record.lastUpdateTime <= dateOfExpiration {
            entityManager.requestUpdate(record.id)
        }
    }
}

Here, entityManager is an object responsible for requesting and storing an update to the record by id.

How could a unit test for such a function look like?

import Quick
import Nimble
@testable import MainAppModule

class ControllerSpec: QuickSpec {
    override func spec() {
        var sut: Controller!
        var entityManager: EntityManagingMock!
        beforeEach {
            // Construct instances of `sut` and `entityManager`
        }
        describe("Controller.requestUpdateIfNeeded()") {
            context("when expired") {
                let expiredRecord = RecordFixture.expiredRecord()
                beforeEach {
                    sut.requestUpdateIfNeeded(expiredRecord)
                }
                it("requests entityManager to update the record by its id") {
                    expect(entityManager.requestUpdateCallCount) == 1
                }
            }
            context("when not expired") {
                let nonExpiredRecord = RecordFixture.nonExpiredRecord()
                beforeEach {
                    sut.requestUpdateIfNeeded(nonExpiredRecord)
                }
                it("does not request update") {
                    expect(entityManager.requestUpdateCallCount) == 0
                }
            }
        }
    }
}

See the problem? We need a nonExpiredRecord, i.e., an instance of Record for which the condition record.lastUpdateTime <= dateOfExpiration would be false. So the RecordFixture.nonExpiredRecord() should generate a Record with lastUpdateTime updated to the current time! Imagine how this would look like when Record is a struct - you'll have to copy all fields of the struct, updating one with the current date. Even worse, when such a fixture comes from e.g., a saved network response, the schema of which could change over time, supporting such test code becomes a constant pain, and a source of failures on the CI. Even if the fixture is constructed properly, stepping over the function in the debugger could result in evaluating the condition to the wrong result if the actual time has already passed.

The solution

Freeze the time.

/Captain obvious mode on/ Unit-tests should be deterministic.
Even the ones that deal with the current time. /Captain obvious mode off/

Imagine that calling Date() when running under unit-test suite would always return, say, 1 January 2016 12:00GMT? Then creating a test fixture for a record that's always "unexpired" would be trivial, isn't it?

So how we could override Date() under unit-tests so that it would return a predefined date? For the good or for the worse, that's not possible directly - swizzling a method implementation is now a thing of the past.

What we can do, is to:

  1. Provide an alternative to Date() that would under normal program execution return current date, with the ability for the unit-test suite to override its behavior;
  2. In the test suite, override it to always return a pre-defined date;
  3. Prohibit usage of Date() initializer in the source code with a lint rule, or a git pre-commit hook, or both.

Let's do this step by step.

1. Providing an alternative to getting the current date.

//  Date+current.swift

internal var __date_currentImpl: () -> Date {
    return Date()
}

extension Date {
    /// Return current date.
    /// Please replace `Date()` and `Date(timeIntervalSinceNow:)` with `Date.current`,
    /// the former will be prohibited by lint rules/commit hook.
    static var current: Date {
        return __date_currentImpl()
    }
}

2. Overriding the current date behavior under the test suite.

//  Date+mockCurrentDate.swift

@testable import MainAppModule
import Quick

// `configure()` function gets executed when test suite is loaded (same rules as +[NSObject load] apply);
/// This replaces the `Date.current` implementation so that when running under the test suite it always returns `Date.mockDate`,
/// allowing to write unit-tests than test the code dependent on the current date.
class MockCurrentDateConfiguration: QuickConfiguration {
    override class func configure(_ configuration: Configuration) {
        // This gets executes before initialization of `let` constants in unit tests.
        Date.overrideCurrentDate(Date.mockDate)

        // This gets executed before any other `beforeEach` blocks.
        configuration.beforeEach {
            Date.overrideCurrentDate(Date.mockDate)
        }

        // Restore the possibly overridden (in a Quick test) mock date handler.
        configuration.afterEach {
            Date.overrideCurrentDate(Date.mockDate)
        }
    }
}

extension Date {
    static var mockDate: Date = Date(timeIntervalSinceReferenceDate: 536500800) // 1 January 2018, 12:00GMT

    static func overrideCurrentDate(_ currentDate: @autoclosure @escaping () -> Date) {
        __date_current = currentDate
    }
}

If Quick is not being used, then the same trick could be done by overriding one of the unit-test suite classes' class func load() functions.

So now, the code in the main app module that calls Date.current, when running under test suite, would always receive the value of Date.mockDate, so constructing test fixtures becomes as easy as:

    let expiredRecord = Record(
        lastUpdateTime: Date.mockDate.addingTimeInterval(
            -configuration.expirationInterval)
    let nonExpiredRecord = Record(
        lastUpdateTime: Date.mockDate)

Alternatively, the test case could override the value of the current date for the code:

    // ...
    beforeEach {
        // Override the current date
        Date.overrideCurrentDate(Date.mockDate.add)
    }

3. Prohibiting the usage of Date() in the code.

Let's add a section to the git pre-commit hook script, with a simple regexp to find all occurrences of Date() pattern being added to the stage area:

#!/bin/sh
# pre-commit
# copy to .git/hooks folder ()

date=$(git diff --cached -- *.swift ":(exclude)*/Date+current.swift" | grep -E '\bDate\(\)\b' | wc -l)
if [ "$date" -gt 0 ] ; then
    echo "  Error: Usage of Date() initializer is prohibited."
    echo "         Please use Date.current value, and make sure unit-tests are not dependent on the actual current date."
    exit 1
fi

Hope this makes sense. Happy hacking!

Also, would really appreciate any feedback.

Thanks!

Top comments (4)

Collapse
 
omarjalil profile image
Jalil

I'm having this exact problem but with Calendar.current.isDateInToday(date) or isDateInThisWeek`, how could we achieve the same thing in these use cases?

Collapse
 
maroony profile image
maroony

Thanks for sharing your code! I think this could be very useful. The only problem I have: If the are two test classes using Date.overrideCurrentDate(_:) with different mock dates running at the same time (in a ci environment), things will be broken?

For your git hook: Instead of 'wc -l' I would use 'grep -cE' directly.

Collapse
 
mariusdereus profile image
m.dereus

Nice solution!
As an addition: in the application code the use of Date(timeIntervalSinceNow:) should be avoided as well, because that returns also a date based on the system time.

Collapse
 
ivanmisuno profile image
Ivan Misuno

Yes, indeed! Going to update the pre-commit hook accordingly.