iosswiftalamofirealamofireimage

How can I mock the Alamofire Extension on UIImageView?


Alamofire has an extension on UIImageView that makes loading an image very easy. But, for unit testing my code, I would like to mock the result of the response, so that I can test success and failure. How can I mock the .af.setImage(withURL:) function?

Example code:

imageView.af.setImage(withURL: url) { response in
    // do stuff on success or failure
}

Solution

  • I think the cleanest way to write tests for code that depends on external frameworks, such as Alamofire, or for that matter, that use I/O, such as network access, is to centralize direct usage of them in a bottleneck that you control, so you can mock that bottleneck. To do that, you will need to refactor your source base to use the bottleneck instead of calling Alamofire directly.

    The code that follows does not depend on a 3rd party mocking library, though you could certainly use one if that suits your needs.

    Create an API bottleneck

    What you want to mock is AlamofireImage's setImage(withURL:completion) method, so that's the thing you need to create a bottleneck for. You could create an API for loading images into a view from a URL. Since you basically just need to either call Alamofire's API or some mock, you could use an inheritance-based approach without getting into trouble, but I prefer a protocol approach:

    protocol ImageLoader
    {
        func loadImage(
            into view: UIImageView,
            from: URL,
            imageTransition: UIImageView.ImageTransition,
            completion: ((AFIDataResponse<UIImage>) -> Void)?)
    }
    
    struct AFImageLoader: ImageLoader
    {
        func loadImage(
            into view: UIImageView,
            from url: URL,
            imageTransition: UIImageView.ImageTransition,
            completion: ((AFIDataResponse<UIImage>) -> Void)?)
        {
            view.af.setImage(
                withURL: url,
                imageTransition: imageTransition,
                completion: completion
            )
        }
    }
    

    It may seem at this point that loadImage(into:from:imageTransition:closure) could be a static method, but if you do that, mocking will be a pain, because you'll want to associate an image or failure with a specific URL. With a static method, you'd either have store the associations globally (in a static dictionary, for example), which would pollute the mock values across tests, especially if they are executed in parallel, or you'd need to write specific mock types for each test. Ideally you want as many tests as possible to share a single mock type that can be easily configured appropriately for each test, which means it will need to carry some instance data, which loadImage will need to access. So it really does need to be an instance method.

    That gives you your bottleneck that just calls through to Alamofire, but you don't want your app code to have to explicitly say that it wants to use AFImageLoader. Instead, we'll put using it in an extension on UIImageView, so we can allow it to default to AFImageLoader if a specific ImageLoader isn't specified.

    extension UIImageView
    {
        func loadImage(
            fromURL url: URL,
            using imageLoader: ImageLoader,
            imageTransition: ImageTransition = .noTransition,
            completion: ((AFIDataResponse<UIImage>) -> Void)? = nil)
        {
            imageLoader.loadImage(
                into: self,
                from: url,
                imageTransition: imageTransition,
                completion: completion
            )
        }
        
        func loadImage(
            fromURL url: URL,
            imageTransition: ImageTransition = .noTransition,
            completion: ((AFIDataResponse<UIImage>) -> Void)? = nil)
        {
            loadImage(
                fromURL: url,
                using: AFImageLoader(),
                imageTransition: imageTransition,
                completion: completion
            )
        }
    }
    

    I should mention that Alamofire's actual setImage(withURL:...) method actually takes a lot of parameters that have default values. You should probably include all of those, but for now I'm only including imageTransition and of course completion.

    Refactor your code base

    Now you need to replace all the calls to af.setImage(withURL:...) in your code base with .loadImage(fromURL:...)

    Note since you can now call myView.loadImage(fromURL: url) { response in ... } very similar to using Alamofire's API, it's a fairly simple search and replace, though you should probably inspect each one instead of doing "Replace All" just in case there is some weird case you have to handle differently.

    I chose to name the new method loadImage rather than setImage because in my mind things called set shouldn't be doing any network access to set something local. load to me implies a more heavyweight operation. That's a matter of personal preference. It also makes code that is still directly using Alamofire stand out more visually as you refactor to call loadImage(fromURL:...)

    Create a mock type for your bottleneck

    Now let's mock it, so you can use it in tests.

    struct MockImageLoader: ImageLoader
    {
        var responses: [URL: (UIImage?, AFIDataResponse<UIImage>)] = [:]
        
        func loadImage(
            into view: UIImageView,
            from url: URL,
            imageTransition: UIImageView.ImageTransition,
            completion: ((AFIDataResponse<UIImage>) -> Void)?)
        {
            DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1))
            {
                let (image, response) = imageAndResponse(for: url)
                
                if let image = image {
                    view.af.run(imageTransition, with: image)
                }
                completion?(response)
            }
        }
        
        func imageAndResponse(for url: URL) -> (UIImage?, AFIDataResponse<UIImage>)
        {
            guard let response = responses[url] else {
                fatalError("No mocked response for \(url)")
            }
            
            return response
        }
        
        mutating func add(image: UIImage, for url: URL)
        {
            let request = makeGetRequest(for: url)
            let response = AFIDataResponse<UIImage>(
                request: request,
                response: nil,
                data: nil,
                metrics: nil,
                serializationDuration: 0.0,
                result: .success(image)
            )
            
            responses[url] = (image, response)
        }
        
        mutating func add(failure: AFIError, for url: URL)
        {
            let request = makeGetRequest(for: url)
            let response = AFIDataResponse<UIImage>(
                request: request,
                response: nil,
                data: nil,
                metrics: nil,
                serializationDuration: 0.0,
                result: .failure(failure)
            )
            responses[url] = (nil, response)
        }
        
        func makeGetRequest(for url: URL) -> URLRequest {
            return try! URLRequest(url: url, method: .get, headers: nil)
        }
    }
    

    Use the mock in unit tests

    At this point you'd want to use it to write tests, but you'll discover that you're not finished refactoring your app. To see what I mean consider this function:

    func foo(completion: @escaping (UIImage) -> Void)
    {
        someImageView.loadImage(fromURL: someURL)
        { response in
            switch response.result
            {
                case .success(let image):
                    completion(image)
                case .failure(let error):
                    someStandardErrorHandler(error)
            }
       }
    }
    

    And suppose you have this test:

        func test_foo() throws
        {
            let expectation = expectation(description: "HandlerCalled")
            
            var x = false
            foo
            { image in
                x = true
                expectation.fulfill()
            }
            
            waitForExpectations(timeout: 0.001)
            XCTAssertTrue(x)
        }
    

    Refactor some more, but incrementally this time

    You need to introduce a MockImageLoader into your test, but as written foo doesn't know about it. We need to "inject" it, which means we need to use some mechanism to get foo to use an image loader we specify. If foo is a struct or class, we could just make it a property, but since I've written foo as a free function, we'll pass it in as a parameter, which would work with methods too. So foo becomes:

    func foo(
        using imageLoader: ImageLoader = AFImageLoader(),
        completion: @escaping (UIImage) -> Void)
    {
        someImageView.loadImage(fromURL: someURL, using: imageLoader)
        { response in
            switch response.result
            {
                case .success(let image):
                    completion(image)
                case .failure(let error):
                    someStandardErrorHandler(error)
            }
       }
    }
    

    What this means is that as you write tests that use MockImageLoader, you'll increasingly need to somehow pass around ImageLoaders in your app's code. For the most part you can do that incrementally though.

    OK, so now let's create a Mock in our test:

        func test_foo() throws
        {
            let expectation = expectation(description: "HandlerCalled")
            
            // You might want to use some real image here
            let anImage = UIImage()
            var imageLoader = MockImageLoader()
            imageLoader.add(image: anImage, for: someURL)
            var x = false
            
            foo(using: imageLoader)
            { image in
                x = true
                expectation.fulfill()
            }
            
            waitForExpectations(timeout: 0.001)
            XCTAssertTrue(x)
        }
    

    You could also test for failure:

        func test_foo_failed() throws
        {
            let expectation = expectation(description: "HandlerCalled")
            
            var imageLoader = MockImageLoader()
            imageLoader.add(
                failure: AFIError.imageSerializationFailed,
                for: someURL
            )
            var x = false
            
            foo(using: imageLoader)
            { image in
                x = true
                expectation.fulfill()
            }
            
            waitForExpectations(timeout: 0.001)
            XCTAssertFalse(x)
        }