Unit testing UIView action and gesture in Swift

A big part of the developer journey is make sure our code behaves as expected. It’s best practice to setup tests that allow us to test quickly and often that nothing is broken. If unit testing is common practice to check the business logic, we can also extend it to cover some specific UI behaviors. Let’s how to unit test views and gesture in UIKit.

For this article, I’ve build a very simple app with fruits to add or remove to my cart.

uni-test-uiview-sample

Before diving into the code, let’s refresh some testing concepts. In computer science, we can have different layers of testing, each representing a safety-net for the team to make sure everything work as expected:

Unit testing: a suite of test that covers a specific self-contained piece of code, a unit, like a file or a class. If it fails, your unit needs to be fixed.

Integration testing: a suite of test that covers the interaction between two units. When failing, the interaction isn’t working as expected.

Regression testing: this is the complete suite of test across your project. By running it, we’re making sure the last contribution didn’t break anything else. When failing, the app doesn’t behave as it use to be.

Acceptance testing: It’s a higher level of test and most likely performed by the Quality Assurance team to check the new feature meet the requirement. It’s often based on the business / customer / stack-holder needs. It answers the question “did we build the right thing?”

Functional testing / end-to-end testing: it describes the functionality itself and answers the question “did we build a working product?” . It covers more than the acceptance criteria, making sure error-handling and “it shouldn’t happen” scenario actually doesn’t happen.


In my case, I want to make sure some gesture and tap on button are executing the right block. So it sits between unit testing / integration testing, depending of each view integration.

I’ve used an MVVM architecture design pattern to make it easier to test each layer. That being said, I’ll stay focused on the View layer, that’s what interest me today.

Let’s start with testing the buttons.

Unit testing UIButton action

Each cell includes a label and two buttons to add or remove to the cart. When tapping a button, it executes a closure matching that action.

class CustomCell: UITableViewCell {
    
