Joe Susnick
Joe Susnick

Reputation: 6772

How do you test UIStoryBoard segue is triggered by didSelectRow(at:)

So I have a storyboard with a UITableView. There is a prototype cell with a show segue hooked up to another UIViewController

Example enter image description here

class looks like this:

import UIKit

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
    @IBOutlet weak var tableView: UITableView!

    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 2
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        return tableView.dequeueReusableCell(withIdentifier: "CellOne", for: indexPath)
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        print("Selected the row")
    }
}

Normally I would test it by swizzling prepare for segue to capture the destination ViewController and whatever else I need but calling tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) programmatically doesn't trigger the code in prepare for segue.

Is it possible to test that selecting Cell One triggers the storyboard segue without adding a segue identifier and calling it explicitly from prepareForSegue?

Upvotes: 2

Views: 1318

Answers (3)

Viker
Viker

Reputation: 3243

For just testing a segue you can do:

In your VC:

open let segueToSomewhere = "segueToSomewhere"
open var calledSegue: UIStoryboardSegue!

override open func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    super.prepare(for: segue, sender: sender)
    calledSegue = segue
}

In your Tests:

func testYourSegue() {
    //Given
    let storyboard = UIStoryboard(name: "Your_storyboard", bundle: nil)
    let yourVC = storyboard.instantiateViewController(withIdentifier: "YourVC") as? YourVC

    //When
    yourVC.performSegue(withIdentifier: previousVC.segueToSomewhere, sender: nil)

    //Then
    XCTAssertEqual(previousVC.calledSegue.identifier, yourVC.segueToSomewhere, "The selected segue should be \(previousVC.segueToSomewhere)")
}

Upvotes: 0

Joe Susnick
Joe Susnick

Reputation: 6772

Updated: Long story short there isn't a great way to do this. What makes this difficult is that while most controls we're used to testing have an action and a sender, a touch on a UITableViewCell is a different paradigm.

That said, having a segue identifier is basically a pre-requisite for any strategy.

One way is to get a reference to the cell and call performSegue(withIdentifier:,sender:):

class ViewControllerTests: XCTestCase {

    func testClickingACell() {
        let controller = UIStoryboard(name: "ViewController", bundle: nil).instantiateInitialViewController() as! ViewController
        let cell = controller.tableView.dataSource?.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 0))

        controller.performSegue(withIdentifier: "MySegue", sender: cell)

        XCTAssertNotNil(controller.presentedViewController as? TheDestinationViewController)
    }
}

Another (completely overkill) way would be to have a custom cell where you handle all of your own touch logic. This would be insane but it's a possibility and it would open up more options for testing. I'm not going to show this way because it would be an insane way to do it.

Another way is to use a different architecture that gets rid of UIKit and allows for testing just the performSegue logic. Example:

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    @IBOutlet var myTableView: UITableView!
    var navigator: UIViewController?

    override func viewDidLoad() {
        super.viewDidLoad()

        navigator = self
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        navigator?.performSegue(withIdentifier: "MySegue", sender: nil)
    }

}

This allows you to do something like this in your tests:

class MockNavigator: ViewController {
    var performSegueCalled = false
    var performSegueIdentifier: String?

    override func performSegue(withIdentifier identifier: String, sender: Any?) {
        performSegueCalled = true
        performSegueIdentifier = identifier
    }
}

func testExample() {
    let controller = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as! ViewController
    controller.loadViewIfNeeded()

    // Need to keep a reference to be able to assert against it
    let mockNavigator = MockNavigator()

    controller.navigator = mockNavigator

    controller.tableView(controller.myTableView, didSelectRowAt: IndexPath(row: 0, section: 0))

    XCTAssertTrue(mockNavigator.performSegueCalled)
    XCTAssertEqual(mockNavigator.performSegueIdentifier, "MySegue")
}

Another way to structure your code to avoid UIKit is to use something like a view model-coordinator pattern to create and test a viewModel. Basically you'd tell your coordinator that a cell was selected and the coordinator would update a view model with the desired segue identifier. This way you could test your coordinator object to and be mostly sure that you'll trigger the correct segue if the coordinator is hooked up. A simple manual test would tell you that.

In pseudocode:

struct ViewModel {
    let labelText: String
    let segueIdentifier: String
}

class Coordinator {
    var data = [YourObject]()
    var viewModel = ViewModel(labelText: "", segueIdentifier: "")

    func selectedItem(at row: Int) {
        let item = data[row]

        // Do some logic to figure out which identifier you want
        var segueIdentifer: String
        if item == whatever {
            segueIdentifier = "something"
        }
        viewModel = ViewModel(labelText: item.text, segueIdentifier: segueIdentifier)
    }
}

Probably the best way is a combination of approaches. Use a coordinator with a view model that's tested on its own. Then have a test where you use UIKit to select a cell and make sure that a mocked implementation of that coordinator is used as expected. The smaller units you're testing at a time the easier it will be.

Upvotes: 1

Josh Homann
Josh Homann

Reputation: 16327

If your tableViewCell is the only thing that triggers a segue to the destination you can use is or as:

if let destination = segue.destination as? MyViewController,
   let indexPath = tableView.indexPathForSelectedCell {
     destination.detail = model[indexPath.row]
}

Otherwise if you need to disambiguate you can check the class of the sender with is or as

Upvotes: 1

Related Questions