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
}
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.
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
.
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:...)
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)
}
}
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)
}
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 ImageLoader
s 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)
}