Trigger UIAlertAction on UIAlertController programmatically?
Asked Answered
P

4

14

There are a couple of existing questions on this topic but they aren't quite what I'm after. I've written a little Swift app rating prompt for my app which presents two UIAlertController instances, one triggered by the other.

I'm now trying to unit test this, and trying to reach that second alert in the tests. I've written a simple spy to check the first controller, but I'd like a way to trigger one of the actions on the first alert, which in turn shows the second.

I've already tried alert.actions.first?.accessibilityActivate(), but it didn't seem to break inside the handler of that action – that's what I'm after.

Penick answered 23/3, 2016 at 9:3 Comment(0)
P
5

Here's roughly what I did:

  1. Created a mocked version of my class that would present the alert controller, and in my unit tests, used this mock.

  2. Overrode the following method that I'd created in the non-mocked version:

    func alertActionWithTitle(title: String?, style: UIAlertActionStyle, handler: Handler) -> UIAlertAction
    
  3. In the overridden implementation, stored all the details about the actions in some properties (Handler is just a typealias'd () -> (UIAlertAction))

    var didCreateAlert = false
    var createdTitles: [String?] = []
    var createdStyles: [UIAlertActionStyle?] = []
    var createdHandlers: [Handler?] = []
    var createdActions: [UIAlertAction?] = []
    
  4. Then, when running my tests, to traverse the path through the alerts, I implemented a callHandlerAtIndex method to iterate through my handlers and execute the right one.

This means that my tests look something like this:

feedback.start()
feedback.callHandlerAtIndex(1) // First alert, second action
feedback.callHandlerAtIndex(2) // Second alert, third action
XCTAssertTrue(mockMailer.didCallMail)
Penick answered 5/4, 2016 at 7:42 Comment(1)
Have a look at the most upvoted answer, it is pretty nice !Killarney
V
31

A solution that doesn't involve changing the production code to allow programmatic tapping of UIAlertActions in unit tests, which I found in this SO answer.

Posting it here as well as this question popped up for me when Googling for an answer, and the following solution took me way more time to find.

Put below extension in your test target:

extension UIAlertController {
    typealias AlertHandler = @convention(block) (UIAlertAction) -> Void

    func tapButton(atIndex index: Int) {
        guard let block = actions[index].value(forKey: "handler") else { return }
        let handler = unsafeBitCast(block as AnyObject, to: AlertHandler.self)
        handler(actions[index])
    }
}
Vancevancleave answered 9/1, 2019 at 13:53 Comment(5)
This should be the selected answerChordophone
Thanks, works! Because of the index for a button may change you could identify them by title like: func tapButton(title: String) { guard let action = actions.first(where: {$0.title == title}), let block = action.value(forKey: "handler") else { return } let handler = unsafeBitCast(block as AnyObject, to: AlertHandler.self) handler(action) } Pacify
I get caught "NSInvalidArgumentException", "Source type 1 not available" on line handler(actions[index])Surfactant
@Surfactant looks like you're using UIImagePickerController? This code should work for UIAlertController, I haven't tested on UIImagePickerArose
@atereshkov, yes I want to present a UIImagePickerController when execute UIAlertAction handler.Surfactant
P
5

Here's roughly what I did:

  1. Created a mocked version of my class that would present the alert controller, and in my unit tests, used this mock.

  2. Overrode the following method that I'd created in the non-mocked version:

    func alertActionWithTitle(title: String?, style: UIAlertActionStyle, handler: Handler) -> UIAlertAction
    
  3. In the overridden implementation, stored all the details about the actions in some properties (Handler is just a typealias'd () -> (UIAlertAction))

    var didCreateAlert = false
    var createdTitles: [String?] = []
    var createdStyles: [UIAlertActionStyle?] = []
    var createdHandlers: [Handler?] = []
    var createdActions: [UIAlertAction?] = []
    
  4. Then, when running my tests, to traverse the path through the alerts, I implemented a callHandlerAtIndex method to iterate through my handlers and execute the right one.

This means that my tests look something like this:

feedback.start()
feedback.callHandlerAtIndex(1) // First alert, second action
feedback.callHandlerAtIndex(2) // Second alert, third action
XCTAssertTrue(mockMailer.didCallMail)
Penick answered 5/4, 2016 at 7:42 Comment(1)
Have a look at the most upvoted answer, it is pretty nice !Killarney
T
0

I used Luke's guidance above to create a subclass of UIAlertAction that saves its completion block so it can be called during tests:

class BSAlertAction: UIAlertAction {

    var completionHandler: ((UIAlertAction) -> Swift.Void)?

    class func handlerSavingAlertAction(title: String?,
                                        style: UIAlertActionStyle,
                                        completionHandler: @escaping ((UIAlertAction) -> Swift.Void)) -> BSAlertAction {
        let alertAction = self.init(title: title, style: style, handler: completionHandler)
        alertAction.completionHandler = completionHandler
        return alertAction
    }

}

You could customize this to save more information (like the title and the style) if you like. Here's an example of an XCTest that then uses this implementation:

func testThatMyMethodGetsCalled() {
    if let alert = self.viewController?.presentedViewController as? UIAlertController,
        let action = alert.actions[0] as? BSAlertAction,
        let handler = action.completionHandler {
            handler(action)
            let calledMyMethod = self.presenter?.callTrace.contains(.myMethod) ?? false
            XCTAssertTrue(calledMyMethod)
    } else {
        XCTFail("Got wrong kind of alert when verifying that my method got called“)
    }
}
Threnody answered 23/9, 2018 at 14:8 Comment(2)
I thought of subclassing UIAlertAction for this purpose, as in a way it feels like this is functionality that's missing (based on UIContextualAction), but in the end I went a different route. Can you tell me what the callTrace property is that you're using?Ojeda
Whoops, sorry, yeah I should have been more clear there. In this code, callTrace was an array that stored enum representations of the function names in our code. The logic was that when a function was called, we could append the name of it / corresponding enum in callTrace, then, when performing our assertions elsewhere in our code, we could query callTrace to verify that the methods we expected to get called actually were.Threnody
O
0

I took a slightly different approach based on a tactic I took for testing UIContextualAction—it's very similar to UIAction but exposes its handler as a property (not sure why Apple wouldn't have done the same for UIAction). I injected an alert actions provider (encapsulated by a protocol) into my view controller. In production code, the former just vends the actions. In unit tests, I use a subclass of this provider which stores the action and the handler in two dictionaries—these can be queried and then triggered in tests.

typealias UIAlertActionHandler = (UIAlertAction) -> Void

protocol UIAlertActionProviderType {
    func makeAlertAction(type: UIAlertActionProvider.ActionTitle, handler: UIAlertActionHandler?) -> UIAlertAction
}

Concrete object (has typed titles for easy retrieval later):

class UIAlertActionProvider: UIAlertActionProviderType {
    enum ActionTitle: String {
        case proceed = "Proceed"
        case cancel = "Cancel"
    }

    func makeAlertAction(title: ActionTitle, handler: UIAlertActionHandler?) -> UIAlertAction {
        let style: UIAlertAction.Style
        switch title {
        case .proceed: style = .destructive
        case .cancel: style = .cancel
        }

        return UIAlertAction(title: title.rawValue, style: style, handler: handler)
    }
}

Unit testing subclass (stores actions and handlers keyed by ActionTitle enum):

class MockUIAlertActionProvider: UIAlertActionProvider {
    var handlers: [ActionTitle: UIAlertActionHandler] = [:]
    var actions: [ActionTitle: UIAlertAction] = [:]

    override func makeAlertAction(title: ActionTitle, handler: UIAlertActionHandler?) -> UIAlertAction {
        handlers[title] = handler

        let action = super.makeAlertAction(title: title, handler: handler)
        actions[title] = action

        return action
    }
}

Extension on UIAlertAction to enable typed action title lookup in tests:

extension UIAlertAction {
    var typedTitle: UIAlertActionProvider.ActionTitle? {
        guard let title = title else { return nil }

        return UIAlertActionProvider.ActionTitle(rawValue: title)
    }
}

Sample test demonstrating usage:

func testDeleteHandlerActionSideEffectTakesPlace() throws {
    let alertActionProvider = MockUIAlertActionProvider()
    let sut = MyViewController(alertActionProvider: alertActionProvider)

    // Do whatever you need to do to get alert presented, then retrieve action and handler
    let action = try XCTUnwrap(alertActionProvider.actions[.proceed])
    let handler = try XCTUnwrap(alertActionProvider.handlers[.proceed])
    handler(action)

    // Assert whatever side effects are triggered in your code by triggering handler
}
Ojeda answered 8/8, 2020 at 3:0 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.