Luke
Luke

Reputation: 9700

Trigger UIAlertAction on UIAlertController programmatically?

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.

Upvotes: 14

Views: 8154

Answers (4)

Brian Sachetta
Brian Sachetta

Reputation: 3463

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“)
    }
}

Upvotes: 0

Evan R
Evan R

Reputation: 875

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
}

Upvotes: 0

mmvdv
mmvdv

Reputation: 526

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])
    }
}

Upvotes: 32

Luke
Luke

Reputation: 9700

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)

Upvotes: 5

Related Questions