UISearchController 的 isActive 属性 无法在单元测试中设置为 true

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

我正在为我的 UITableViewController 的数据源编写单元测试,它有一个用于过滤结果的 UISearchController。我需要测试 NumberOfRowsInSection 中的逻辑,以便当搜索控制器处于活动状态时,数据源 returns 来自过滤数组而不是普通数组的计数。

该函数通过检查搜索控制器的 'isActive' 是否为 true/false 来控制这一点。

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

所以我的单元测试是这样写的

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

因此 'isActive' 属性 在单元测试中将其设置为 true 后不会立即保持为 true。这很奇怪,因为在应用程序的常规 运行 期间,我可以在视图控制器中将其设置为 true 并保持活动状态。

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

文档显示它是可设置的 属性,那么为什么不将其设置为 true 在单元测试中执行任何操作?我也试过将它附加到导航栏,但这并没有改变任何东西。如果我不能在单元测试中像这样测试它,我将不得不模拟该功能,这会很烦人,因为测试它应该很简单。

更新示例:

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

}

当我面临这样的挑战时,我会使用两个工具:

  1. 正在执行 运行 循环
  2. 将视图控制器的视图变成真实的window

我写了一个没有用的单元测试,然后尝试了这些技巧。 window 技巧奏效了。

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

注意:当你使用 window 技巧时,视图控制器不会在测试结束时被释放,除非我们也使用 运行 循环。这必须在测试功能结束后在拆卸中完成:

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

func executeRunLoop() {
    RunLoop.current.run(until: Date())
}

在没有让 UIWindow 工作之后,我的解决方案是使用协议模拟 UISearchController。

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

}