-
Notifications
You must be signed in to change notification settings - Fork 7
Chapter 02: Network Image Source
In the previous version of our tutorial, we serialized images from binary data using the Apple UIImage
class. You might ask yourself: “Self, why must my networking library have to depend on a UI framework?” That’s a great question. Your networking library shouldn’t have to depend on a UI framework. Depending on UIKit for image serialization had (at least) one big advantage: we needed only one line of code. Our tests were simple, and our test-double class was simple.
Can we do better? Suppose we know this networking library would only deploy to iOS apps. Is it so wrong to depend on UIKit? Maybe not. What about macOS? What if we want to deploy this networking library to an app that expects NSImage
? If we serialize our binary data using AppKit, we can’t deploy that same implementation to iOS. We could try using the Core Image framework to serialize images. That framework deploys to iOS, macOS, and tvOS. Unfortunately, Core Image does not (as of this writing), deploy to watchOS.
It looks like the “highest-level” solution available that will deploy to all four Apple platforms (macOS, iOS, watchOS, and tvOS) is Image I/O. We will serialize our binary data to CGImage
. This is going to be challenging. Image I/O originally shipped as a Core Foundation (C) framework. To accomplish our goal, we will need to make use of two “standalone” functions: functions defined outside a type. In our previous chapter, we saw how we could test a type that depends on an existing Apple type by injecting a test-double. We can’t do that here; there is no type to double. As Jon Reid said: “A standalone function, living on its own, is a locked-down dependency.”[^1]
There are multiple ways we can approach this problem. We’ll take a look at some possibilities and discuss some strengths (and weaknesses) of each approach. Let’s begin with the two Image I/O functions we will need.
// ImageIO.CGImageSource
public func CGImageSourceCreateWithData(
_ data: CFData,
_ options: CFDictionary?
) -> CGImageSource?
public func CGImageSourceCreateImageAtIndex(
_ isrc: CGImageSource,
_ index: Int,
_ options: CFDictionary?
) -> CGImage?
That doesn’t look so tough. We need one function to create a CGImageSource
from binary data, and one more function to create a CGImage
from a CGImageSource
. Could we just call these two functions directly in our image serialization type without injecting a new test-double? Maybe. What would our tests look like? Since we’ve already locked down our dependency on the Apple functions, we can test our new type with real image data. We could import some binary data in our test bundle that we know represents image data. In our tests, we can pass that binary data to our production type, serialize that same data (a second time) inside our tests, and then test that the two images are equal. Since we want this implementation to handle errors, we also want to make sure we have “good” binary data and “bad” binary data. We also want to test this code with a variety of image types (JPEG, PNG, TIFF…). Each of those types should be tested with (at least) one success and (at least) one error. The Image I/O functions also accept options
parameters. We would want to test that the options are being respected. The second function also includes an index
parameter (for image sources that support multiple images). We would want to test that the index is being respected. These tests might all make us confident this production type behaves correctly for any image data, but it also sounds like a lot of work.
We would like to continue the pattern we saw in our last chapter: we don’t want to care so much about about testing Apple functions; we want to assume the Apple functions behave as documented. We care more about testing that our new production types make correct use of the Apple functions. We want to spy on the parameters going in and stub the values coming out. We can then feel confident our new production type will correctly serialize any image data, not just the image data we define inside our tests.
What would make our life easier? Let’s look at our strategy from the last chapter. What would we do if Apple defined these two functions on a type?
open class CGImageSource {
open class func createWithData(
_ data: CFData,
_ options: CFDictionary?
) -> CGImageSource?
open class func createImageAtIndex(
_ isrc: CGImageSource,
_ index: Int,
_ options: CFDictionary?
) -> CGImage?
}
If that was the case, we would just have to test-double the CGImageSource
class and spy and stub the two methods we need. What if we define our own type (and wrap the two functions from Image I/O)?
class NetworkImageSource {
class func createWithData(
_ data: CFData,
_ options: CFDictionary?
) -> CGImageSource? {
return CGImageSourceCreateWithData(
data,
options
)
}
class func createImageAtIndex(
_ isrc: CGImageSource,
_ index: Int,
_ options: CFDictionary?
) -> CGImage? {
return CGImageSourceCreateImageAtIndex(
isrc,
index,
options
)
}
}
This doesn’t do much to solve the same problem we had before. We can test-double (and inject) the NetworkImageSource
class, but we still have these two locked-down dependencies on the Apple functions; we would have to test NetworkImageSource
correctly serializes images, which would imply we would have to test the Apple functions. This just moves around our original problem to a different place. Suppose we didn’t try and wrap the functions. Suppose we just returned the functions. What would that look like?
class NetworkImageSource {
class func createImageSource() -> (CFData, CFDictionary?) -> CGImageSource? {
return CGImageSourceCreateWithData
}
class func createImage() -> (CGImageSource, Int, CFDictionary?) -> CGImage? {
return CGImageSourceCreateImageAtIndex
}
}
This almost works. We can create a test-double type that implements these two methods to return test-double closures we can spy and stub. That would work for testing the type that depends on NetworkImageSource
. How would we test the NetworkImageSource
type itself? We would want to test that the closure returned is identical to the Apple functions from Image I/O.
XCTAssertIdentical(
NetworkImageSource.createImageSource(),
CGImageSourceCreateWithData
)
XCTAssertIdentical(
NetworkImageSource.createImage(),
CGImageSourceCreateImageAtIndex
)
Unfortunately, Swift does not (as of this writing) have a natural way to test for “function equality” as you might be used to from Objective-C (or C). In Objective-C, it’s easy to write this test; it’s just testing two pointers for equality.
XCTAssert([NetworkImageSource createImageSource] == CGImageSourceCreateWithData);
XCTAssert([NetworkImageSource createImage] == CGImageSourceCreateImageAtIndex);
Suppose we were to implement NetworkImageSource
in Objective-C (and return pointers to our two Image I/O functions). Let’s see what that interface might look like.
@interface NetworkImageSource : NSObject
+ (CGImageSourceRef _Nullable (*_Nonnull)(CFDataRef _Nonnull, CFDictionaryRef _Nullable))createImageSource;
+ (CGImageRef _Nullable (*_Nonnull)(CGImageSourceRef _Nonnull, size_t, CFDictionaryRef _Nullable))createImage;
@end
Since we would plan to make use of the Objective-C class in our Swift types, let’s see what the Swift interface would look like.
open class NetworkImageSource : NSObject {
open class func createImageSource() -> @convention(c) (CFData, CFDictionary?) -> Unmanaged<CGImageSource>?
open class func createImage() -> @convention(c) (CGImageSource, Int, CFDictionary?) -> Unmanaged<CGImage>?
}
While it is possible to bridge C-style function pointers directly into Swift, you can see that we have some artifacts (like the Unmanaged
return values) that look like we’re adding extra work for the engineers making use of this new type. How could we write an interface in Objective-C that bridges more cleanly into Swift?
@interface NetworkImageSource : NSObject
+ (CGImageSourceRef _Nullable)createImageSourceWithData:(CFDataRef _Nonnull)data
options:(CFDictionaryRef _Nullable)options CF_RETURNS_RETAINED;
+ (CGImageRef _Nullable)createImageWithImageSource:(CGImageSourceRef _Nonnull)imageSource
atIndex:(size_t)index
options:(CFDictionaryRef _Nullable)options CF_RETURNS_RETAINED;
@end
Let’s see what that looks like from Swift.
open class NetworkImageSource : NSObject {
open class func createImageSource(
with data: CFData,
options: CFDictionary?
) -> CGImageSource?
open class func createImage(
with imageSource: CGImageSource,
at index: Int,
options: CFDictionary?
) -> CGImage?
}
If we combine our previous approach (expose function pointers on the Objective-C type), with this new approach (wrap those function pointers with proper Objective-C methods), we have a new type that will bridge cleanly to Swift and also offer us the ability to spy and stub for easy testing. This approach works not only for Image I/O, but also for just about any Core Foundation (or third-party) framework that implements stand-alone C functions you need in your production types.
Add a new Objective-C file named NetworkImageSourceTests.m
. Add this file to your AlbumsTests
target. We will choose not to create a bridging header (this will not be necessary for now because we will not make use of the NetworkImageSourceTestCase
type in our Swift code). Let’s write our first test.
// NetworkImageSourceTests.m
#import <XCTest/XCTest.h>
#import "NetworkImageSource.h"
@interface NetworkImageSourceTestCase : XCTestCase
@end
@implementation NetworkImageSourceTestCase
@end
@implementation NetworkImageSourceTestCase (CreateImageSource)
- (void)testCreateImageSource {
XCTAssert([NetworkImageSource createImageSource] == CGImageSourceCreateWithData);
}
@end
Command-U. Our compiler fails. We haven’t created the new NetworkImageSource
type (or the new NetworkImageSource
header file). Add a new Cocoa Touch class named NetworkImageSource
. This new class will inherit from NSObject
. This will be an Objective-C class. Add this file to to your Albums
target and your AlbumsTests
target. We will choose to add a bridging header (this will be necessary because we will make use of the NetworkImageSource
type in our Swift code). Xcode should configure the Albums
target correctly once we choose to add the bridging header. We will also update our AlbumsTests
target with the path to the same header (Albums-Bridging-Header.h
). The “Importing Objective-C into Swift” article from Apple documents where to find this build setting.[^2]
Let’s add a new interface with the method we are testing for.
// NetworkImageSource.h
#import <Foundation/Foundation.h>
#import <ImageIO/ImageIO.h>
@interface NetworkImageSource : NSObject
@end
@interface NetworkImageSource (CreateImageSource)
+ (CGImageSourceRef _Nullable (*_Nonnull)(CFDataRef _Nonnull, CFDictionaryRef _Nullable))createImageSource;
@end
Command-U. Tests fail. We have not implemented our new method. Let’s go and fix that.
// NetworkImageSource.m
#import "NetworkImageSource.h"
@implementation NetworkImageSource
@end
@implementation NetworkImageSource (CreateImageSource)
+ (CGImageSourceRef _Nullable (*_Nonnull)(CFDataRef _Nonnull, CFDictionaryRef _Nullable))createImageSource {
return CGImageSourceCreateWithData;
}
@end
Command-U. Tests pass. Let’s add a test for the second function we need to serialize image data.
// NetworkImageSourceTests.m
@implementation NetworkImageSourceTestCase (CreateImage)
- (void)testCreateImage {
XCTAssert([NetworkImageSource createImage] == CGImageSourceCreateImageAtIndex);
}
@end
Command-U. Our compiler fails. Let’s add this new method to our interface.
// NetworkImageSource.h
@interface NetworkImageSource (CreateImage)
+ (CGImageRef _Nullable (*_Nonnull)(CGImageSourceRef _Nonnull, size_t, CFDictionaryRef _Nullable))createImage;
@end
Command-U. Tests fail. Let’s implement this new method.
// NetworkImageSource.m
@implementation NetworkImageSource (CreateImage)
+ (CGImageRef _Nullable (*_Nonnull)(CGImageSourceRef _Nonnull, size_t, CFDictionaryRef _Nullable))createImage {
return CGImageSourceCreateImageAtIndex;
}
@end
Command-U. Tests pass. This is our first step. We have our NetworkImageSource
type correctly returning the Image I/O functions. Our next step will be to wrap these function calls in Objective-C methods. Let’s start with defining a test-double version of our first function. We’ll be writing a C function, but our approach will be similar to what we did for Swift; we’ll spy on the parameters coming in and stub the values going out.
// NetworkImageSourceTests.m
static CGImageSourceRef _Nullable NetworkImageSourceTestDoubleCreateImageSource(CFDataRef _Nonnull, CFDictionaryRef _Nullable) CF_RETURNS_RETAINED;
static id NetworkImageSourceTestDoubleCreateImageSourceParameterData = 0;
static id NetworkImageSourceTestDoubleCreateImageSourceParameterOptions = 0;
static id NetworkImageSourceTestDoubleCreateImageSourceReturnImageSource = 0;
CGImageSourceRef NetworkImageSourceTestDoubleCreateImageSource(CFDataRef data, CFDictionaryRef options) {
NetworkImageSourceTestDoubleCreateImageSourceParameterData = (__bridge id)data;
NetworkImageSourceTestDoubleCreateImageSourceParameterOptions = (__bridge id)options;
return (__bridge_retained CGImageSourceRef)NetworkImageSourceTestDoubleCreateImageSourceReturnImageSource;
}
In order to save ourselves the trouble of manually managing Core Foundation retains (and releases), let us save ourselves some work by using the id
type for spying and stubbing (and letting ARC manage our memory). We do have some work to do to properly cast (and bridge) the Core Foundation types to Objective-C, but this is not so difficult.
If you are familiar with the previous version of our tutorial, you saw that our primary method to inject dependencies for testing was subclassing our production type and overriding to return test-doubles. For our Swift types, we’ve been using generic programming to statically inject these dependencies at compile-time. Now that we’re back in Objective-C, let’s subclass-and-override our production type and inject our test-double with polymorphism.
// NetworkImageSourceTests.m
@interface NetworkImageSourceTestDouble : NetworkImageSource
@end
@implementation NetworkImageSourceTestDouble
@end
@implementation NetworkImageSourceTestDouble (CreateImageSource)
+ (CGImageSourceRef _Nullable (*_Nonnull)(CFDataRef _Nonnull, CFDictionaryRef _Nullable))createImageSource {
return NetworkImageSourceTestDoubleCreateImageSource;
}
@end
We added some new static
variables. Remember that our best-practice is to clean those variables up with a tearDown
implementation. Let’s do that.
// NetworkImageSourceTests.m
@implementation NetworkImageSourceTestCase (TearDown)
- (void)tearDown {
NetworkImageSourceTestDoubleCreateImageSourceParameterData = 0;
NetworkImageSourceTestDoubleCreateImageSourceParameterOptions = 0;
NetworkImageSourceTestDoubleCreateImageSourceReturnImageSource = 0;
}
@end
Let’s write the new test to prove our Objective-C wrapper is correctly making use of our Image I/O function. Since we’re testing our test-double type, we’ll test by inspecting the values we spied and stubbed.
// NetworkImageSourceTests.m
@implementation NetworkImageSourceTestCase (CreateImageSourceTestDouble)
- (void)testCreateImageSourceTestDouble {
NetworkImageSourceTestDoubleCreateImageSourceReturnImageSource = [[NSObject alloc] init];
id data = [[NSObject alloc] init];
id options = [[NSObject alloc] init];
id imageSource = (__bridge_transfer id)[NetworkImageSourceTestDouble createImageSourceWithData:(__bridge CFDataRef)data
options:(__bridge CFDictionaryRef)options];
XCTAssert(NetworkImageSourceTestDoubleCreateImageSourceParameterData == data);
XCTAssert(NetworkImageSourceTestDoubleCreateImageSourceParameterOptions == options);
XCTAssert(imageSource == NetworkImageSourceTestDoubleCreateImageSourceReturnImageSource);
}
@end
Command-U. Our compiler fails. Let’s add the missing interface.
// NetworkImageSource.h
@interface NetworkImageSource (CreateImageSource)
+ (CGImageSourceRef _Nullable (*_Nonnull)(CFDataRef _Nonnull, CFDictionaryRef _Nullable))createImageSource;
+ (CGImageSourceRef _Nullable)createImageSourceWithData:(CFDataRef _Nonnull)data
options:(CFDictionaryRef _Nullable)options CF_RETURNS_RETAINED;
@end
Command-U. Our tests fail. Let’s add the missing implementation.
// NetworkImageSource.m
@implementation NetworkImageSource (CreateImageSource)
+ (CGImageSourceRef _Nullable (*_Nonnull)(CFDataRef _Nonnull, CFDictionaryRef _Nullable))createImageSource {
return CGImageSourceCreateWithData;
}
+ (CGImageSourceRef)createImageSourceWithData:(CFDataRef)data
options:(CFDictionaryRef)options {
return [self createImageSource](data, options);
}
@end
Command-U. Tests pass. Using polymorphism, we pass our parameters to the correct function at runtime. For our production type, this will be the CGImageSourceCreateWithData
function from Image I/O (we wrote a test to verify this behavior). For our test-double type, this will be the NetworkImageSourceTestDoubleCreateImageSource
function (we then spy the parameters going in and stub the values coming out). When we add these two tests together, we have confidence our production type will correctly make use of the Image I/O function. Let’s try the second function we need for image serialization and see one more example of how this strategy works.
// NetworkImageSourceTests.m
static CGImageRef _Nullable NetworkImageSourceTestDoubleCreateImage(CGImageSourceRef _Nonnull, size_t, CFDictionaryRef _Nullable) CF_RETURNS_RETAINED;
static id NetworkImageSourceTestDoubleCreateImageParameterImageSource = 0;
static size_t NetworkImageSourceTestDoubleCreateImageParameterIndex = 0;
static id NetworkImageSourceTestDoubleCreateImageParameterOptions = 0;
static id NetworkImageSourceTestDoubleCreateImageReturnImage = 0;
CGImageRef NetworkImageSourceTestDoubleCreateImage(CGImageSourceRef imageSource, size_t index, CFDictionaryRef options) {
NetworkImageSourceTestDoubleCreateImageParameterImageSource = (__bridge id)imageSource;
NetworkImageSourceTestDoubleCreateImageParameterIndex = index;
NetworkImageSourceTestDoubleCreateImageParameterOptions = (__bridge id)options;
return (__bridge_retained CGImageRef)NetworkImageSourceTestDoubleCreateImageReturnImage;
}
@implementation NetworkImageSourceTestDouble (CreateImage)
+ (CGImageRef _Nullable (*_Nonnull)(CGImageSourceRef _Nonnull, size_t, CFDictionaryRef _Nullable))createImage {
return NetworkImageSourceTestDoubleCreateImage;
}
@end
@implementation NetworkImageSourceTestCase (TearDown)
- (void)tearDown {
NetworkImageSourceTestDoubleCreateImageSourceParameterData = 0;
NetworkImageSourceTestDoubleCreateImageSourceParameterOptions = 0;
NetworkImageSourceTestDoubleCreateImageSourceReturnImageSource = 0;
NetworkImageSourceTestDoubleCreateImageParameterImageSource = 0;
NetworkImageSourceTestDoubleCreateImageParameterIndex = 0;
NetworkImageSourceTestDoubleCreateImageParameterOptions = 0;
NetworkImageSourceTestDoubleCreateImageReturnImage = 0;
}
@end
@implementation NetworkImageSourceTestCase (CreateImageTestDouble)
- (void)testCreateImageTestDouble {
NetworkImageSourceTestDoubleCreateImageReturnImage = [[NSObject alloc] init];
id imageSource = [[NSObject alloc] init];
size_t index = 1;
id options = [[NSObject alloc] init];
id image = (__bridge_transfer id)[NetworkImageSourceTestDouble createImageWithImageSource:(__bridge CGImageSourceRef)imageSource
atIndex:index
options:(__bridge CFDictionaryRef)options];
XCTAssert(NetworkImageSourceTestDoubleCreateImageParameterImageSource == imageSource);
XCTAssert(NetworkImageSourceTestDoubleCreateImageParameterIndex == index);
XCTAssert(NetworkImageSourceTestDoubleCreateImageParameterOptions == options);
XCTAssert(image == NetworkImageSourceTestDoubleCreateImageReturnImage);
}
@end
Command-U. Our compiler fails. Let’s add the interface for this new method.
// NetworkImageSource.h
@interface NetworkImageSource (CreateImage)
+ (CGImageRef _Nullable (*_Nonnull)(CGImageSourceRef _Nonnull, size_t, CFDictionaryRef _Nullable))createImage;
+ (CGImageRef _Nullable)createImageWithImageSource:(CGImageSourceRef _Nonnull)imageSource
atIndex:(size_t)index
options:(CFDictionaryRef _Nullable)options CF_RETURNS_RETAINED;
@end
Command-U. Our tests fail. Let’s implement the missing method like we did last time.
// NetworkImageSource.m
@implementation NetworkImageSource (CreateImage)
+ (CGImageRef _Nullable (*_Nonnull)(CGImageSourceRef _Nonnull, size_t, CFDictionaryRef _Nullable))createImage {
return CGImageSourceCreateImageAtIndex;
}
+ (CGImageRef)createImageWithImageSource:(CGImageSourceRef)imageSource
atIndex:(size_t)index
options:(CFDictionaryRef)options {
return [self createImage](imageSource, index, options);
}
@end
Command-U. Our tests pass. We now have a new Objective-C type which wraps the stand-alone Image I/O functions we need. We can inject this Objective-C type in our Swift implementations; we can also inject a test-double to avoid having to implicitly test the behavior of the functions Apple wrote for us.
If you inspect the Swift interface generated from our Objective-C class, you might notice that we are still publishing our function pointers to Swift. While we do need to test these two methods (in Objective-C), we don’t need to make use of them in production Swift (our intention is engineers will call the Objective-C wrappers). As a matter of personal style, you might wish to “hide” these two methods from Swift. One approach would be to create a “private” header that is only imported for your test code.
Before we move on, let’s make sure our bridging header imports our new interface. This will make our Objective-C class available to our Swift types.
// Albums-Bridging-Header.h
#import "NetworkImageSource.h"
It’s not every day you’re going to need to make use of stand-alone functions from Core Foundation; if you do, you’ve seen how we can still use dependency-injection and test-doubles to write safe (and tested) code we feel confident will behave correctly. We did write an Objective-C class, but this will easily bridge to our Swift project. Let’s move back to Swift and see how we can use NetworkImageSource
to build a new type to serialize image data.
[^1]: Reid, Jon (2017-11-28). How to Mock Standalone Functions in Swift.
[^2]: Apple Inc. Importing Objective-C into Swift.