The Accidental Discovery
One afternoon I was experimenting with EnvironmentKeys—trying to drive navigation and coordinate between screens without resorting to imperative hacks. SwiftUI’s environment feels like a top‑down broadcast: you inject a value at the root, and child views pick it up. But what if you want the opposite? What if a child view could whisper something back up the tree?
Enter PreferenceKeys—SwiftUI’s built‑in “bottom‑up” communication channel. I stumbled on them via the standard navigation‑title machinery (SwiftUI uses a preference under the hood to let child views set their own title). In that moment I thought:
“If SwiftUI itself can bubble up its internal state this way… why can’t I?”
So I attached a tiny custom preference to a view, let it bubble up, and then read it off in my hosting code. Suddenly I could observe exactly which views were on‑screen, what their internal state was, and react—without touching the view’s implementation.
This lead to a new testing framework that I published on Github: SwiftLens
From EnvironmentKeys to PreferenceKeys
EnvironmentKeys are SwiftUI’s way of injecting values from parent to children:
extension EnvironmentValues {
@Entry var themeColor: Color = .pink
}
The default color is pink, but you can pass a different color to childviews like:
VStack {
Text("This is another view")
CustomView(text: "This lives in another environment")
.environment(\.themeColor, Color.blue)
}
All views inside the VStack get the new blue them color and use it like so:
struct CustomView: View {
@Environment(.themeColor) var themeColor
let text: String
var body: some View {
Text(text)
.foregroundStyle(themeColor)
}
}

As you can see the environment passes keys down the view hierarchy. It is a top-down approach.
PreferenceKeys are the mirror image: children push values up, and parents collect them.

For example, I could pass up the view hierachy the accessibility identifiers of the screens:
struct LensCaptureKey: PreferenceKey {
static var defaultValue: [String] = []
static func reduce(value: inout [String], nextValue: () -> [String]) {
value.append(contentsOf: nextValue())
}
}
and set them on the views like so:
struct DetailView: View {
let item: Int
var body: some View {
VStack {
...
}
.padding()
.navigationTitle("Detail for (item)")
.preference(key: LensCaptureKey.self,
value: ["screen.detailview.\(item)"])
}
}
You can think of this as any view can “tag” itself with an identifier and some metadata—and SwiftUI will deliver all of those tags to an ancestor via onPreferenceChange.
You can then catch
them at a higher level in the view hierarchy with the onPreferenceChange
modifier:
struct ContentView: View {
var body: some View {
NavigationStack {
RootView()
.navigationTitle("Root")
.navigationDestination(for: Int.self) { item in
DetailView(item: item)
}
}
.onPreferenceChange(LensCaptureKey.self) { keys in
print(keys)
}
}
}

