After the team writes the to-do count in the main app, the widget on a remote simulator still shows the old value. Calling the refresh API more often usually adds noise to the investigation: writing shared data, reading it in the extension, generating a timeline, and having the system display it are four separate stages. Using a WidgetKit widget that displays a to-do count, we can give each stage its own testable condition.
Define the data handoff boundary
The main app and the widget extension run in separate processes. Ordinary UserDefaults.standard does not automatically become a shared data source. First, enable the same App Group for both targets and put its identifier in project configuration that both can read. Do not hard-code a test identifier into the release configuration.
After writing the business data, the main app saves the count and update time to UserDefaults(suiteName:). The extension reads them using the same suite name. The write function should report success or an error rather than silently retaining the old value; if a read fails, the extension should show an explicit default state. If the data contains more than one integer, encode it as a versioned record so an updated count cannot be paired with an old title.
A refresh request only tells the system that “the data may have changed.” It does not move data from the main app into the extension or guarantee an immediate widget redraw.
Read the data back once in the main app after writing it, then log a redacted version number when the extension reads it. If the versions differ, investigate the data handoff. If they match but the screen does not, move on to the timeline and display. Do not put task contents, account information, or the complete shared record in diagnostic logs.
Make timeline computation a pure function
Timeline entry contents should not depend on the real clock when a test runs. Have the provider read the data and call a computation function that accepts the current time and the snapshot already read. The following code shows the minimum boundary; the types can live in a source file shared by the main app and extension.
import Foundation
struct CountEntry: Equatable {
let date: Date
let count: Int
let revision: Int
}
func makeEntries(
now: Date,
count: Int,
revision: Int
) -> [CountEntry] {
[
CountEntry(date: now, count: count, revision: revision),
CountEntry(
date: now.addingTimeInterval(15 * 60),
count: count,
revision: revision
)
]
}
The second entry still carries the same snapshot: fifteen minutes passing does not imply that the to-do count will change. Once the provider has the entries, it can set a policy to request another timeline after the last entry. That policy expresses a desired check time, not a promise that the UI will update to the second. When new business data arrives, request a reload for the relevant widget kind. If the product requires an exact countdown, revisit the requirement rather than simulating a real-time process with closely spaced timeline entries.
Fix the inputs and assert predictable results
In a unit test, supply a fixed Date, count, and revision number. Assert that the function produces exactly two entries, their dates are strictly increasing, and both carry the same count and revision. Also test a count of zero: zero must render as a real value, not be hidden by the default state used for read failures. These tests neither launch a simulator nor wait for system scheduling, so a failure points directly to the computation logic.
Verify the real handoff in a cloud simulator
First, run xcrun simctl list devices available to find an available iOS simulator and its device ID. Pin that ID for builds and tests so a change of runner does not match the device name to a different runtime:
xcodebuild test \
-project Widgets.xcodeproj \
-scheme Widgets \
-destination "platform=iOS Simulator,id=$SIM_ID" \
-resultBundlePath "$PWD/widget-tests.xcresult"
The project name and scheme are examples; replace them with names that exist in the repository before running the command. The task configuration should explicitly provide SIM_ID. Use a fresh results directory for each run so an existing .xcresult does not cause the command to fail early. After the timeline unit tests pass, install a build containing both the main app and extension, then add the widget in the simulator. Confirm the default state first, write a new revision from the main app and request a refresh, and finally compare the extension’s recorded read with the screen.
For UI acceptance, check only the final content and revision number. Do not make “appears within a few seconds of the request” a hard assertion. The system can adjust refresh timing. If a test must verify immediate display precisely, test a controllable preview view inside the main app instead of treating system scheduling as an app timer.
Diagnose by symptom instead of repeatedly reinstalling
| Observation | Check first |
|---|---|
| The value read back by the main app is already wrong | Write timing, suite name, and error handling |
| The main app reads the right value, but the extension still reads the old one | App Group configuration for both targets and the record version |
| The extension reads the new value, but entries are still old | Provider arguments and timeline construction |
| New entries are correct, but the screen is still old | Widget kind, values used by the view, and when the system actually displays it |
Before investigating, record the project commit, runtime version, device ID, and shared-record revision. They say more than a screenshot showing that “nothing changed.” Do not erase the entire simulator after every failure; that also deletes the shared data needed to reproduce the issue. When a reset is necessary, first preserve test results and the necessary redacted records, then explicitly choose whether to clear app data or recreate the simulator.
Put acceptance criteria in the pipeline
Split automation into two layers. The first runs pure-function tests to catch regressions in entry order, zero values, or revision numbers. The second uses a fixed simulator to check that both the app and extension are installed and read the same record, then verifies layout manually or through a controllable preview. Treat the widget’s actual refresh as a separate observation rather than mixing it with the first layer’s deterministic assertions.
Finally, check three things: do both targets point to the same shared group, does new data carry a traceable revision number without revealing its contents, and do tests take time as an input instead of waiting for real minutes to pass? Once those conditions hold, the next stale screen can be investigated in order—data, entries, then display—instead of guessing by repeatedly requesting a refresh.
Frequently asked questions
Why does a widget still show an old value after a reload request?
A reload request does not guarantee immediate execution. Check the value in the shared container and the provider’s read result first; do not treat a fixed display deadline as proof of failure.
Do timeline tests need to wait for a simulator refresh?
No. Inject the current time and data into a pure function and assert entry order and content. Use the simulator to verify installation, data transfer, and eventual display.
Add a cloud Mac with a fixed configuration to your workflow
Rent dedicated physical nodes by the day, week, month, or quarter. Run builds, automation, and MLX inference with fixed hardware resources.