    private(set) lazy var addButton: UIButton = {
        let button = UIButton()
        button.setTitle("Add", for: .normal)
        button.setTitleColor(.white, for: .normal)
        button.backgroundColor = .orange
        button.addTarget(self, action: #selector(tapAddButton), for: .touchUpInside)
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    private(set) lazy var removeButton: UIButton = {
        let button = UIButton()
        button.setTitle("Remove", for: .normal)
        button.setTitleColor(.white, for: .normal)
        button.backgroundColor = .purple
        button.addTarget(self, action: #selector(tapRemoveButton), for: .touchUpInside)
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()

    // MARK: - Actions
    var didTapAdd: (() -> Void)?
    @objc private func tapAddButton() {
        didTapAdd?()
    }
    
    var didTapRemove: (() -> Void)?
    @objc private func tapRemoveButton() {
        didTapRemove?()
    }

    // ... more code ...

For this case, I want to add a test that, when the button receive a touchUpInside, will execute the matching closure.

Good thing is UIButton has a function that can send actions directly. So let’s use it to test the executed closure.

import XCTest
@testable import ViewSample

class CustomCellTests: XCTestCase {

    func test_did_tap_add_succeed_when_touch_up_inside() {
        // Given:
        let tapAddExpectation = expectation(description: #function)
        let cell = CustomCell(style: .default, reuseIdentifier: "id")
        cell.didTapAdd = {
            tapAddExpectation.fulfill()
        }
    
        // When:
        cell.addButton.sendActions(for: .touchUpInside)
        
        // Then:
        wait(for: [tapAddExpectation], timeout: 0.1)
    }
}

For this test, I uses a XCTestExpectation that I pass in the closure to know when it’s executed. It also means that the test will have to wait for it to be completed.

Asynchronous test isn’t ideal but the view actually never render, so it should be executed almost instantly. In worse case, when failing, it would be under 0.1 sec delay which is can reasonable time to fail.

So far so good.

What else can we test around this function? We could make sure another type of action doesn’t execute the closure.

func test_did_tap_add_fail_when_touch_down() {
    // Given:
    let cell = CustomCell(style: .default, reuseIdentifier: "id")
    cell.didTapAdd = {
        XCTFail("unexpected trigger")
    }

    // When:
    cell.addButton.sendActions(for: .touchUpInside)
}

When I execute this test, it first failed due to XCTFail. That’s expected, because the action sent is still touchUpInside, I just want to check that we don’t need to use wait(for: ...) to be failing.

Now I know it would fail as expected for another action, then we can edit that action.

func test_did_tap_add_fail_when_touch_down() {
    // Given:
    let cell = CustomCell(style: .default, reuseIdentifier: "id")
    cell.didTapAdd = {
        XCTFail("unexpected trigger")
    }

    // When:
    cell.addButton.sendActions(for: .touchDown)
}

Alright, so we’ve managed to unit test UIButton action within our custom cell without too much trouble so far. Let’s see what other gestures we can cover.

Unit testing UIView tap gesture

In my ViewController, I have a UIView that user will have to tap to proceed to checkout. I could use a UIButton, but since we want to check other testing (and learn new things), let’s use UIView and UITapGestureRecognizer, just this time. So let’s focus on this area.

class ViewController: UIViewController {

    var viewModel: ViewModelProtocol!
    
    init(viewModel: ViewModelProtocol) {
        super.init(nibName: nil, bundle: nil)
        self.viewModel = viewModel
    }
    
    lazy var checkoutView: UIView = {
        let label = UILabel()
        label.text = "Checkout"
        label.textColor = .white
        label.textAlignment = .center
        
        let view = UIView()
        view.backgroundColor = .blue
        view.addSubview(label)
        
        label.translatesAutoresizingMaskIntoConstraints = false
        view.translatesAutoresizingMaskIntoConstraints = false
        
        view.addGestureRecognizer(checkoutTapGesture)
        
        NSLayoutConstraint.activate([
            label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
        return view
    }()
    
    private lazy var checkoutTapGesture = UITapGestureRecognizer(target: self, action: #selector(tapCheckout))

    @objc private func tapCheckout() {
        viewModel.checkout()
    }

    // ... more code
}

As mentioned earlier, I use MVVM design pattern with protocol oriented programming so we can inject our own ViewModelProtocol later on. When the user will tap on the checkoutView, it will execute viewModel.checkout.

Let’s get to the testing.

import XCTest
@testable import ViewSample

class ViewControllerTests: XCTestCase {
    // ...
}

fileprivate class MockViewModel: ViewModelProtocol {
    var addFruitCalled = 0
    var didCallAddFruit: ((Int) -> Void)?
    func addFruit(_ text: String) {
        addFruitCalled += 1
        didCallAddFruit?(addFruitCalled)
    }
    
    var removeFruitCalled = 0
    var didCallRemoveFruit: ((Int) -> Void)?
    func removeFruit(_ text: String) {
        removeFruitCalled += 1
        didCallRemoveFruit?(removeFruitCalled)
    }
    
    var checkoutCalled = 0
    var didCallCheckout: ((Int) -> Void)?
    func checkout() {
        checkoutCalled += 1
        didCallCheckout?(checkoutCalled)
    }
}

The first part is to create a mock of our ViewModel so we can detect changes. In this case, I uses two properties, one to count how many call and one to get a callback when it’s executed.

The second part is to use setUp and tearDown functions of our test class to prepare the testing environment.

Finally, we can set our test.

class ViewControllerTests: XCTestCase {
    
    private var sut: ViewController!
    fileprivate var viewModel: MockViewModel!

    override func setUpWithError() throws {
        try super.setUpWithError()
        viewModel = MockViewModel()
        sut = ViewController(viewModel: viewModel)
    }

    override func tearDownWithError() throws {
        viewModel = nil
        sut = nil
        try super.tearDownWithError()
    }

    func test_tap_checkout() {
        // Given:
        let tapCheckoutExpectation = expectation(description: #function)
        XCTAssertEqual(viewModel.checkoutCalled, 0)
        viewModel.didCallCheckout = { counter in
            XCTAssertEqual(counter, 1)
            tapCheckoutExpectation.fulfill()
        }
        
        let tapGestureRecognizer = sut.checkoutView.gestureRecognizers?.first as? UITapGestureRecognizer
        XCTAssertNotNil(tapGestureRecognizer, "Missing tap gesture")
        
        // When:
        tapGestureRecognizer?.state = .ended
        
        // Then:
        wait(for: [tapCheckoutExpectation], timeout: 0.1)
    }
}

Similar to the UIButton unit test, I access the gestures of the view and manually change the state.

Passing the number of calls in the closure can also help to detect if there is more than one execution for the same event.

Wait, why not just testing checkoutCalled instead of waiting for the expectation.

Unfortunately, this doesn’t work. Gestures are still recognized asynchronous and executed on the next runloop. So the following doesn’t work:

// Then:
XCTAssertEqual(viewModel.checkoutCalled, 1) // 🚫 fail
wait(for: [tapCheckoutExpectation], timeout: 0.1)

Is there any other way other to keep it synchronous?

Well, yes and no.

One way could be to try access the private target of the gesture underneath via UIGestureRecognizerTarget. It would look something like this.

extension UIGestureRecognizer {
    
    func forceTrigger() throws {
        let gestureRecognizerTarget: AnyClass? = NSClassFromString("UIGestureRecognizerTarget")
        
        let targetIvar = class_getInstanceVariable(gestureRecognizerTarget, "_target")
        let actionIvar = class_getInstanceVariable(gestureRecognizerTarget, "_action")
        
        guard let targets = self.value(forKey: "targets") as? [Any] else {
            throw NSError(domain: "", code: 999, userInfo: [NSLocalizedDescriptionKey: "Cannot access targets"])
        }
        
        for gestureTarget in targets {
            guard let targetIvar = targetIvar,
               let actionIvar = actionIvar else {
                continue
            }
                
            if let target = object_getIvar(gestureTarget, targetIvar) as? NSObject,
               let action = object_getIvar(gestureTarget, actionIvar) as? Selector { // 🚫 fail
                target.perform(action)
                return
            }
        }

        throw NSError(domain: "", code: 999, userInfo: [NSLocalizedDescriptionKey: "Couldn't find target or action to execute"])
    }
}

This code gets access to the private property _action and _target on runtime and manually execute it. Unfortunately, if I can get to the target, the next line object_getIvar(gestureTarget, actionIvar) always crash. No luck.

Similar to the ViewModel we could create a MockTapGestureRecognizer to keep track of the action and force the execution, similar to the following.

class MockTapGestureRecognizer: UITapGestureRecognizer {
    
    var target: Any?
    var action: Selector?
    override func addTarget(_ target: Any, action: Selector) {
        self.target = target
        self.action = action
    }
    
    override var state: UIGestureRecognizer.State {
        didSet {
            forceTriggerAction()
        }
    }
    
    func forceTriggerAction() {
        guard let target = target as? NSObject,
              let action = action else { 
            return
        }
        
        target.perform(action)
    }
}

IF that sound safer, this require to expose more properties and function outside of the ViewController. It also require more effort and feel going away of unit testing the gesture execution, but the ViewController construction. That feels wrong.

Another solution could be to do some method swizzling but it goes away our goal too. I ideally want to test my code behavior, not alter the whole system to check how it’s executed. Same as before, it doesn’t seem the right approach.

If we could find a synchronous solution, it goes against altering too much of our code. Based on the trade-off, it feels the asynchronous test of 0.1 sec does look more balanced solution than any others mentioned. You could make a different decision based on your own need.

Unit testing UIView swipe gesture

Well, once you got the concept, it’s actually the same aspect for any other gesture. That’s because we’re testing the action, not the actual gesture.

So for a swipe, we’re not expected to change location of a CGPoint, we expect the gesture to successfully recognize it, and we check the outcome.

// ViewController
lazy var checkoutView: UIView = {
    let label = UILabel()
    label.text = "Checkout"
    label.textColor = .white
    label.textAlignment = .center
    
    let view = UIView()
    view.backgroundColor = .blue
    view.addSubview(label)
    
    label.translatesAutoresizingMaskIntoConstraints = false
    view.translatesAutoresizingMaskIntoConstraints = false
    
    view.addGestureRecognizer(checkoutSwipeGesture)
    
    NSLayoutConstraint.activate([
        label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        label.centerXAnchor.constraint(equalTo: view.centerXAnchor)
    ])
    return view
}()

private lazy var checkoutSwipeGesture: UISwipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipeCheckout))


// ViewControllerTests
func test_swipe_checkout() {
    // Given:
    let swipeCheckoutExpectation = expectation(description: #function)
    XCTAssertEqual(viewModel.checkoutCalled, 0)
    viewModel.didCallCheckout = { counter in
        XCTAssertEqual(counter, 1)
        tapCheckoutExpectation.fulfill()
    }
    
    
    let swipeGestureRecognizer = sut.checkoutView.gestureRecognizers?.first as? UISwipeGestureRecognizer
    XCTAssertNotNil(swipeGestureRecognizer, "Missing tap gesture")
    
    // When:
    swipeGestureRecognizer?.state = .ended
    
    // Then:
    wait(for: [swipeCheckoutExpectation], timeout: 0.1)
}

That’s it for today! We’ve managed to create unit test for different UI component, making sure user actions and gestures under the right condition would execute the right block. This tests make our project a bit safer to side effect and more reliable if we ever have to revisit this bit.

This code is available on Github as ViewSample.

Happy testing πŸ› 


Resources:

© 2023 Benoit Pasquier. All Rights Reserved
Author's picture

Benoit Pasquier

Software Engineer πŸ‡«πŸ‡·, writing about career development, mobile engineering and self-improvement

ShopBack πŸ’°

Singapore πŸ‡ΈπŸ‡¬