Unit testing gesture recognizers

Borut Tomazin
Sep 4, 2018 · 4 min read

We all know tested code is an essential path to the successful app. It all starts and ends with the code quality.

If you have ever wondered how to test UIKit frameworks, you are not alone. It’s not comfortable nor elegant in any way. However, this is the path we need to follow until Apple does the job and make it friendlier and more accessible to test.

Regardless the words above we can still work around the limitations UIKit impose on us.

Testing gesture recognizers should be easier if we had a proper public APIs. However, until we do, we’ll need to make an extra effort.

What Apple offers us?

Let’s take a look at the UIPanGestureRecognizer public API first to see what is available and what is not.

open class UIPanGestureRecognizer: UIGestureRecognizer {
open var minimumNumberOfTouches: Int
open var maximumNumberOfTouches: Int
open func translation(in view: UIView?) -> CGPoint
open func setTranslation(_ translation: CGPoint, in view: UIView?)
open func velocity(in view: UIView?) -> CGPoint
}

UIPanGestureRecognizer directly derives from UIGestureRecognizer which holds many more data like state, allowedTouchTypes, numberOfTouches, etc.

As you can see from the code above we can get some basic info about the gesture itself. Also, more info available from the base class. However, at this point, you are probably wondering how can we get out the info about the target and action/selector that we need for a successful unit test.

Ground work

We need to create a subclass of UIPanGestureRecognizer

import UIKitclass UIPanGestureRecognizerMock: UIPanGestureRecognizer {
let target: Any?
let action: Selector?

override init(target: Any?, action: Selector?) {
self.target = target
self.action = action
super.init(target: target, action: action)
}
}

This is pretty much self-explanatory. We are just overriding designated initializer and exposing target and action we’ll later use in the tests.

We can also override other properties like velocity or translation so we can have broader control over gesture.

var gestureTranslation: CGPoint?
var gestureVelocity: CGPoint?
override func translation(in view: UIView?) -> CGPoint {
if let gestureTranslation = gestureTranslation {
return gestureTranslation
}
return super.translation(in: view)
}
override func velocity(in view: UIView?) -> CGPoint {
if let gestureVelocity = gestureVelocity {
return gestureVelocity
}
return super.velocity(in: view)
}

At this point, we can manipulate a gesture recognizer to this point so we can start writing unit tests. Before we do that, let’s set up our base viewController that handles the panning.

import UIKitclass PannableViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()

let action = #selector(panGesture(_:)
let gesture = UIPanGestureRecognizer(target: self,
action: action))
view.addGestureRecognizer(panGesture)
}
}
private extension PannableViewController {
@objc func panGesture(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: view)
// pan gesture handling code goes here
}
}

Also, a test case class that executes all tests.

import XCTest
@testable import BaseTarget
class PannableViewControllerTests: XCTestCase {
private let vc = PannableViewControllerInjector()

override func setUp() {
super.setUp()
_ = vc.view
}
}
private class PannableViewControllerInjector: PannableViewController {
var gestureRecognizer: UIPanGestureRecognizerMock?

override func viewDidLoad() {
super.viewDidLoad()
guard let existingGestureRecognizer = view.gestureRecognizers?.first(where: { $0 is UIPanGestureRecognizer }) as? UIPanGestureRecognizer else { return }
view.removeGestureRecognizer(existingGestureRecognizer)

let action = Selector("panGesture:") // an Objective-C selector instead because a caller metod is private
let newGestureRecognizer = UIPanGestureRecognizerMock(target: self, action: action)
view.addGestureRecognizer(newGestureRecognizer)
gestureRecognizer = newGestureRecognizer
}
}

We have mocked our base view controller so we can take control over the gesture recognizer.

First, we have to find out the existing gesture recognizer and remove it.

Second, we create a new gesture recognizer from our mocked class we have prepared earlier and attached it to the view.

Notice how we defined a Selector? Since we need to access the action method behind a private extension, we need to create an Objective-c selector.

If your action method has any of the less restrictive access modifiers set, you can create Swift Selector instead.

Let’s test it

We are ready to start testing recognizer behavior.

func testSetup() {
XCTAssertNotNil(vc.view.gestureRecognizers?.first(where: { $0 is UIPanGestureRecognizerMock }))
}

func testPanBegan() {
vc.gestureRecognizer?.pan(location: nil, translation: .zero, state: .began)
XCTAssertEqual(vc.view.frame.minY, 0)
}

func testPanDownwards() {
vc.gestureRecognizer?.pan(location: nil, translation: CGPoint(x: 0, y: 200), state: .changed)
XCTAssertEqual(vc.view.frame.minY, 200)
}

func testPanEndedShouldResetDueToLowVelocity() {
vc.gestureRecognizer?.gestureVelocity = .init(x: 0, y: 0)
let offset = vc.view.frame.height * vc.minimumScreenRatioToHide + 1
vc.gestureRecognizer?.pan(location: nil, translation: CGPoint(x: 0, y: offset), state: .ended)
XCTAssertEqual(vc.view.frame.minY, 0)
}

func testPanCancelled() {
vc.gestureRecognizer?.pan(location: nil, translation: CGPoint(x: 0, y: 1), state: .cancelled)
XCTAssertEqual(vc.view.frame.minY, 0)
}

You can see the whole gist here.

Conclusion

I am pretty happy with the result at the end since I can test out our gesture recognizer with confidence. Sure, we only covered pan gesture, but all other gestures can be tested in a similar fashion.

I still have some hopes and wishes that Apple will bring us some good news in the not so distant future about testability of their frameworks. Until then, I’m good with this.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade