Everything You (N)ever Wanted to Know about Testing View Controllers

Preview:

Citation preview

everything you (n)ever wanted to know about testing view

controllers

unit testing is great…

…for your model layer

- Model layer is easy to test- Most examples of testing/TDD show model layer tests

can you even test view controllers?

- But view controllers—ugh!- Can you even test them?

- Yes!- This is unfortunate misconception among many iOS devs

- Yes!- This is unfortunate misconception among many iOS devs

1. App module 2. Manual lifecycle events 3. Storyboard accessibility

- Just a few simple tricks

- We’ll be testing my stealth mode app, BananaApp, made up of one view controller, BananaViewController

- We’ll be testing my stealth mode app, BananaApp, made up of one view controller, BananaViewController

1. App module 2. Manual lifecycle events 3. Storyboard accessibility

- Public class- App defines a Swift module

- Defines module = YES -> classes in app exported as module- “Product module name” is the name of what you import in test file

// BananaViewController.swift

public class BananaViewController: UIViewController { // ... }

- To test classes, they must be exported in module, so they must be public- This goes for all classes, not just view controllers

// BananaViewControllerTests.swift

import BananaApp import XCTest

class BananaViewControllerTests: XCTestCase {

var viewController: BananaViewController!

// ... }

- We import our defined module- Since class is public, we can reference it from imported module

// BananaViewControllerTests.swift

import BananaApp import XCTest

class BananaViewControllerTests: XCTestCase {

var viewController: BananaViewController!

// ... }

- We import our defined module- Since class is public, we can reference it from imported module

// BananaViewControllerTests.swift

import BananaApp import XCTest

class BananaViewControllerTests: XCTestCase {

var viewController: BananaViewController!

// ... }

- We import our defined module- Since class is public, we can reference it from imported module

1. App module 2. Manual lifecycle events 3. Storyboard accessibility

- When app runs, view controller lifecycle methods are triggered automatically.- In tests, you’ll need to trigger methods like viewDidLoad: yourself.

// BananaViewController.swift

import UIKit

public class BananaViewController: UIViewController { // ... public override func viewDidLoad() { super.viewDidLoad() updateButtons() }

private func updateButtons() { moreButton.enabled = bananaCount < 10 lessButton.enabled = bananaCount > 0 } }

- BananaViewController overrides viewDidLoad to update its buttons- Let’s test this behavior

// BananaViewController.swift

import UIKit

public class BananaViewController: UIViewController { // ... public override func viewDidLoad() { super.viewDidLoad() updateButtons() }

private func updateButtons() { moreButton.enabled = bananaCount < 10 lessButton.enabled = bananaCount > 0 } }

- BananaViewController overrides viewDidLoad to update its buttons- Let’s test this behavior

// BananaViewController.swift

import UIKit

public class BananaViewController: UIViewController { // ... public override func viewDidLoad() { super.viewDidLoad() updateButtons() }

private func updateButtons() { moreButton.enabled = bananaCount < 10 lessButton.enabled = bananaCount > 0 } }

- BananaViewController overrides viewDidLoad to update its buttons- Let’s test this behavior

// BananaViewController.swift

import UIKit

public class BananaViewController: UIViewController { // ... public override func viewDidLoad() { super.viewDidLoad() updateButtons() }

private func updateButtons() { moreButton.enabled = bananaCount < 10 lessButton.enabled = bananaCount > 0 } }

- BananaViewController overrides viewDidLoad to update its buttons- Let’s test this behavior

// BananaViewControllerTests.swift

class BananaViewControllerTests: XCTestCase {

var viewController: BananaViewController!

override func setUp() { viewController = BananaViewController() let _ = viewController.view }

func testLessButtonIsDisabled() { XCTAssertFalse(viewController.lessButton.enabled) } }

- Here we test the less button is disabled- But when we run the tests, they fail- Need to access view to trigger viewDidLoad

// BananaViewControllerTests.swift

class BananaViewControllerTests: XCTestCase {

var viewController: BananaViewController!

override func setUp() { viewController = BananaViewController() let _ = viewController.view }

func testLessButtonIsDisabled() { XCTAssertFalse(viewController.lessButton.enabled) } }

- Here we test the less button is disabled- But when we run the tests, they fail- Need to access view to trigger viewDidLoad

// BananaViewControllerTests.swift

class BananaViewControllerTests: XCTestCase {

var viewController: BananaViewController!

override func setUp() { viewController = BananaViewController() let _ = viewController.view }

func testLessButtonIsDisabled() { XCTAssertFalse(viewController.lessButton.enabled) } }

- Here we test the less button is disabled- But when we run the tests, they fail- Need to access view to trigger viewDidLoad

// BananaViewControllerTests.swift

class BananaViewControllerTests: XCTestCase {

var viewController: BananaViewController!

override func setUp() { viewController = BananaViewController() let _ = viewController.view }

func testLessButtonIsDisabled() { XCTAssertFalse(viewController.lessButton.enabled) } }

XCTAssertFalse failed

- Here we test the less button is disabled- But when we run the tests, they fail- Need to access view to trigger viewDidLoad

// BananaViewControllerTests.swift

class BananaViewControllerTests: XCTestCase {

var viewController: BananaViewController!

override func setUp() { viewController = BananaViewController() let _ = viewController.view }

func testLessButtonIsDisabled() { XCTAssertFalse(viewController.lessButton.enabled) } }

- Here we test the less button is disabled- But when we run the tests, they fail- Need to access view to trigger viewDidLoad

1. App module 2. Manual lifecycle events 3. Storyboard accessibility

- If your view controller’s interface is buried within a storyboard file, you’ll need to provide a way to access it

- You’ll need to give your view controller an ID

// BananaViewControllerTests.swift

let storyboard = UIStoryboard(name: "Main", bundle: nil) viewController = storyboard.instantiateViewControllerWithIdentifier( "BananaViewControllerID") as BananaViewController

- You can instantiate any view controller with an ID in your tests- If it’s the initial view controller in your storyboard, you don’t need an identifier

// BananaViewControllerTests.swift

let storyboard = UIStoryboard(name: "Main", bundle: nil) viewController = storyboard.instantiateViewControllerWithIdentifier( "BananaViewControllerID") as BananaViewController

viewController = storyboard.instantiateInitialViewController() as BananaViewController

- You can instantiate any view controller with an ID in your tests- If it’s the initial view controller in your storyboard, you don’t need an identifier

public class BananaViewController: UIViewController {

@IBOutlet public weak var countLabel: UILabel! @IBOutlet public weak var moreButton: UIButton! @IBOutlet public weak var lessButton: UIButton!

// ... }

- And remember, XIB and storyboard files set IBOutlet properties during -viewDidLoad

// BananaViewControllerTests.swift

let storyboard = UIStoryboard(name: "Main", bundle: nil) viewController = storyboard.instantiateViewControllerWithIdentifier( "BananaViewControllerID") as BananaViewController

let _ = viewController.view XCTAssertFalse(viewController.lessButton.enabled)

- So if you access an IBOutlet prior to triggering -viewDidLoad, you’ll hit an assert

Thread 1: EXC_BAD_INSTRUCTION

// BananaViewControllerTests.swift

let storyboard = UIStoryboard(name: "Main", bundle: nil) viewController = storyboard.instantiateViewControllerWithIdentifier( "BananaViewControllerID") as BananaViewController

let _ = viewController.view XCTAssertFalse(viewController.lessButton.enabled)

- So if you access an IBOutlet prior to triggering -viewDidLoad, you’ll hit an assert

// BananaViewControllerTests.swift

let storyboard = UIStoryboard(name: "Main", bundle: nil) viewController = storyboard.instantiateViewControllerWithIdentifier( "BananaViewControllerID") as BananaViewController

let _ = viewController.view XCTAssertFalse(viewController.lessButton.enabled)

- So if you access an IBOutlet prior to triggering -viewDidLoad, you’ll hit an assert

1. App module 2. Manual lifecycle events 3. Storyboard accessibility

- So remember

func testLessButtonAfterAddingBananaIsEnabled() { viewController.moreButton.sendActionsForControlEvents( UIControlEvents.TouchUpInside) XCTAssert(viewController.lessButton.enabled) }

- With these in mind, we can write tests like this- Tap the button to add banana, then less button (to remove banana) is enabled

func testLessButtonAfterAddingBananaIsEnabled() { viewController.moreButton.sendActionsForControlEvents( UIControlEvents.TouchUpInside) XCTAssert(viewController.lessButton.enabled) }

- With these in mind, we can write tests like this- Tap the button to add banana, then less button (to remove banana) is enabled

func testLessButtonAfterAddingBananaIsEnabled() { viewController.moreButton.sendActionsForControlEvents( UIControlEvents.TouchUpInside) XCTAssert(viewController.lessButton.enabled) }

- With these in mind, we can write tests like this- Tap the button to add banana, then less button (to remove banana) is enabled

- But in the end, it’s not as easy as testing regular objects- So push as much logic into model layer as possible

- But in the end, it’s not as easy as testing regular objects- So push as much logic into model layer as possible

thin view controllers!

- Remember, thin view controllers!

Recommended