Eliminating Flaky iOS Tests: Mastering Deterministic Date Handling
Flaky tests — those that randomly pass or fail despite no code changes — are a notorious drain on developer productivity. One common culprit in iOS development is the misuse of Date()
, which introduces non-determinism into tests. In this article, we dissect a real-world example of a flaky test caused by Date()
comparisons and demonstrate how to eliminate randomness through controlled time management.
The Problem: Non-Deterministic Date Comparisons
Scenario
Consider a test validating that a screen initializes without dispatching unintended actions:
func test_init_doesNotDispatchAction() {
let state = State(
message: "any-message",
lastReadMessageDate: Date() // ❌ Non-deterministic!
)
let sut = makeSUT()
XCTAssertNotEqual(sut.state.lastReadMessageDate, state.lastReadMessageDate)
}
Result: The test randomly fails with: XCTAssertNotEqual failed: ("2024-05-03 14:28:43 +0000") is equal to ("2024-05-03 14:28:43 +0000")
Why Does This Happen?
Date()
captures the exact system time when called.- When called in rapid succession (e.g., during test setup), multiple
Date
instances may share the same value due to the system clock's limited precision (e.g., millisecond resolution). - Tests become environment-dependent: They may pass during development but fail on CI/CD where execution speed differs.
The Solution: Controlling Time with Dependency Injection
To eliminate flakiness, decouple your code from the system clock. Here’s how:
Step 1: Introduce a Date Provider Protocol
Replace direct Date()
calls with an injectable dependency:
protocol DateProvider {
func currentDate() -> Date
}
// Production: Uses real system time
struct SystemDateProvider: DateProvider {
func currentDate() -> Date { Date() }
}
Step 2: Create a Controlled Date Provider for Tests
Simulate time progression in tests:
struct ControlledDateProvider: DateProvider {
private var currentDate: Date
init(initialDate: Date = Date()) {
self.currentDate = initialDate
}
mutating func advance(by interval: TimeInterval = 0) {
currentDate = currentDate.addingTimeInterval(interval)
}
func currentDate() -> Date { currentDate }
}
Step 3: Inject the Date Provider into Your Codebase
Modify your system under test (SUT) to depend on DateProvider
:
class ChatViewModel {
private let dateProvider: DateProvider
init(dateProvider: DateProvider = SystemDateProvider()) {
self.dateProvider = dateProvider
// Initialize state using dateProvider.currentDate()
}
}
Refactoring the Test
Before: Flaky Assertions
// ❌ Relies on uncontrollable Date()
let state = State(lastReadMessageDate: Date())
let sut = makeSUT()
XCTAssertNotEqual(sut.state.lastReadMessageDate, state.lastReadMessageDate)
After: Deterministic Validation
func test_init_doesNotDispatchAction() {
// 1. Initialize controlled time
let initialDate = Date()
var dateProvider = ControlledDateProvider(initialDate: initialDate)
// 2. Create state with initial date
let state = State(lastReadMessageDate: dateProvider.currentDate())
// 3. Advance time before initializing SUT
dateProvider.advance(by: 1) // Move time forward
// 4. Inject controlled provider into SUT
let sut = makeSUT(dateProvider: dateProvider)
// 5. Assert deterministic inequality
XCTAssertNotEqual(
sut.state.lastReadMessageDate,
state.lastReadMessageDate // Now guaranteed to differ
)
}
Key Benefits
- Flakiness Eliminated: Tests no longer depend on system clock precision.
- Time Simulation: Test time-sensitive logic (e.g., expiration, intervals) by advancing dates predictably.
- Improved Maintainability: Clear ownership of time-related logic.
Best Practices
Avoid Date()
in Business Logic: Use the injected DateProvider
everywhere.
Default to System Time in Production:
init(dateProvider: DateProvider = SystemDateProvider())
Test Edge Cases:
- Leap years
- Time zone changes
- Daylight saving transitions
When to Use This Approach
- Testing time-dependent logic: Caching, session expiration, etc.
- Reproducing time-specific bugs: e.g., “Occurs every Friday at 3 PM.”
- CI/CD pipelines: Ensure consistency across environments.
Conclusion
Flaky tests erode trust in your test suite and waste valuable time. By decoupling from Date()
and injecting a controlled time source, you transform unpredictable tests into reliable validations of your code’s behavior.
Further Reading:
Testing Date and Time in Swift (Essential Developer)
By mastering deterministic date handling, you’ll ship robust features with confidence — and leave flakiness in the past. 🚀