0

I am writing unit tests for my UITableViewController's data source, which has a UISearchController for filtering results. I need to test the logic in NumberOfRowsInSection so that when the search controller is active, the data source returns the count from the filtered array rather than the normal array.

The function controls this by checking if the search controller's 'isActive' is true/false.

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return searchController.isActive ? filteredResults.count : results.count
}

So my unit test is written like this

func testNumberOfRowsInSection() {
    XCTAssertFalse(searchController.isActive)
    XCTAssertEqual(4, dataSource.tableView(tableView, numberOfRowsInSection: 0))
    
    searchController.isActive = true
    XCTAssertTrue(searchController.isActive) // Fails right after setting it true
    XCTAssertEqual(0, dataSource.tableView(tableView, numberOfRowsInSection: 0)) // Fails
    
    searchController.isActive = false
    XCTAssertFalse(searchController.isActive)
    XCTAssertEqual(4, dataSource.tableView(tableView, numberOfRowsInSection: 0))
}

So the 'isActive' property is not staying true immediately after setting it to true in unit tests. This is strange because during the regular run of the app I can set it to true in the view controller and it stays active.

override func viewDidLoad() {
    super.viewDidLoad()
    
    // ...
    
    searchController.isActive = true // Makes search bar immediately visible
    
    // ...
}

The documentation shows that it's a settable property, so why doesn't setting it to true do anything in unit tests? I've also tried attaching it to a navigation bar but that didn't change anything. If I can't test it like this in unit tests, I'm going to have to mock that functionality, which would be annoying since it should be simple to test this.

Updated example:

class MovieSearchDataSourceTests: XCTestCase {

private var window: UIWindow!
private var controller: UITableViewController!
private var searchController: UISearchController!
private var sut: MovieSearchDataSource!

override func setUp() {
    window = UIWindow()
    controller = UITableViewController()
    searchController = UISearchController()
    
    sut = MovieSearchDataSource(tableView: controller.tableView,
                                searchController: searchController,
                                movies: Array(repeating: Movie.test, count: 4))
    
    window.rootViewController = UINavigationController(rootViewController: controller)
    controller.navigationItem.searchController = searchController
}

override func tearDown() {
    window = nil
    controller = nil
    searchController = nil
    sut = nil
    RunLoop.current.run(until: Date())
}

func testNumberOfRowsInSection() {
    window.addSubview(controller.tableView)
    controller.loadViewIfNeeded()
    
    XCTAssertFalse(searchController.isActive)
    XCTAssertEqual(4, sut.tableView(controller.tableView, numberOfRowsInSection: 0))
    
    searchController.isActive = true
    XCTAssertTrue(searchController.isActive)
    XCTAssertEqual(0, sut.tableView(controller.tableView, numberOfRowsInSection: 0))
    
    searchController.isActive = false
    XCTAssertFalse(searchController.isActive)
    XCTAssertEqual(4, sut.tableView(controller.tableView, numberOfRowsInSection: 0))
}

}

user2129800
  • 131
  • 1
  • 9
  • A test involving a UISearchController or even a table view is not a unit test. You should mock the controller and test only the system under test. Your code uses the term `sut` but fails to apprehend its meaning. The `sut` should not involve Apple’s code! We know what it does. It should be _your_ code. – matt May 07 '21 at 01:49
  • After trying it the other way, I can agree mocking the controller was the simpler solution in the end. What I don't get with is why you say having a table view makes it not a unit test. How am I to test _my_ code logic in `tableView:numberOfRowsInSection` without a table view to call it on? – user2129800 May 08 '21 at 20:08

2 Answers2

1

When I face challenges like this, I reach for two tools:

  1. Executing the run loop
  2. Putting the view controller's view into a real window

I wrote a unit test that didn't work, then tried these tricks. The window trick worked.

func test_canSetWhetherSearchControllerIsActive() throws {
    putInViewHierarchy(sut)

    sut.searchController.isActive = false
    XCTAssertFalse(sut.searchController.isActive)

    sut.searchController.isActive = true
    XCTAssertTrue(sut.searchController.isActive)
}

func putInViewHierarchy(_ vc: UIViewController) {
    let window = UIWindow()
    window.addSubview(vc.view)
}

Note: When you use the window trick, the view controller won't be released at the end of the test unless we also pump the run loop. And this must be done in tear-down, after the test function has concluded:

override func tearDownWithError() throws {
    sut = nil
    executeRunLoop()
    try super.tearDownWithError()
}

func executeRunLoop() {
    RunLoop.current.run(until: Date())
}
Machavity
  • 30,841
  • 27
  • 92
  • 100
Jon Reid
  • 20,545
  • 2
  • 64
  • 95
  • I see what you're saying, but this hasn't worked for me. I separated my data source from view controllers to avoid having to setup a whole controller stack in testing. However, even after doing that by putting my data source into dummy controllers and adding the tableview as a window subview as you said, isActive would not stick. I've added more code to my description and if you could show the rest of your example code that would help. – user2129800 May 07 '21 at 00:20
0

After not getting UIWindow to work, my solution was mocking the UISearchController with a protocol.

// Protocol in main project
protocol Activatable: AnyObject {
    var isActive: Bool { get set }
}

extension UISearchController: Activatable { }

final class MovieSearchDataSource: NSObject {
    private var movies: [Movie] = []
    private var filteredMovies: [Movie] = []

    private let tableView: UITableView
    private let searchController: Activatable

    init(tableView: UITableView, searchController: Activatable, movies: [Movie] = []) {
        self.tableView = tableView
        self.searchController = searchController
        self.movies = movies
        super.init()
    }
}

extension MovieSearchDataSource: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return searchController.isActive ? filteredMovies.count : movies.count
    }
}

// Unit test file
class MockSearchController: Activatable {
    var isActive = false
}

class MovieSearchDataSourceTests: XCTestCase {

private var tableView: UITableView!
private var searchController: MockSearchController!
private var sut: MovieSearchDataSource!

override func setUp() {
    tableView = UITableView()
    searchController = MockSearchController()
    
    sut = MovieSearchDataSource(tableView: tableView,
                                searchController: searchController,
                                movies: Array(repeating: Movie.test, count: 4))
    
}

override func tearDown() {
    tableView = nil
    searchController = nil
    sut = nil
}

func testNumberOfRowsInSection() {
    XCTAssertFalse(searchController.isActive)
    XCTAssertEqual(4, sut.tableView(tableView, numberOfRowsInSection: 0))
    
    searchController.isActive = true
    XCTAssertTrue(searchController.isActive)
    XCTAssertEqual(0, sut.tableView(tableView, numberOfRowsInSection: 0))
    
    searchController.isActive = false
    XCTAssertFalse(searchController.isActive)
    XCTAssertEqual(4, sut.tableView(tableView, numberOfRowsInSection: 0))
}

}

user2129800
  • 131
  • 1
  • 9