Eliminating Flaky iOS Tests: Mastering Deterministic Date Handling

abdul ahad
3 min readJan 28, 2025

--

Photo by Nathan Dumlao on Unsplash

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

  1. Flakiness Eliminated: Tests no longer depend on system clock precision.
  2. Time Simulation: Test time-sensitive logic (e.g., expiration, intervals) by advancing dates predictably.
  3. 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. 🚀

--

--

abdul ahad
abdul ahad

Written by abdul ahad

A software developer dreaming to reach the top and also passionate about sports and language learning

No responses yet