When navigating inside the navigation stack, you will see the updated print statements:
Initially when you at the rootview -> [“screen.rootview”]
navigating to item 0 -> [“screen.rootview”, “screen.detailview.0”]
continue navigating to item 11 -> [“screen.rootview”, “screen.detailview.0”, “screen.detailview.11”]
pressing back button -> [“screen.rootview”, “screen.detailview.0”]
pressing back button -> [“screen.rootview”]
Because each view calls .preferencKey(“…”), SwiftUI automatically collects them via your LensCaptureKey and invokes the parent’s onPreferenceChange. You get real‑time feedback on exactly which screens are active—no hacks, no introspection, no brittle accessibility selectors.
Bottom‑up: PreferenceKeys bubble from children up to parents
How SwiftUI Uses PreferenceKeys Under the Hood
Before I repurposed them for testing, I saw PreferenceKeys in action powering:
- Navigation titles
- Toolbar items
- List section headers
⠀…all of which child views “declare,” and the system hoists up to configure the navigation bar or toolbar. It felt like discovering a secret API invitation from Apple.
I also want to stress this in case you wonder if I am using some magic hack here, which I don´t, it´s a SwiftUI native API that has been around since iOS 13 and never changed. I am simply using PreferenceKeys in a creative way 😇.
Next up, we’ll see how to wire this same mechanism into a test suite (using async/await and a simple observer) so your UI tests can wait for exactly the right views to appear or disappear—with zero flakiness.
Minimal Testing Setup
Before we dive into programmatic navigation, let’s get our feet wet with the absolute bare minimum to test a PreferenceKey tag. I will use unit tests to run these tests, which will make them super fast.
Here’s what we need:
import Testing
@testable import TestingWithPreferencesProject
struct TestingWithPreferencesProjectTests {
@MainActor
func rootScreenPreference_is_Captured() throws {
//–– 1. Create the observer
//–– 2. Wrap ContentView in an onPreferenceChange
let sut = ContentView()
.onPreferenceChange(LensCaptureKey.self) { preference in
// pass the preferences to the observer
}
//–– 3. Host it in a window inside UIHostingController
//–– 4. wait for SwiftUI render
//–– 5. Assert that "screen.rootview" was emitted
}
}
Step‑by‑Step
Lets define a simple observer where we’ll store any tags we see in an array. This file lives in the test suite.
import Foundation
final class LensObserver {
@Published var lensCaptures: [String] = []
}
and use it in the test function and write the assertion against the observer values:
@MainActor
func rootScreenPreference_is_Captured() throws {
//–– 1. Create the observer
let observer = LensObserver()
//–– 2. Wrap ContentView in an onPreferenceChange
let testView = ContentView()
.onPreferenceChange(LensCaptureKey.self) {
observer.lensCaptures = $0
}
//–– 3. Host it in a window inside UIHostingController
//–– 4. wait for SwiftUI render
RunLoop.main.run(until: Date().addingTimeInterval(0.1))
//–– 5. Assert that "screen.rootview" was emitted
#expect(observer.lensCaptures.contains("screen.rootview"))
}
Next we need to actually run the SwiftUI view. This is a bit clunky, but I took inspiration from other testing frameworks like ViewInspector and Snapshot testing
//–– 3. Host it in a window inside UIHostingController
let hostingController = UIHostingController(rootView: testView)
let frame = UIScreen.main.bounds
let window = UIWindow(frame: frame)
let rootVC = UIViewController()
window.rootViewController = rootVC
window.makeKeyAndVisible()
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
// Add to parent
hostingController.willMove(toParent: rootVC)
rootVC.addChild(hostingController)
rootVC.view.addSubview(hostingController.view)
// Setup constraints
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: rootVC.view.leadingAnchor),
hostingController.view.topAnchor.constraint(equalTo: rootVC.view.topAnchor),
hostingController.view.widthAnchor.constraint(equalTo: rootVC.view.widthAnchor),
hostingController.view.heightAnchor.constraint(equalTo: rootVC.view.heightAnchor)
])
hostingController.didMove(toParent: rootVC)
window.layoutIfNeeded()
Now if you build and run, the test should most likely pass ✅.
What’s happening?
- We spin up a real UIWindow + UIHostingController.
- onPreferenceChange(LensCaptureKey.self) catches any strings our views emit.
- We run the main run‑loop briefly so SwiftUI has time to attach preferences.
- Finally, we assert that our collector saw “screen.rootview”.
Ditch the Fixed Delay: Async/Await Waiting for Tags
In our first test we used a hard‑coded sleep to give SwiftUI time to emit its preferences:
RunLoop.main.run(until: Date().addingTimeInterval(0.1))
That works… most of the time. But it’s brittle: if your CI is under load you might need to bump the delay, and your suite gets slower. Instead, let’s write a tiny async helper that waits for our tag to appear, and errors out if it doesn’t arrive in a reasonable timeout.
First I will define a useful (but ugly) helper like so:
import Foundation
import Combine
extension Published.Publisher where Value: Equatable {
public func waitUntilMatches(
_ target: Value,
errorMessage: String,
timeout: TimeInterval = 1.0
) async throws {
try await self.waitUntilMatches({ $0 == target },
errorMessage: errorMessage,
timeout: timeout)
}
public func waitUntilMatches(
_ predicate: @escaping (Value) -> Bool,
errorMessage: String,
timeout: TimeInterval = 1.0
) async throws {
let subject = PassthroughSubject<Value, Error>()
var cancellables = Set<AnyCancellable>()
let timeoutPublisher = Fail<Value, Error>(error: WaitUntilError.timeout(description: errorMessage))
.delay(for: .seconds(timeout), scheduler: RunLoop.main)
.eraseToAnyPublisher()
let valuePublisher = self
.mapError { $0 as Error }
.eraseToAnyPublisher()
valuePublisher
.merge(with: timeoutPublisher)
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion {
subject.send(completion: .failure(error))
}
}, receiveValue: { value in
if predicate(value) {
subject.send(value)
subject.send(completion: .finished)
}
})
.store(in: &cancellables)
for try await _ in subject.values {}
}
}
public struct TestTimeoutError: Error, Equatable {}
public enum WaitUntilError: Error, CustomStringConvertible, Equatable {
case timeout(description: String)
public var description: String {
switch self {
case .timeout(let message):
return "⏰ Timeout: (message)"
}
}
}
This is an extensin to @Pubisher. I need to make my test function async.
Now I can write test code like so:
@MainActor
@Test
func rootScreenPreference_is_Captured() async throws {
//–– 1. Create the observer
let observer = LensObserver()
//–– 2. Wrap ContentView in an onPreferenceChange
let testView = ContentView()
.onPreferenceChange(LensCaptureKey.self) {
observer.lensCaptures = $0
}
//–– 3. Host it in a window inside UIHostingController
let hostingController = UIHostingController(rootView: testView)
...
//–– 4. wait for SwiftUI render
try await observer.$lensCaptures.waitUntilMatches({ $0.contains("screen.rootview") },
errorMessage: "We should see the rootview")
//observer.$lensCaptures.assertNoFailure()
//–– 5. Assert that "screen.rootview" was emitted
#expect(observer.lensCaptures.contains("screen.rootview"))
}
What changed?
- We removed the RunLoop.main.run(…) hack.
- waitUntilMatches polls our published tags array until it sees the desired ID or times out.
- Tests now wait just long enough—no more, no less.
Refactoring for better testing workflows
You migh think that the code from the above test is very verbose and you are right. But luckily most of it is boilerplate that every test will need. I will create a separate instance that acts like the WorkBench
where the view is hosted and connected to the observe. Create a new file in your test target and add this:
import SwiftUI
import TestingWithPreferencesProject
@MainActor
public struct LensWorkBench {
public let observer: LensObserver
public var hostingController: UIViewController?
public init<Content: View>(
@ViewBuilder content: (_ sut: LensWorkBench) -> Content
) {
let observer = LensObserver()
self.observer = observer
let window = {
let frame = UIScreen.main.bounds
let window = UIWindow(frame: frame)
let rootVC = UIViewController()
window.rootViewController = rootVC
window.makeKeyAndVisible()
return window
}()
let rootView = content(self)
.onPreferenceChange(LensCaptureKey.self) { metas in
observer.lensCaptures = metas
}
let hostingController = UIHostingController(rootView: rootView)
self.hostingController = hostingController
// Add as child of root view controller
let rootVC = window.rootViewController!
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
// Add to parent
hostingController.willMove(toParent: rootVC)
rootVC.addChild(hostingController)
rootVC.view.addSubview(hostingController.view)
// Setup constraints
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: rootVC.view.leadingAnchor),
hostingController.view.topAnchor.constraint(equalTo: rootVC.view.topAnchor),
hostingController.view.widthAnchor.constraint(equalTo: rootVC.view.widthAnchor),
hostingController.view.heightAnchor.constraint(equalTo: rootVC.view.heightAnchor)
])
hostingController.didMove(toParent: rootVC)
window.layoutIfNeeded()
}
}
with this utily in place your tests become very short and readable:
@MainActor
@Test func rootScreenPreference_is_Captured() async throws {
// --- GIVEN ---
let sut = LensWorkBench { _ in
ContentView()
}
// --- THEN ---
try await sut.observer.$lensCaptures.waitUntilMatches({ $0.contains("screen.rootview") },
errorMessage: "We should see the rootview")
}
You can further shorted this by adding shortcuts to the observer like so:
public final class LensObserver {
@Published public var lensCaptures: [String] = []
public func waitForViewVisible(withID id: String,
timeout: TimeInterval = 1.0) async throws {
try await $lensCaptures.waitUntilMatches(
{ $0.contains { $0 == id } },
errorMessage: "Expected view visible with identifier: (id)",
timeout: timeout
)
}
public func waitForViewHidden(withID id: String,
timeout: TimeInterval = 1.0) async throws {
try await $lensCaptures.waitUntilMatches(
{ !$0.contains { $0 == id } },
errorMessage: "Expected view hidden with identifier: (id)",
timeout: timeout
)
}
}
This allows you to write tests as short as this:
@MainActor
@Test func rootScreenPreference_is_Captured() async throws {
// --- GIVEN ---
let sut = LensWorkBench { _ in
ContentView()
}
// --- THEN ---
try await sut.observer.waitForViewVisible(withID: "screen.rootview")
}
With this async helper in place, your tests become deterministic and fast. Next up: let’s wire in a tiny NavigationCoordinator and see how we can programmatically push and pop screens—and test it with the PreferenceKey tags.
Programmatic Navigation with an Injectable Coordinator
Now that we’ve ditched fixed delays and can await for preference tags, let’s introduce a minimal navigation coordinator so our tests can drive pushes and pops programmatically. We’ll refactor our ContentView to accept an external NavigationCoordinator and then write an async test that:
- Boots the view.
- Waits for the “screen.rootview” tag.
- Calls coordinator.showDetail(0).
- Waits for the “screen.detailview.0” tag.
- Calls coordinator.popToRoot().
- Waits again for only the root tag.
Refactor ContentView to Inject the Coordinator
I will define simple coordinator driving a NavigationStack and add 2 functions for popToRoot and showDetail:
final class NavigationCoordinator: ObservableObject {
@Published var path = NavigationPath()
func showDetail(_ item: Int) {
path.append(item)
}
func popToRoot() {
path = NavigationPath()
}
}
struct ContentView: View {
@ObservedObject var coordinator: NavigationCoordinator
var body: some View {
NavigationStack(path: $coordinator.path) {
RootView()
.navigationTitle("Root")
.navigationDestination(for: Int.self) { item in
DetailView(item: item)
}
}
}
}
Testing if the Programmtic Navigation to Detail View is Working
I will write a new test function with the NavigationCoordinator:
@MainActor
@Test func programmatically_navigating_to_detail_view() async throws {
// --- GIVEN ---
let coordinator = NavigationCoordinator()
let sut = LensWorkBench { _ in
ContentView(coordinator: coordinator)
}
try await sut.observer.$lensCaptures.waitUntilMatches({ $0.contains("screen.rootview") },
errorMessage: "We should see the rootview")
let targetDetailId = 2
// --- WHEN ---
coordinator.showDetail(targetDetailId)
// --- THEN ---
try await sut.observer.waitForViewVisible(withID: "screen.detailview.(targetDetailId)")
}
In the above code, I am setting up the SUT which is the ContentView with the coordinator. The action under test is the showDetail(targetDetailId)
function. Then I am waiting for the observer to collect the identifier for the detail view.
Why this works:
- No magic, just your NavigationCoordinator, a preference tag on each view, and our AsyncPreferenceObserver.
- waitForViewVisible polls until the desired tag shows up (or times out), so you never have to guess how long to sleep.
- Your tests become fast, deterministic, and self‑documenting: you can read the steps top‑to‑bottom and see exactly what UI state you’re awaiting.
Testing if the Programmtic Pop to Root is Working
Let`s test now the pop to root function of the coordinator:
@MainActor
@Test func programmatically_navigating_pop_to_root() async throws {
// --- GIVEN ---
let coordinator = NavigationCoordinator()
let sut = LensWorkBench { _ in
ContentView(coordinator: coordinator)
}
let targetDetailId = 2
let targetViewId = "screen.detailview.(targetDetailId)"
coordinator.showDetail(targetDetailId)
try await sut.observer.waitForViewVisible(withID: targetViewId)
// --- WHEN ---
coordinator.popToRoot()
// --- THEN ---
try await sut.observer.waitForViewHidden(withID: targetViewId)
}
First I need to start the workbench with the coordinator. Then I programmatically push to the detail view. After waiting for the views to be updated, I start the test and call popToRoot
. As the test assertion, I check if the detail view is hidden.
Thats it you can test you navigation in a very clear and short. Each test is fast and if I run all 3 above tests in parallet it take
✔ Test run with 3 tests passed after 0.130 seconds.
Is this really working
When I tested this, I was honestly surprised how well this is working. It feels a bit like flying blindly. So I decice to add some snapshot tests. I will use this library and write a test where i navigate to the detail, wait for the view to appear and take the snapshot:
@MainActor
@Test func snapshot_navigating_to_detail_view() async throws {
// --- GIVEN ---
let coordinator = NavigationCoordinator()
let sut = LensWorkBench { _ in
ContentView(coordinator: coordinator)
}
try await sut.observer.$lensCaptures.waitUntilMatches({ $0.contains("screen.rootview") },
errorMessage: "We should see the rootview")
let targetDetailId = 2
// --- WHEN ---
coordinator.showDetail(targetDetailId)
// --- THEN ---
try await sut.observer.waitForViewVisible(withID: "screen.detailview.(targetDetailId)")
assertSnapshots(of: sut.hostingController!, as: [.image], record: false)
}
Note: snapshots are sometimes wired, I had to restart Xcode, clean the project and relaunch the testing to get proper screenshots.
Here is the result from my Xcode project

This is showing the expected ui from the detail view. Pretty cool ✅
Why Preference Testing Avoids Flakiness
- Traditional UI tests often rely on assumptions about when the UI will update (e.g.
.wait(for: 2)
orXCTestExpectation
). - Observer uses
.onPreferenceChange
and tracks views through actual appearance/disappearance in the view hierarchy. - Your assertions (
waitForViewVisible
,waitForViewCount
, etc.) wait only until the UI actually renders the state, regardless of what caused the delay (network, animation, debounce, etc.).
That means:
- You can refactor internal logic without breaking tests.
- Race conditions are avoided because the test synchronizes with what the user would actually see.
TDD with Preference – Reducing Brittleness
With preferences as `tags` you can say “something meaningful is visible here” — then later add: “and it’s a button”
We started with observing the screens in the navigation stack. During test driven development, you might have placeholder views for some of your screens:
Text("Placeholder View")
.preference(key: LensCaptureKey.self,
value: ["screen.detail"])
You write a test for this tag. Later you decide to change the placeholder and implement the screen e.g.:
struct ProductDetailScreen: View {
let product: Product
@State private var showAuth = false
var body: some View {
ScrollView {
VStack(spacing: 20) {
Image(product.image)
Button(action: {
// call buy
}) {
Label("Buy Now", systemImage: "cart.badge.plus")
}
}
}
.preference(key: LensCaptureKey.self,
value: ["screen.detail"])
}
}
Your first test against the “screen.detail” tag is still working because you did not use an assertion against an implementation detail.
You want to further define the test and make sure the “Buy Now” button is visible.
Grouping Preference Values
Preference keys are propagaged and reduced in the view hierarchy. You can use transformPreference to group individual preference values. I first change the preference value to include a child array:
public struct LensCapture: Equatable {
public let viewType: String
public let identifier: String
public var info: [String: AnyHashable]
public var children: [LensCapture] = [] // use transformPreferences
}
And then change the view like so:
ScrollView {
VStack(spacing: 20) {
Image(product.image)
Button(action: {
// call buy
}) {
Label("Buy Now", systemImage: "cart.badge.plus")
}
.preference(key: LensCaptureKey.self,
value: [LensCapture(identifier: "screen.detail.buy.button",
children: [])
}
.onPreferenceChange(LensCaptureKey.self) { values in
values = [LensCapture(identifier: "screen.detail",
children: values]
}
}
I then can write my test assertions agains these different levels of scope:
@MainActor
@Test
func show_buy_button() {
let sut = LensWorkBench { _ in
ProductDetailScreen()
}
try await sut.observer.waitForViewVisible(withID: "screen.detail")
try await sut.observer.waitForViewVisible(withID: "screen.detail.buy.button")
}
I am using the following pattern that helps me avoid brittle tests:
- Layered identifiers (via
onPreferenceChange
andpreference
) - Lets you say: “something meaningful is visible here” — then later add: “and it’s a button”
- You write tests while developing, not just after
- Tests adapt to UI evolution, not break on it
Think of it as “progressive disclosure” for test intent — start coarse, go detailed as the UI settles.
This benefit of writing scoped tests, is probably one of my favorite approaches. It allows me to write and develop my code and write flexibile tests.
Whats Next
I was very happy with the results from my test the test setup. I will in the future write more to show you:
- How to test for internal state like button disabled, testfield text
- Creating scoped tests with assertions that don`t give you brittle tests
- Simulate user interaction
You can look at the open source package I published on Github here: SwiftLens. You can have a look at the test cases to get more examples
Edge cases & caveats: Note that PreferenceKeys only fire when a view is in the hierarchy, and that very large hierarchies may add minor overhead. Please choose only specific views/scopes for you tests and don`t add this blindly to everyview. I prefere to use these specifically for conditional views e.g. inside a if or switch statement.
Summary
With just a few small pieces in place—PreferenceKeys tagging your views, an async observer to await tags, and a minimal hosting setup—you get:
- Blazing‑fast, in‑process tests No simulator launch, no XCUITest. Your suite runs in milliseconds.
- Scoped & deterministic You only observe exactly the screens or controls you tag, and waitUntil… ensures you never race or over‑sleep.
- Zero flakiness Polling your published preference array beats hard‑coded delays every time.
- Minimal boilerplate A tiny LensWorkBench wrapper and a handful of helpers keep your test code concise and readable.
- Full control over navigation Programmatic pushes and pops become first‑class test actions, and you can await each state transition.
- Ready to extend The same pattern works for loading spinners, empty states, toggle and text‑field states, sheets, full‑screen covers—you name it.