10

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.

Luke
  • 8,709
  • 14
  • 74
  • 140

4 Answers4

21

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])
    }
}
mmvdv
  • 380
  • 3
  • 11
  • 4
    This should be the selected answer – matteok Feb 05 '19 at 16:35
  • 2
    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) } ` – corban Mar 06 '19 at 07:01
  • I get `caught "NSInvalidArgumentException", "Source type 1 not available"` on line `handler(actions[index])` – SchmidtFx Mar 09 '20 at 10:55
  • @SchmidtFx looks like you're using UIImagePickerController? This code should work for UIAlertController, I haven't tested on UIImagePicker – atereshkov Mar 09 '20 at 11:16
  • @atereshkov, yes I want to present a `UIImagePickerController` when execute `UIAlertAction` handler. – SchmidtFx Mar 09 '20 at 12:05
  • It worked pretty well in my tests. – Erich Flock Feb 22 '21 at 08:27
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)
Pang
  • 8,605
  • 144
  • 77
  • 113
Luke
  • 8,709
  • 14
  • 74
  • 140
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“)
    }
}
Brian Sachetta
  • 2,821
  • 2
  • 29
  • 42
  • 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? – Evan R Aug 08 '20 at 04:16
  • 1
    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. – Brian Sachetta Aug 12 '20 at 13:13
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
}
Evan R
  • 793
  • 11
  • 25