Search code examples
swiftunit-testinguikitxctestuisearchcontroller

UISearchController's isActive property can't be set to true in unit tests


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))
}

}


Solution

  • 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))
    }
    

    }