Stacktrace

Thoughts and learnings in technology.

Unit Testing iOS UIViewControllers - the Advanced Way

| Comments

“Am I suggesting 100% test coverage? No, I’m not suggesting it. I’m demanding it. Every single line of code that you write should be tested. Period.”
― Uncle Bob

I have already written a post on this topic, but after discussing with several colleagues I have realized that the approach I was talking about might not be scalable. The approach discussed there relied on several hacks like method swizzling which though cool could cause some unexpected behaviour. We were relying on the internal implementation of UIViewControllers which might change anytime when devs at Apple feel like. Plus that approach made us comfortable in writing core business logic inside UIViewControllers instead of creating a ViewModels for them.

The approach I am going to describe here does not necessarily apply only to UIViewControllers. It can in fact be applied to any MVC pattern implementation. The problem with the current iOS implementation is that the UIViewController is very tightly coupled with the UIView. So if you want to test a your logic when a particular UIButton is tapped you cannot do that without inflating the whole view hierarchy.

The idea is to separate the business logic from the UIViewController to something like BusinessLogicController. The BusinessLogicController should have no reference of UIKit, thus making the code modular and platform independent. Technically speaking, this means that we aren’t testing the UIViewControllers, instead we are moving the core business out of it into another layer.

The UIViewController implements the Controllable protocol which basically has a set of methods -

  1. render : Called to paint something on the screen
  2. getValue: Called to get the value for a particular key
  3. showAlert: Optional, called to show an Alert
  4. goToPage: Optional, called to navigate to another page
  5. property eventable: To dispatchEvents to the BusinessLogicController

The first 4 methods are called by the BusinessLogicController as and when required. The UIViewController calls the dispatchEvent when a events like button clicked occur to let the BusinessLogicController know about a particular event.

The BusinessLogicController implements Eventable which has the following:

  1. dispatchEvent: called when a particular event like buttonPressed occurs
  2. property controllable: To call methods on the controllable whenever need be

Lets take the example of Login. Lets say you have to code the following scenario -

  • When the username & password are correctly entered take the user to HomeVC.
  • If the credentials entered are incorrect display the message “Incorrect username or password”.
  • Also, if the user exceeds 5 attempts display an alert saying “Maximum number of retries exceeded”.

With the above criteria the LoginBusinessLogicController would look something like this -

[LoginBusinessLogicController] (LoginBusinessLogicController.swift) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import Foundation

class LoginBusinessLogicController: NSObject,Eventable{

    var controllable: Controllable?

    private var numberOfAttempts = 0
    private let MAXIMUM_NUMBER_OF_ATTEMPTS = 5

    func dispatchEvent(eventName: String!, object: NSObject!) {
        switch(eventName){
            case "loginButtonPressed":
                checkLogin()
            default:
            NSLog("NO SUCH EVENT IMPLEMENTED\(eventName)")
        }
    }


    func checkLogin(){

        if numberOfAttempts < MAXIMUM_NUMBER_OF_ATTEMPTS {
            let username = controllable?.getValue("username") as! String
            let password = controllable?.getValue("password") as! String
            if isCorrect(username, password: password) {
                controllable?.goToPage?("Home")
            }else{
                controllable?.render("message", value:"Wrong username or password" )
                numberOfAttempts++

            }
        }else{
            controllable?.showAlert!("You have exceeded the maximum number of attempts, please try after sometime.")
        }
}

    func isCorrect(username: String,password: String) -> Bool{
        if(username == "batman" && password == "bruce"){
            return true
        }

        return false
    }

}

Notice the it conforms to the Eventable protocol, which declares the method dispatchEvent.

To test such BusinessLogicControllers, you can create a StubbedControllable and use that to send events. I am not going to show the implementation of the StubbedControllable here because it will just bloat the code, if you really want to look at that you can head on my Github example repo. Lets look at how we could implement tests for this -

[LoginBusinessLogicControllerTests] (LoginBusinessLogicControllerTests.swift) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import XCTest

class LoginBusinessLogicControllerTests: XCTestCase {


    var stubbedControllable: StubbedControllable!
    override func setUp() {
        super.setUp()
        let businessController = LoginBusinessLogicController()
        stubbedControllable = StubbedControllable()
        stubbedControllable.eventable = businessController
    }

    func enterUsernameAndPassword(username: String,password: String){
        stubbedControllable.stubbedGetValue.updateValue(username, forKey: "username")
        stubbedControllable.stubbedGetValue.updateValue(password, forKey: "password")
        stubbedControllable.dispatchEvent("loginButtonPressed", eventValue: nil)
    }

    func testLoginFailure(){
        enterUsernameAndPassword("batman", password: "joker")

        let lastMessage = stubbedControllable.lastRender["message"] as! String
        XCTAssertEqual(lastMessage, "Wrong username or password", "Should render error message on incorrect password")
    }

    func testLoginSuccess(){
        enterUsernameAndPassword("batman", password: "bruce")
        XCTAssertEqual(stubbedControllable.lastPage!, "Home", "Should goTo Home when correct password is enterred")
    }

    func testMaximumNumberOfRetries(){
        for i in 0...5{
            enterUsernameAndPassword("batman", password: "joker")
        }

        XCTAssertEqual(stubbedControllable.lastAlert!, "You have exceeded the maximum number of attempts, please try after sometime.", "Should alert if exceeded maximum number of attempts")
    }

}

Note that neither the LoginBusinessLogicController nor its test is importing the UIKit module. The code written here is independent of the iOS UIKit Framework which means the tests can run as a separately from the Simulator, faster in-parallel if need be. As a bonus you can create a new iPad-App or Mac-App with the same BusinessLogicControllers wiring them with new ViewControllers.

I am pretty sure that by now you already have an idea of the LoginViewController’s implementation

[LoginViewController] (LoginViewController.swift) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import UIKit

class LoginViewController: UIViewController,Controllable{

    var eventable: Eventable
    @IBOutlet weak var labelMessage: UILabel!
    @IBOutlet weak var textFieldUsername: UITextField!
    @IBOutlet weak var textFieldPassword: UITextField!
    required init(coder aDecoder: NSCoder) {
        self.eventable = LoginBusinessLogicController()
        super.init(coder: aDecoder)
        self.eventable.controllable = self
    }

    func render(key: String!, value: NSObject!) {
        switch(key){
            case "message":
                self.labelMessage.text = value as? String
            default:
                NSLog("No implementation in render for key:\(key)")
        }
    }

     func getValue(key: String!) -> NSObject {
        switch(key){
        case "username":
            return self.textFieldUsername.text
        case "password":
            return self.textFieldPassword.text
        default:
            return NSNull()
        }
    }

     func goToPage(pageName: String!) {
        switch(pageName){
            case "Home":
                self.performSegueWithIdentifier("HomeIdentifier", sender: self)
            default:
                NSLog("No implementation in goToPage for page:\(pageName)")
        }
    }

    @IBAction func onLoginButtonPressed(sender: AnyObject) {
        self.eventable.dispatchEvent("loginButtonPressed", object: nil)
    }

    func showAlert(alertMessage: String!) {
        UIAlertView(title: "Alert", message: alertMessage, delegate: nil, cancelButtonTitle: "Okay").show()
    }

}

You can clone/download the Example project from -> my Github repo

Comments