Testing code often demands faking the “real world”. IoC plays a huge role in here where you flip the dependency from a concrete implementation to an interface.
This technique is very useful when you want to abstract away third-party code (think UserDefaults), but there are instances where this is not enough. That’s the case when working with the camera.
On iOS, to use the camera, one has to use the machinery that comes with AVFoundation.
Although you can use protocols to generalize the real objects, at some point, you are going to stumble upon a dilemma: the simulator doesn’t have a camera, and you can’t instantiate the framework classes making the tests (almost) impossible.
What are you talking about?
Let’s start with a very simple program that captures QR Code (I’m skipping lots of boilerplate but if you are looking for a more thorough example, here you have a great article).
When the detection happens, you can compute from framework-provided values, by implementing the following method from AVCaptureMetadataOutputObjectsDelegate. Say we want to exercise our program in a way that we ensure that the CameraOutputDelegate methods are properly called, given what AVFoundation provides.
1234567891011121314151617181920212223242526272829
finalclassCameraOutputSpy:CameraOutputDelegate{varqrCodeReadCalled:Bool?varqrCodePassed:String?varqrCodeFailedCalled:Bool?varqrCodeErrorPassed:CameraError?funcqrCode(readcode:String){qrCodeReadCalled=trueqrCodePassed=code}funcqrCode(failederror:CameraError){qrCodeFailedCalled=trueqrCodeErrorPassed=error}}letdelegate=CameraOutputSpy()letcamera=Camera(session:AVCaptureSession(),metadataOutput:AVCaptureMetadataOutput(),delegate:delegate)camera.metadataOutput(AVCaptureMetadataOutput(),didOutput:[AVMetadataMachineReadableCodeObject()],// error: 'init()' is unavailablefrom:AVCaptureConnection()//error: 'init()' is unavailable)
Waat!?
The problem here is that all of these classes are concrete, so we can’t abstract them into an interface. Also they are supposed to be created and populated at runtime, hence you can’t init them.
🍸 Swizzle to the rescue
One possible solution for this kind of scenario (since the framework it’s all Objective-C…for now at least), is to use the Objective-C runtime shenanigans to “fill this gap”.
This is only possible because in Objective-C the method to call when a message is sent to an object is resolved at runtime.
I’m not going to lay down the nitty-gritty details about how it works, but the main idea (for the sake of this example) is to, at runtime, copy the implementation of NSObject.init and exchange it with some new fake init we are going to create.
Now, we can create a fake QR code payload in our tests and check if your implementation of AVCaptureMetadataOutputObjectsDelegate does what you expect it to.
123456789101112131415161718192021222324
letdelegate=CameraOutputSpy()letcamera=Camera(session:AVCaptureSession(),metadataOutput:AVCaptureMetadataOutput(),delegate:delegate)camera.metadataOutput(QRMetadataOutputFake(),// plain ol' subclass, not really importantdidOutput:[FakeMachineReadableCodeObject.createFake(code:"interleaved2of5 value",type:.interleaved2of5)!FakeMachineReadableCodeObject.createFake(code:"QR code value",type:.qr)!],from:AVCaptureConnection(inputPorts:[],output:AVCaptureOutput.createFake!// Another swizzle))XCTAssertEqual(delegate.qrCodeReadCalled,true)XCTAssertEqual(delegate.qrCodePassed,"QR code value")XCTAssertNil(delegate.qrCodeFailedCalled)XCTAssertNil(delegate.qrCodeErrorPassed)
As you can see, you can also check if your sut handles just QR code.
You can use this technique along side with other collaborators, like AVCaptureDevice, AVCaptureInput and AVCaptureOutput.