pointfreeco / swift-snapshot-testing

📸 Delightful Swift snapshot testing.
https://www.pointfree.co/episodes/ep41-a-tour-of-snapshot-testing
MIT License
3.76k stars 578 forks source link

Snapshot of table view with combine receiver happens too late #831

Closed grennis closed 7 months ago

grennis commented 7 months ago

Describe the bug I'm not able to take a snapshot of a UITableView that has cells that receive and update values using a Combine publisher. Using the wait strategy does not help.

To Reproduce I attached the sample project, but the relevant code is pasted below. The snapshot taken shows the switch OFF, but I want the snapshot to happen just a tick later and capture the snapshot after the UI had a chance to update (when the switch is ON). Using the wait strategy doesn't help, because the wait occurs before layout.

class ViewController: UIViewController, UITableViewDataSource {
    let table = UITableView(frame: .init(x: 30, y: 80, width: 200, height: 500))
    let publisher = Just(true).eraseToAnyPublisher()
    var cancellables: Set<AnyCancellable> = []

    override func viewDidLoad() {
        super.viewDidLoad()

        table.dataSource = self

        view.addSubview(table)
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return section == 0 ? 1 : 0
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        let switchView = UISwitch()

        publisher
            .receive(on: DispatchQueue.main)
            .sink { value in switchView.isOn = value }
            .store(in: &cancellables)

        cell.addSubview(switchView)
        return cell
    }
}

Expected behavior Is there some way to delay the snapshot even for just a tick to let the child views update after they are created by the table view?

Environment

SnapshotTester.zip

mbrandonw commented 7 months ago

Hi @grennis, you currently have an uncontrolled dependency in your feature which is what is causing the problems:

.receive(on: DispatchQueue.main)

That is what is causing the thread hop. Ideally you wouldn't need that receive(on:) (does the publisher really do work on a background thread?), but if it is needed then you should control the dependency in the controller:

class ViewController: UIViewController, UITableViewDataSource {
  let mainQueue: AnySchedulerOf<DispatchQueue>
  …
}

And then use that dependency instead of reaching out to DispatchQueue.main:

publisher
  .receive(on: mainQueue)
  .sink { value in switchView.isOn = value }
  .store(in: &cancellables)

And then you can use an ImmediateScheduler in tests to completely avoid the thread hop. In my opinion that is the correct way to fix this problem.

Another way to fix the problem in an ad hoc manner is to force the view to render and then wait a small amount of time to have the thread hop happen:

let vc = ViewController(nibName: nil, bundle: nil)
_ = vc.view.snapshotView(afterScreenUpdates: true)
try await Task.sleep(for: .seconds(0.1))

assertSnapshot(
  of: vc,
  as: .image(size: .init(width: 200, height: 800))
)

The .wait strategy in our library currently has a few limitations that we do want to fix in the long run, but it requires a more substantial change to fully embrace Swift's concurrency tools in the library. That will be a breaking change in the long run, and is a lot of work to attack right now.

So, for the time being I recommend either controlling your dependencies, or using the other technique described above.