[iOS] Acceptence Test Implementation

Lets code base on Technical stories

Muhammad Alfiansyah
4 min readNov 19, 2024
Photo by Ainur Khakimov on Unsplash

Prerequisite

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.

--

--

No responses yet