Test iOS preferences in isolation on a cloud Mac
The same iOS settings tests pass on the first run, then read a theme value left over from that run on the second. Move them to a cloud Mac, and the results change again. This usually has nothing to do with machine performance: the tests are conflating in-process defaults, saved settings, and launch arguments. When running these tasks on VpsKeep, isolate preferences before checking application behavior. Do not rely on wiping the entire simulator and hoping for the best.
Define the read contract first
UserDefaults is more than a single key-value table. Reads search several domains: launch arguments can override the application domain, while the registration domain supplies fallback values. A value returned by object(forKey:) is therefore not proof that it has been saved.
Define three things for each setting: its key, its valid type, and the result when it is missing. For example, a theme key might accept only system, light, and dark, returning system if the key is absent, the type is wrong, or the string is unrecognized. This also gives invalid values left behind by older versions a predictable outcome.
register(defaults:) registers fallback values for the current process only. It does not write them as user settings. Reading a default again after deleting a persisted value is normal fallback behavior, not a failed deletion.
Do not use
dictionaryRepresentation()as proof that a key has been saved. It combines visible settings and may include values from the registration domain. To check whether a value is saved, read the specific persistent domain.
Inject a separate suite and leave real settings untouched
Production code can default to .standard, but tests must inject a dedicated instance. Do not let the preferences wrapper fetch .standard again inside its methods, or dependency injection will provide isolation in name only.
The following code can go in an iOS unit test target. Each test uses a random suite name and deletes only the persistent domain it creates. The example also keeps the read wrapper in the test file; when integrating it into a project, move the wrapper to the application target and import it from the tests.
import Foundation
import XCTest
struct ThemePreferences {
let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
defaults.register(defaults: ["theme": "system"])
}
var theme: String {
guard let value = defaults.object(forKey: "theme") as? String,
["system", "light", "dark"].contains(value) else {
return "system"
}
return value
}
}
final class ThemePreferencesTests: XCTestCase {
private func withSuite(
_ body: (UserDefaults, String) throws -> Void
) throws {
let name = "preferences-tests." + UUID().uuidString
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defaults.removePersistentDomain(forName: name)
defer {
defaults.removePersistentDomain(forName: name)
}
try body(defaults, name)
}
func testOverrideAndRemoval() throws {
try withSuite { defaults, name in
let preferences = ThemePreferences(defaults: defaults)
XCTAssertEqual(preferences.theme, "system")
XCTAssertNil(
defaults.persistentDomain(forName: name)?["theme"]
)
defaults.set("dark", forKey: "theme")
XCTAssertEqual(preferences.theme, "dark")
XCTAssertEqual(
defaults.persistentDomain(forName: name)?["theme"]
as? String,
"dark"
)
defaults.removeObject(forKey: "theme")
XCTAssertNil(
defaults.persistentDomain(forName: name)?["theme"]
)
XCTAssertEqual(preferences.theme, "system")
}
}
func testInvalidValuesFallBack() throws {
try withSuite { defaults, _ in
let preferences = ThemePreferences(defaults: defaults)
defaults.set(["dark"], forKey: "theme")
XCTAssertEqual(preferences.theme, "system")
defaults.set("unknown", forKey: "theme")
XCTAssertEqual(preferences.theme, "system")
}
}
}
Random names prevent parallel tests from overwriting one another's settings. Cleanup on exit covers normal execution paths; defer is not guaranteed to run if the process is forcibly terminated. Leftover random domains will not be reused on the next run, but long-running test devices still need periodic audits. Remove only domains confirmed to belong to tests.
Cover override and deletion edge cases
The minimum checks are fallback when no value is saved, an override after writing a valid value, fallback after deletion, fallback for an incorrect type, and fallback for an unknown enum value. Also check that recreating the application wrapper still reads the saved value. This guards against a wrapper that resets the user's choice with set every time it is initialized.
If the application uses both a shared suite and the application domain, do not casually call addSuite(named:) in unit tests. It changes the search path and makes the results depend on other domains. Treat shared settings as another injectable dependency, verify each dependency separately, and then run application integration tests.
Boolean settings also need to distinguish “not set” from “the user chose off.” Calling only bool(forKey:) returns false for a missing value, so it cannot tell you whether the user has made a choice. Check explicitly whether the key exists, or use a separate, versioned state field.
Verify real persistence with two processes
Creating a new UserDefaults object within the same process proves only read behavior, not persistence across a restart. Split the integration test into write and read phases, using the same fixed test suite.
In the first phase, write the theme, confirm that it can be read back through the UI or a test interface, and let the application exit normally. In the second phase, launch the application without writing the value again and check directly whether the theme is restored. Finish with a separate cleanup phase that deletes the test domain.
Do not treat an immediate read after forcibly killing the process as a normal save acceptance test: that tests the abnormal-exit boundary. Nor should a fixed delay of a few seconds or a call to synchronize() be taken as proof that the data has been written to disk.
Launch arguments need a separate test track. If the application injects a theme through arguments, verify that override independently and remove the arguments on subsequent launches. Otherwise, they will mask the saved value and produce a false pass in the persistence test.
Turn failures into traceable records
The pipeline on the cloud Mac only needs to record the test name, suite name, application version, whether the key exists, the actual type, and the expected fallback. Do not export the entire settings dictionary: it may contain user identifiers or internal environment details.
Run the tests twice in succession, then enable parallel execution and check that the results remain consistent. If only the second run fails, look first for fixed suites and domains that were not cleaned up. If failures occur only in parallel, check shared singletons. If they occur only after a restart, check whether initialization overwrites user values.
Final acceptance is not “the right string was returned once.” Defaults, user overrides, fallback after deletion, and process restarts each need independent evidence. Once these boundaries are clearly defined, settings tests can reach the same conclusions on both new machines and long-running nodes.
Frequently asked questions
Does register(defaults:) save defaults to disk?
No. It adds fallback values to the current process’s registration domain. Each new process must register them again; they are not saved user preferences.
Does creating another UserDefaults object prove persistence?
No. Objects within one process may share preference state. Let the writer exit normally, then read from a new process. Calling synchronize() is not a substitute.
Run your workflow on a dedicated cloud Mac
Rent an entire physical Mac mini—not a virtual machine. Choose a daily, weekly, monthly, or quarterly plan. Check the model and location before confirming your order; availability is confirmed in real time by the console.