Test recurring iOS schedules across daylight-saving changes
A background task set to “sync once a day at 02:30 local time” may work on ordinary days but miss a run or run twice when daylight-saving time changes. Start on a cloud Mac by isolating the next-run calculation as a pure function and testing it against fixed dates. Only then connect it to notifications or background scheduling. These tests check the calendar calculation contract, not when the operating system actually wakes the app.
Distinguish wall-clock time from elapsed time
“Every twenty-four hours” and “every day at 02:30” are different rules. The first advances by elapsed seconds; the second follows the calendar in a particular time zone. A day is not always twenty-four hours long, so the same seconds-based arithmetic cannot implement both.
For a daily rule, store the hour, minute, time zone identifier, and policy version. Storing the absolute time of the next run makes queries easier, but it is no substitute for the original rule. Recalculate when the user changes the time zone or policy.
Daterepresents an absolute instant and carries no time zone. “At a certain time every day” is a calendar rule that must be interpreted with an explicitCalendarandTimeZone.
Also decide whose time zone the schedule follows: the zone in which it was created, or the user’s current location. Do not let the cloud machine’s current time zone make that product decision, and do not store only a fixed offset; an offset cannot represent future daylight-saving changes.
Define product rules for missing and repeated times
When clocks move forward in spring, 02:30 may not exist. You can skip that day, move the run to the first valid time later that day, or move it forward while preserving the minute component. These three choices produce different results.
The Calendar policy .strict requires an exact match; for a daily rule, a nonexistent time causes the search to continue on later dates. .nextTime selects a subsequent valid time, but you cannot assume it preserves the original minute value. If the minute component must be preserved, test .nextTimePreservingSmallerComponents separately.
When clocks move back in autumn, 02:30 may occur twice. Explicitly choose .first or .last. If the product must run at both occurrences, design two candidate instants with distinct event identities. A single matching call is not a complete implementation.
Run the Swift acceptance script with fixed dates
Save the following as recurrence.swift. The example uses the Gregorian calendar and an explicit time zone. Its test dates are boundary fixtures, not dates on which measurements are taken. There is no need to change the machine’s clock or launch a simulator.
import Foundation
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Europe/Berlin")!
let formatter = ISO8601DateFormatter()
func instant(_ value: String) -> Date {
guard let date = formatter.date(from: value) else {
fatalError("Invalid fixture date")
}
return date
}
func next(
after cursor: Date,
policy: Calendar.MatchingPolicy,
repeated: Calendar.RepeatedTimePolicy
) -> Date {
guard let date = calendar.nextDate(
after: cursor,
matching: DateComponents(hour: 2, minute: 30, second: 0),
matchingPolicy: policy,
repeatedTimePolicy: repeated,
direction: .forward
) else {
fatalError("No matching occurrence")
}
precondition(date > cursor)
return date
}
let spring = instant("2027-03-28T00:00:00+01:00")
let skipped = next(
after: spring,
policy: .strict,
repeated: .first
)
precondition(skipped == instant("2027-03-29T02:30:00+01:00"))
let shifted = next(
after: spring,
policy: .nextTime,
repeated: .first
)
precondition(shifted == instant("2027-03-28T03:00:00+02:00"))
let autumn = instant("2027-10-31T00:00:00+02:00")
let first = next(
after: autumn,
policy: .strict,
repeated: .first
)
let last = next(
after: autumn,
policy: .strict,
repeated: .last
)
precondition(first == instant("2027-10-31T02:30:00+02:00"))
precondition(last == instant("2027-10-31T02:30:00+01:00"))
precondition(last.timeIntervalSince(first) == 3600)
Run it with the selected development toolchain:
xcrun swift recurrence.swift
The script exits silently on success and with a failure status if an assertion fails. Propagate that failure to the pipeline rather than merely checking whether logs were generated. Rerun the fixtures after upgrading the toolchain or operating system, and record the OS version, toolchain version, and time zone identifier.
The script tests only the fixed 02:30 rule. When integrating it into your project, parameterize the hour and minute, then add cases for ordinary dates, month ends, year ends, and leap days. Specify the expected absolute instant for every case rather than comparing only formatted strings.
Verify cursor advancement and prevent duplicate catch-up runs
When calculating multiple candidate instants, use the previous result as the cursor for the next search and verify that results are strictly increasing. Do not subtract one second before searching again: an exact boundary match could return the same event twice. Also cap the number of iterations in batch searches so that an abnormal rule cannot tie up the executor for an extended period.
Use a fixed window for catch-up runs: exclude the start and include the end. Freeze the endpoint before enumerating candidates instead of repeatedly reading the current time inside the loop. Otherwise, a long-running task keeps expanding its catch-up window, and acceptance test results become impossible to reproduce.
For a schedule that runs only once a day, build a deduplication key from “schedule identifier + rule version + local date in the rule’s time zone.” Persist completion records. If the business operation has external side effects, pass that key to the business layer as well; marking success only in memory is not enough.
Test an interruption during processing: complete the business operation, then simulate an interruption before the completion record reaches persistent storage. Business-level idempotency must prevent a retry from repeating the operation. Scheduler deduplication is not a transactional guarantee.
Test calendar correctness separately from system scheduling
Once the pure function passes, use the simulator to test rule editing, display, and reading back persisted data. The system decides when background execution opportunities are available, so you cannot promise execution at the exact second. Display separate “scheduled execution time” and “actual execution time” fields to avoid mistaking a scheduling delay for a calendar error during troubleshooting.
Finish with four checks: tests do not read the default time zone; daily rules do not advance by a fixed number of seconds; the repeated-time policy is passed explicitly; and catch-up runs use a stable window and persisted identities. Regression tests across daylight-saving transitions should compare calculated results with execution records, not rely on changing the system clock and hoping for the best.
Frequently asked questions
Can a daily schedule advance by adding twenty-four hours?
Only if the requirement is a fixed elapsed interval. For the same local time each day, match calendar components in an explicit time zone so daylight-saving changes do not shift the intended clock time.
Should a repeated local time trigger the task twice?
Define that as a product rule: use the first occurrence, the second, or both. For a once-daily rule, deduplicate by schedule identifier, rule revision, and local date rather than UTC timestamp alone.
Run your projects on a dedicated physical Mac
Build, test, and use a remote desktop on a cloud Mac. Choose the configuration and billing period that suit your project. Check the console for real-time availability.