[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:
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
XCTUnwrap
and 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.