KingFisher + UICollectionView + XCTest with NSURLSession mocking doesn't load images

616 views Asked by At

I'm writing unit tests for some of the views/viewcontrollers in my apps.

My app uses UICollectionView, with the cells containing images loaded using kingfisher. I am using FBSnapshotTestCase to record images of the view and compare them against known good images (and as an aside, using buddybuild's CI to automate running the tests when our developers own pull requests, which is really cool).

I'm using NSURLSession-Mock to insert precanned data (both JSON and images) into the tests.

My problem is that it seems hard to write tests that get the final same end result the users see; I'm frequently finding that (unless the images are already cached - which they aren't as I clear out the cache in test's setup to make sure the tests are running from a clean state!) all the screenshots I take are missing the images, showing only the placeholders.

1

There are 1 answers

0
JosephH On

I've found ways to get this apparently working reliably, but I can't see that I'm 100% happy with my solutions.

Firstly I do this in didFinishLaunchingWithOptions to avoid the application's main UI getting loaded, which caused all sort of confusion when also trying to write tests for the app's home screen:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    BuddyBuildSDK.setup()

    //Apply Itison UI Styles
    ItIsOnUIAppearance.apply()

    #if DEBUG
    if let _ = NSClassFromString("XCTest") {
        // If we're running tests, don't launch the main storyboard as
        // it's confusing if that is running fetching content whilst the
        // tests are also doing so.
        let viewController = UIViewController()
        let label = UILabel()
        label.text = "Running tests..."
        label.frame = viewController.view.frame
        label.textAlignment = .center
        label.textColor = .white
        viewController.view.addSubview(label)
        self.window!.rootViewController = viewController
        return true
    }
    #endif

then in the test, once I've fully setup the UIViewController I need to do things like this:

    func wait(for duration: TimeInterval) {
        let waitExpectation = expectation(description: "Waiting")

        let when = DispatchTime.now() + duration
        DispatchQueue.main.asyncAfter(deadline: when) {
            waitExpectation.fulfill()
        }

        waitForExpectations(timeout: duration+1)
    }

    _ = viewController.view // force view to load
    viewController.viewWillAppear(true)
    viewController.view.layoutIfNeeded() // forces view to layout; necessary to get kingfisher to fetch images

    // This is necessary as otherwise the blocks that Kingfisher
    // dispatches onto the main thread don't run
    RunLoop.main.run(until: Date(timeIntervalSinceNow:0.1));
    viewController.view.layoutIfNeeded() // forces view to layout; necessary to get kingfisher to fetch images

    wait(for: 0.1)
    FBSnapshotVerifyView(viewController.view)

The basic issue if I don't do this is that KingFisher only starts to load the images when the FBSnapshotVerifyView forces the view to be laid out, and (as KingFisher loads images by dispatching blocks to background threads, which then dispatch blocks back to the main thread) this is too late - the blocks sent to the main thread can't run as the main thread is blocked in FBSnapshotVerifyView(). Without the calls to 'layoutIfNeeded()' and RunLoop.main.run() the KingFisher dispatch_async GCD to the main queue doesn't get to run until the /next/ test lets the runloop run, which is far too late.

I'm not too happy with my solution (eg. it's far from clear why I need to layoutIfNeeded() twice and run the runloop twice) so would really appreciate other ideas, but I hope this at least helps other people that run into the same situation as it took a little bit of head scratching to figure out what was happening.