everything you (n)ever wanted to know about testing view controllers
TRANSCRIPT
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!