-1

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
}
  • You can look into `URLProtocol`. Also this [SwiftLee blog](https://www.avanderlee.com/swift/mocking-alamofire-urlsession-requests/) on this very topic should help get your started. Unfortunately that post relies on the blogger's company's `Mocker` framework (which he provides a GitHub link to), and you have to think if you want to add another external dependency to your project, but mocking network can be a deep rabbit hole, and they've already done a lot of the work. – Chip Jarred Apr 29 '23 at 01:13
  • That could be a solution, but then I'm testing the dependency as a whole. I just want to know if the function was called and simple mock the response. Testing the framework isn't my goal, that is already done by Alamofire. – Bob Voorneveld Apr 29 '23 at 05:23
  • Injecting the image loader rather than directly referencing the singleton will allow you to mock it. eg. extend `imageView` again so you have a `loadImage(from:)` method, with a default method of the AF one. Then in production you can call `loadImage(from: url)` and in testing you can inject a dummy method. – flanker Apr 29 '23 at 10:35
  • @BobVoorneveld, of course, it's not your concern to test Alamofire itself. What I'm suggesting is in your tests that use `setImage(with:)` to user a `MockURLProtocol` that you register in AlamoFire, where your `MockURLProtocol` returns your test data or error for the URL you specify in order to trigger whatever code your testing. The singleton issue pointed out by @flanker is a real issue one. But I have a simpler idea that might work for you. I'll write it up in an answer. – Chip Jarred Apr 29 '23 at 13:36

2 Answers2

2

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)
    }
Chip Jarred
  • 2,600
  • 7
  • 12
  • A far better approach than my suggestion above, although heavier for the initial implementation. I Considered a protocol approach but was too lazy to look up the AF signature! Nicely documented too. – flanker Apr 29 '23 at 23:07
  • @flanker, thank you. Yeah reusable mock types take some set-up, and there's the refactoring in the app code to make it testable without hacks. I think that's most of the work. All of this is easier if the code is written to be testable from the start, but how often to do we really start from scratch or inherit code that was written that way? – Chip Jarred Apr 29 '23 at 23:21
  • @flanker, also this answer is basically taking the same idea as your suggestion in comments, and fleshes out to particular implementation. – Chip Jarred Apr 29 '23 at 23:31
1

The answer from @Chip is very complete and makes it very testable. To make it just a bit simpler, I've created an subclass of the UIImageView, that calls the Alamofire extension. On the places where I was using the setImage function, I replaced the UIImageView with the RemoteImageView.

class RemoteImageView: UIImageView {
    public func setImage(withURL url: URL,
                         cacheKey: String? = nil,
                         placeholderImage: UIImage? = nil,
                         serializer: ImageResponseSerializer? = nil,
                         filter: ImageFilter? = nil,
                         progress: ImageDownloader.ProgressHandler? = nil,
                         progressQueue: DispatchQueue = DispatchQueue.main,
                         imageTransition: UIImageView.ImageTransition = .noTransition,
                         runImageTransitionIfCached: Bool = false,
                         completion: ((AFIDataResponse<UIImage>) -> Void)? = nil) {
        af.setImage(withURL: url, cacheKey: cacheKey, placeholderImage: placeholderImage, serializer: serializer, filter: filter, progress: progress, progressQueue: progressQueue, imageTransition: imageTransition, runImageTransitionIfCached: runImageTransitionIfCached, completion: completion)
    }
}

In my tests, I overwrite the RemoteImageView with RemoteImageViewMock and intercept the calls.

class RemoteImageViewMock: RemoteImageView {
    
    var setImageWasCalledWith: [SetImageParameters] = []
    override func setImage(withURL url: URL, cacheKey: String? = nil, placeholderImage: UIImage? = nil, serializer: ImageResponseSerializer? = nil, filter: ImageFilter? = nil, progress: ImageDownloader.ProgressHandler? = nil, progressQueue: DispatchQueue = DispatchQueue.main, imageTransition: UIImageView.ImageTransition = .noTransition, runImageTransitionIfCached: Bool = false, completion: ((AFIDataResponse<UIImage>) -> Void)? = nil) {
        setImageWasCalledWith.append(.init(url: url, cacheKey: cacheKey, placeholderImage: placeholderImage, serializer: serializer, filter: filter, progress: progress, progressQueue: progressQueue, imageTransition: imageTransition, runImageTransitionIfCached: runImageTransitionIfCached, completion: completion))
    }
    
    struct SetImageParameters {
        let url: URL
        let cacheKey: String?
        let placeholderImage: UIImage?
        let serializer: ImageResponseSerializer?
        let filter: ImageFilter?
        let progress: ImageDownloader.ProgressHandler?
        let progressQueue: DispatchQueue
        let imageTransition: UIImageView.ImageTransition
        let runImageTransitionIfCached: Bool
        let completion: ((AFIDataResponse<UIImage>) -> Void)?
    }
    
    static func successResponse(image: UIImage = UIImage()) -> AFIDataResponse<UIImage> {
        AFIDataResponse<UIImage>.init(request: nil, response: nil, data: nil, metrics: nil, serializationDuration: .zero, result: .success(image))
    }
    
    static var failureResponse: AFIDataResponse<UIImage> {
        AFIDataResponse<UIImage>.init(request: nil, response: nil, data: nil, metrics: nil, serializationDuration: .zero, result: .failure(AFIError.imageSerializationFailed))
    }
}

This way I can view the calls to setImage and their parameters. And call the completionblock with the correct response to unit test the things that happen in the completionblock.

func test_foo() {
    let url = URL(string: "https://fake.url")!
    let imageView = RemoteImageViewMock()
    imageView.isHidden = true
    sut.imageView = imageView


    // This happens probably in some function on the View/ViewController   
    sut.loadRemoteImage(url: url)

    /*
    func loadRemoteImage(urk: URL) {
        imageView.setImage(withURL: url) { [weak self] response in
             guard let self else { return }
             // Check result and act
             if case .success = response.result {
                 self.imageView.isHidden = false
             }
        }
    }
    */

    XCTAssertEqual(imageView.setImageWasCalledWith.count, 1)
    XCTAssertEqual(imageView.setImageWasCalledWith[0].url, url)

    imageView.setImageWasCalledWith[0].completion!(RemoteImageViewMock.successResponse())

   // Check what happens in completionblock.
   XCTAssertFalse(imageView.isHidden)