Deterministic unit tests for current date-dependent code in Swift.
Ivan Misuno
Posted on October 13, 2018
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:
- 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; - In the test suite, override it to always return a pre-defined date;
- 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!
Posted on October 13, 2018
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.