Async Navigation Testing
Testing AsyncNavigation flows.
This post is part of the series about navigation. Earlier posts:
- Setting the stage for navigation with async functions
- Sheets and alerts with async functions
- Async Navigation Overview
Async Navigation Testing
As a recap, here is the flow that we used as an example throughout the series.
flowchart LR
A[Cart] --> B[Shipping Address]
B --> C[Add Coupon]
C --> H[Order]
C --> D[Payment Method]
D --> F[Billing Address]
F --> E[Card Number]
D --> G[Apple Pay]
E --> H[Order]
G --> H[Order]
The example code also contains all the unit tests related to this post.
The goal of a navigation test is to verify that a specific user path produces a specific sequence of screens, with the expected data on each screen. In our flow, that means confirming that after the user edits the cart, the app shows the shipping address screen, then the add coupon screen, and so on.
To isolate navigation from everything else, we need to simulate:
- the result of user interaction with a screen
- navigation itself (the actual push calls)
- the navigation stack
AsyncNavigation provides TestNavigationProxy for this. It manages the stack, lets us query the top view model, and validates that no unexpected navigation happens before the expected screen is reached.
When using AsyncNavigation, it’s best to separate the root screen from the rest. This lets us express the flow using any navigation proxy (UIKit, SwiftUI, or custom). For testing, we just call the flow with TestNavigationProxy.
In our example, the navigation view is
1
2
3
NavigationFlow(adjustCart()) { cart, proxy in
await OrderFlow(cart: cart, availableCoupons: availableCoupons, proxy: proxy).run()
}
For testing, we can create OrderFlow using predefined cart, availableCoupons, and a test proxy. We run the flow (await flow.run()) and validate that it progresses through the expected screens with the expected data. To do this, we run the flow in a separate task while the main task simulates user interaction and validates changes in the proxy. The start of each test is the same (setting up the test proxy, the root screen, and the flow), so we can put it in the constructor:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@MainActor
struct ExampleTests {
let navigationProxy = TestNavigationProxy()
let adjustCartVM = CartContainer.ViewModel(cart: .sampleCart)
let adjustCart: ViewModelUI<CartContainer>
let flow: OrderFlow
init() {
adjustCart = ViewModelUI<CartContainer>(adjustCartVM)
_ = navigationProxy.push(adjustCart)
flow = OrderFlow(
cart: adjustCart.viewModel.cart,
availableCoupons: Coupon.available,
proxy: navigationProxy
)
}
...
We need to test 3 ways the flow can work:
- free order (after a coupon with 100% off)
- order with Apple Pay
- order with credit card
In this post, we’ll describe the first test. The others are very similar, and the complete test code for all three is in the project.
Testing a Free Order
The exact navigation for a free order:
- edit cart (root)
- set shipping address
- add a coupon that is 100% off
- show order summary
- go back to root (edit cart)
At each step, we need to test that:
- the user is at the right screen
- it’s the next step in the navigation flow
- the screen has the expected data After that, we need to simulate the end of user interaction for this step, so that the flow can continue.
The flow and the testing code have to run in different tasks so that they can work in lockstep, and we need to ensure that the flow runs to completion.
Step 1 is done in the constructor, as part of setting up the flow. Here is how steps 2–5 are captured in code (the notes below provide the details about the pattern):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Test func freeOrder() async throws {
let flowTask = Task {
await flow.run()
}
var timeIndex = 1
// set shipping address
let setAddressVM = try await navigationProxy.getViewModel(AddressForm.self, &timeIndex)
await setAddressVM.publishOnRequest(.address1)
// add 100% off coupon
let addCouponVM = try await navigationProxy.getViewModel(AddCoupon.self, &timeIndex)
let coupon = Coupon.available[0]
#expect(coupon.discount == 1)
await addCouponVM.publishOnRequest(coupon)
// show order summary
let orderSummaryVM = try await navigationProxy.getViewModel(OrderSummary.self, &timeIndex)
#expect(orderSummaryVM.orderDetails.cart.total == adjustCartVM.cart.total)
#expect(orderSummaryVM.orderDetails.coupon?.discount == 1)
await orderSummaryVM.publishOnRequest(orderSummaryVM.orderDetails)
// back to root
let rootCartVM = try await navigationProxy.getViewModel(CartContainer.self, &timeIndex)
#expect(rootCartVM == adjustCartVM)
await flowTask.value
}
Notes
- The time index validates the sequence of events. Each call to get a view model compares the provided index to the proxy’s internal index, throws if they don’t match, and increments the index for the next call. The time index initial value is 1 because the root node is already there.
getViewModelvalidates that the top node has the right view model and lets us inspect its properties.- User interaction is simulated by calling
publishOnRequestwith the value the user would produce on that screen. - We use
publishOnRequestinstead ofpublishbecause the test and navigation code run in different tasks, and the test might otherwise publish the result before the navigation code is ready.publishOnRequestwaits until the navigation code is ready for the next value. In a running app, this is not an issue because the navigation code waits for real user interaction. - Awaiting the
flowTaskvalue at the end ensures that the flow runs to completion.
For more complicated flows, it’s possible to add helper functions that let tests jump to specific places in the flow, but most flows are short (for better UX), and it’s usually simpler to test the whole flow.
Conclusion
AsyncNavigation makes it easy to express and test navigation flows because it matches how we think about navigation. I hope that you’ll consider it in your next project!
The next post will start a new series, which will be about the reducer architecture (TRA).