DEV Community

Cover image for Enhancing SwiftUI Alerts : A Reusable Approach with Unit and UI Testing πŸ”„
kamimi01
kamimi01

Posted on

Enhancing SwiftUI Alerts : A Reusable Approach with Unit and UI Testing πŸ”„

Why Do We Need a Reusable Alert View?

When building an app, we often display alerts in different places like this:

VStack {
    Button(action: {
        viewModel.showAlert1()
    }) {
        Text("Show Alert1")
    }
    .alert("Alert1", isPresented: $isShowingAlert1) {
        Button(role: .cancel, action: {
            print("do something")
            viewModel.isShowingAlert1 = false
        }) {
            Text("OK")
        }
    } message: {
        Text("Alert1 message")
    }

    Button(action: {
        viewModel.showAlert2()
    }) {
        Text("Show Alert2")
    }
    .alert("Alert2", isPresented: $isShowingAlert2) {
        Button(role: .cancel, action: {
            print("do something")
            viewModel.isShowingAlert2 = false
        }) {
            Text("Action1")
        }
        Button(action: {
            print("do something")
            viewModel.isShowingAlert2 = false
        }) {
            Text("Action2")
        }
    } message: {
        Text("Alert2 message")
    }

    Button(action: {
        viewModel.showAlert3()
    }) {
        Text("Show Alert3")
    }
    .alert("Alert3", isPresented: $isShowingAlert3) {
        Button(role: .cancel, action: {
            print("do something")
            viewModel.isShowingAlert3 = false
        }) {
            Text("Action1")
        }
        Button(action: {
            print("do something")
            viewModel.isShowingAlert3 = false
        }) {
            Text("Action2")
        }
        Button(action: {
            print("do something")
            viewModel.isShowingAlert3 = false
        }) {
            Text("Action3")
        }
    } message: {
        Text("Alert3 message")
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, each alert follows the same structure: a title, a message, and buttons. Instead of duplicating similar code, let’s create a reusable alert system to keep our code clean and maintainable.

Please feel free to leave a comment. I'm happy to discuss my code to improve! πŸ˜„

How to Build the Reusable Alert View

1. Defining the Alert Model

First, we define the structure of an alert and its buttons.

import SwiftUI

struct AlertDetails: Equatable {  // 3
    let title: String
    let message: String
    let buttons: [AlertButton]  // 1
}

struct AlertButton: Identifiable, Equatable {  // 2, 3
    let id = UUID().uuidString  // 2
    let title: String
    let role: ButtonRole?
    let action: (() -> Void)?
    let accessibilityIdentifier: String  // 4

    init(title: String, role: ButtonRole? = nil, action: (() -> Void)? = nil, accessibilityIdentifier: String = "") {
        self.title = title
        self.role = role
        self.action = action
        self.accessibilityIdentifier = accessibilityIdentifier
    }

    // 3 
    static func == (lhs: AlertButton, rhs: AlertButton) -> Bool {
        lhs.title == rhs.title && lhs.role == rhs.role
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. [AlertButton]: An array of AlertButton to make this alert more customizable.
  2. Identifiable: Required for using ForEach in SwiftUI. Using UUID string here to conform to the protocol.
  3. Equatable: Put Equtable protocol to AlertDetails and AlertButton. This makes unit testing easier by allowing assertions. The action is not included in equality checks since it’s a closure. AlertDetails structure doesn't need to have == static method because String protocol which title and message already conforms to Equtable protocol and we let AlertButton conform to Equatable protocol.
  4. accessibilityIdentifier: Useful for UI testing to identify a UI element.

2. Creating a ViewModifier for Alerts

We create a custom ViewModifier to handle alert presentation.

struct AlertView: ViewModifier {
    @Binding var isShowing: Bool
    let details: AlertDetails

    func body(content: Content) -> some View {
        content
            .alert(details.title, isPresented: $isShowing) {
                ForEach(details.buttons) { button in  // 1
                    Button(role: button.role, action: {
                        button.action?()
                        isShowing = false  // 2
                    }) {
                        Text(button.title)
                    }
                    .accessibilityIdentifier(button.accessibilityIdentifier)
                }
            } message: {
                Text(details.message)
            }
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Uses ForEach to dynamically generate alert buttons.
  2. Calls action when a button is tapped. Dismisses the alert after a button tap since we usually expect the alert view will be dimissed after a button is tapped.

3. Extending View for Simplicity

To simplify usage, we add an extension to View.

extension View {
    func showAlert(isShowing: Binding<Bool>, details: AlertDetails) -> some View {
        self.modifier(AlertView(isShowing: isShowing, details: details))
    }
}
Enter fullscreen mode Exit fullscreen mode

Instead of:

.modifier(AlertView(isShowing: isShowing, details: details))
Enter fullscreen mode Exit fullscreen mode

We can now write:

.showAlert(isShowing: isShowing, details: details)
Enter fullscreen mode Exit fullscreen mode

Of course, we can do the same for other custom modifiers.

4. Creating a Protocol for Alert Management

This is not necessary implementation but defining a protocol helps maintain consistency across different ViewModels.

protocol AlertPresentable {
    var isShowingAlert: Bool { get set }
    var alertDetails: AlertDetails { get }
}
Enter fullscreen mode Exit fullscreen mode

View Model class conforming to this protocol can use this like this:

final class ContentViewModel: ObservableObject, AlertPresentable {
    // AlertPresentable
    @Published var isShowingAlert: Bool = false
    @Published var alertDetails = AlertDetails(title: "", message: "", buttons: [AlertButton(title: "")])
}
Enter fullscreen mode Exit fullscreen mode

πŸš€ Let's try using this common alert view!

We've done with all implementations! Let's use this and see how it looks now.

In a view model class:

final class ContentViewModel: ObservableObject, AlertPresentable {
    // AlertPresentable
    @Published var isShowingAlert: Bool = false
    @Published var alertDetails = AlertDetails(title: "", message: "", buttons: [AlertButton(title: "")])

    func showAlert1() {
        let button = AlertButton(title: "OK", action: {
            print("alert1 button")
        })
        alertDetails = AlertDetails(title: "Alert1", message: "Alert1 message", buttons: [button])
        isShowingAlert = true
    }

    func showAlert2() {
        let button1 = AlertButton(title: "Action", action: {
            print("alert2 button1")
        })
        let button2 = AlertButton(title: "Cancel", role: .cancel, action: {
            print("alert2 button2")
        })
        alertDetails = AlertDetails(title: "Alert2", message: "Alert2 message", buttons: [button1, button2])
        isShowingAlert = true
    }

    func showAlert3() {
        let button1 = AlertButton(title: "Action1", action: {
            print("alert3 button1")
        })
        let button2 = AlertButton(title: "Action2", action: {
            print("alert3 button2")
        })
        let button3 = AlertButton(title: "Action3", action: {
            print("alert3 button3")
        })
        alertDetails = AlertDetails(title: "Alert3", message: "Alert3 message", buttons: [button1, button2, button3])
        isShowingAlert = true
    }
}
Enter fullscreen mode Exit fullscreen mode

In a view:

import SwiftUI

struct ContentView: View {
    @StateObject private var viewModel = ContentViewModel()

    var body: some View {
        VStack {
            Button(action: {
                viewModel.showAlert1()
            }) {
                Text("Show Alert1")
            }
            .accessibilityIdentifier("alert1_button")

            Button(action: {
                viewModel.showAlert2()
            }) {
                Text("Show Alert2")
            }
            .accessibilityIdentifier("alert2_button")

            Button(action: {
                viewModel.showAlert3()
            }) {
                Text("Show Alert3")
            }
            .accessibilityIdentifier("alert3_button")
        }
        .padding()
        .showAlert(isShowing: $viewModel.isShowingAlert, details: viewModel.alertDetails)
    }
}
Enter fullscreen mode Exit fullscreen mode

We can see how simple the view is now.

Testing the Alert

Unit Testing

There is nothing of particular note. What we are verifying is that the pre-display state is correct and that the alerts are displayed as expected.

I use Swift-Testing for unit tests instead of XCTest since I've used Xcode 16.2.

Only tests for alert1 is here for simplicity.

import Testing
@testable import SharedAlertSample

struct SharedAlertSampleTests {
    private var viewModel: ContentViewModel

    init() {
        viewModel = ContentViewModel()
    }

    @Test func showAlert1() {
        #expect(!viewModel.isShowingAlert, "❌ Alert should not be showing")

        viewModel.showAlert1()

        let expectedButton = AlertButton(title: "OK", action: {
            print("alert1 button")
        })
        let expectedAlertDetails = AlertDetails(title: "Alert1", message: "Alert1 message", buttons: [expectedButton])

        #expect(viewModel.isShowingAlert, "❌ Alert should be showing")
        #expect(viewModel.alertDetails == expectedAlertDetails, "❌ Alert details are incorrect")
    }
}
Enter fullscreen mode Exit fullscreen mode

UI Testing

This is also a basic test. It verifies the state of the alert during and after it has been hidden.

Since I am writing UI tests using the Page Object Model, I start by implementing PageObject. (Won't write about the Page Object Model because it's off topic, but please refer to this article as it was easy to understand if you're curious.)

import XCTest

protocol PageObject {
    var app: XCUIApplication { get }
}

struct ContentPageObject: PageObject {
    let app: XCUIApplication
    private let TIMEOUT = 5.0

    private enum Identifiers {
        static let alert1Button: String = "alert1_button"
        static let alert2Button: String = "alert2_button"
        static let alert3Button: String = "alert3_button"
    }

    private var showAlert1Button: XCUIElement {
        return app.buttons[Identifiers.alert1Button]
    }

    private var alert1Title: XCUIElement {
        return app.staticTexts["Alert1"]
    }

    private var alert1Message: XCUIElement {
        return app.staticTexts["Alert1 message"]
    }

    private var alert1Button: XCUIElement {
        return app.buttons["OK"]
    }

    func showAlert1() -> Self {
        showAlert1Button.tap()
        return self
    }

    @discardableResult
    func verifyAlert1ForShowing() -> Self {
        XCTAssertTrue(alert1Title.waitForExistence(timeout: TIMEOUT))
        XCTAssertTrue(alert1Message.waitForExistence(timeout: TIMEOUT))
        XCTAssertTrue(alert1Button.waitForExistence(timeout: TIMEOUT))
        return self
    }

    func dimissAlert1() -> Self {
        alert1Button.tap()
        return self
    }

    @discardableResult
    func verifyAlert1ForDismissing() -> Self {
        XCTAssertTrue(alert1Title.waitForNonExistence(timeout: TIMEOUT))
        return self
    }
Enter fullscreen mode Exit fullscreen mode

Then we can use those method here.

import XCTest

final class SharedAlertSampleUITests: XCTestCase {
    private var app: XCUIApplication!

    override func setUpWithError() throws {
        app = XCUIApplication()
        app.launch()
        continueAfterFailure = false
    }

    @MainActor
    func testShowAlert1() throws {
        ContentPageObject(app: app)
            .showAlert1()
            .verifyAlert1ForShowing()
            .dimissAlert1()
            .verifyAlert1ForDismissing()
    }
}
Enter fullscreen mode Exit fullscreen mode

GitHub (Whole code is HERE!)

Please feel free to refer my whole code in GitHub!

Conclusion

I've written down the actual thought process, from implementation to testing of the production code. I'm sure there are still best implementations out there and I'd be happy to discuss them. Feel free to comment on it.

Hope this is useful for anybody who are into iOS Dev!
Happy coding! πŸš€

Top comments (0)