[iOS] Acceptence Test Implementation
Lets code base on Technical stories
Prerequisite
- Xcode 16
- Min iOS 14
- Swift 5
- SceneDelegate
- ViewInspector
Technical Stories
Implement Navigation Logic
As a developer,
I need to implement navigation logic for moving between screens,
So that users can move through the app seamlessly.
Tasks:
Use UINavigationController to handle the navigation stack.
Ensure each screen transition is animated.Add Accessibility Identifiers
As a developer,
I need to add accessibility identifiers to UI elements,
So that the app is testable and accessible.
Tasks:
Add PAGE_ONE_BUTTON, PAGE_TWO_BUTTON, and TEXT_DESCRIPTION identifiers.Lets create our simple navigation logic with SwiftUI and UIKit. We have 3 Pages : HomeView, PageOneView and PageTwoView.
Architecture and Code Design Choices
View Decoupling with Closures
Each view accepts closures (showPageOne, showPageTwo) to handle navigation. This achieves:
- Separation of concerns: Views don’t own navigation logic; they simply emit events.
- Testability: Navigation behavior can be injected or mocked, simplifying integration testing.
- Reusability: Views remain agnostic of their parent or navigation context, making them composable and usable in different scenarios.
UIKit-SwiftUI Interoperability
The use of UIHostingController embeds SwiftUI views into a UINavigationController stack. This is useful when:
- You are incrementally adopting SwiftUI in a UIKit-based project.
- You want granular control over navigation, as SwiftUI’s native navigation APIs (e.g.,
NavigationStack) can feel restrictive or limited for complex workflows.
Accessibility Identifiers for Testing
Accessibility identifiers (like PAGE_ONE_BUTTON) decouple test logic from the view hierarchy. This is crucial in maintaining robust UI tests:
Get Muhammad Alfiansyah’s stories in your inbox
Join Medium for free to get updates from this writer.
Why this matters:
- The identifier remains stable even if the UI hierarchy changes.
- Tests target user-visible elements instead of brittle paths in the view tree.
This practice makes the code future-proof for UI changes and aligns with accessibility standards.
enum Constant {
static let helloWorld = "Hello world"
static let goToPageOne = "Go to Page One"
static let pageOne = "Page One"
static let presentPageTwo = "Present Page Two"
static let pageTwo = "Page Two"
}struct HomeView: View {
var showPageOne: () -> Void
init(showPageOne: @escaping () -> Void) {
self.showPageOne = showPageOne
}
enum Identifier: String {
case PAGE_ONE_BUTTON
}
var body: some View {
Text(Constant.helloWorld)
Button(Constant.goToPageOne) {
showPageOne()
}
.accessibilityIdentifier(Identifier.PAGE_ONE_BUTTON.rawValue)
}
}struct PageOneView: View {
var showPageTwo: () -> Void
init(showPageTwo: @escaping () -> Void) {
self.showPageTwo = showPageTwo
}
enum Identifier: String {
case PAGE_TWO_BUTTON
}
var body: some View {
Text(Constant.pageOne)
Button(Constant.presentPageTwo) {
showPageTwo()
}
.accessibilityIdentifier(Identifier.PAGE_TWO_BUTTON.rawValue)
}
}struct PageTwoView: View {
enum Identifier: String {
case TEXT_DESCRIPTION
}
var body: some View {
Text(Constant.pageTwo)
.accessibilityIdentifier(Identifier.TEXT_DESCRIPTION.rawValue)
}
}Test Strategy
The BaseAppAcceptanceTests simulate UI flows from launch to completion:
App Lifecycle Simulation:
The test manually invokes SceneDelegate's scene(_:willConnectTo:options:), bypassing the usual app lifecycle. This is crucial for:
- Isolating the test environment.
- Fully controlling navigation and scene setup.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
guard let scene = (scene as? UIWindowScene) else { return }
let navigation = UINavigationController()
window = UIWindow(windowScene: scene)
window?.rootViewController = navigation
navigation.setViewControllers([
UIHostingController(rootView: HomeView(showPageOne: {
navigation.pushViewController(UIHostingController(rootView: PageOneView(showPageTwo: {
navigation.present(UIHostingController(rootView: PageTwoView()), animated: true)
})), animated: true)
}))
], animated: true)
window?.rootViewController?.view.backgroundColor = .white
window?.makeKeyAndVisible()
}
}View Inspection Extensions:
Extensions on HomeView, PageOneView, and PageTwoView encapsulate the test logic. For example:
extension HomeView {
func showPageOne() throws {
try inspect()
.find(viewWithAccessibilityIdentifier: Identifier.PAGE_ONE_BUTTON.rawValue)
.button()
.tap()
}
}extension PageOneView {
func showPageTwo() throws {
try inspect()
.find(viewWithAccessibilityIdentifier: Identifier.PAGE_TWO_BUTTON.rawValue)
.button()
.tap()
}
}
extension PageTwoView {
func getString() throws -> String {
try inspect()
.find(viewWithAccessibilityIdentifier: Identifier.TEXT_DESCRIPTION.rawValue)
.text()
.string()
}
}Navigation Tests:
The TestApp helper class abstracts interaction with the app under test:
- It navigates between views by triggering closures (
showPageOne,showPageTwo). - Validates state transitions using
XCTUnwrapand view inspection.
private class TestApp {
private var navigation: UINavigationController?
func launch() throws {
let sceneDelegate = SceneDelegate()
let scene = try XCTUnwrap((UIWindowScene.self as NSObject.Type).init() as? UIWindowScene)
let session = try XCTUnwrap((UISceneSession.self as NSObject.Type).init() as? UISceneSession)
let options = try XCTUnwrap((UIScene.ConnectionOptions.self as NSObject.Type).init() as? UIScene.ConnectionOptions)
sceneDelegate.scene(scene, willConnectTo: session, options: options)
navigation = sceneDelegate.window?.rootViewController as? UINavigationController
}
func showPageOne() throws {
let contentView = try XCTUnwrap(navigation?.topViewController as? UIHostingController<HomeView>).rootView
contentView.showPageOne()
}
func showPageTwo() throws {
let contentView = try XCTUnwrap(navigation?.topViewController as? UIHostingController<PageOneView>).rootView
contentView.showPageTwo()
}
func getString() throws -> String {
let contentView = try XCTUnwrap(navigation?.presentedViewController as? UIHostingController<PageTwoView>).rootView
return try contentView.getString()
}
}Assertions:
The final test assertion validates the content of PageTwoView:
final class BaseAppAcceptenceTests: XCTestCase {
func test() throws {
let app = TestApp()
try app.launch()
try app.showPageOne()
try app.showPageTwo()
XCTAssertEqual(try app.getString(), Constant.pageTwo)
}
}This confirms the UI rendered the correct state post-navigation.



![[Flutter Refresh Token] Dio Interceptor](https://miro.medium.com/v2/resize:fit:1358/format:webp/1*V98-4eNKm-y2wsg7SEGITA.jpeg)




