Skip to content

Commit f530df6

Browse files
authored
Merge pull request #38 from nshintio/post/testing-camera-simulator
Add testing the camera on the simulator post
2 parents 8691193 + 72a20ba commit f530df6

File tree

1 file changed

+210
-0
lines changed

1 file changed

+210
-0
lines changed
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
---
2+
layout: post
3+
author: rafa
4+
title: "Testing the camera on the simulator"
5+
date: 2019-04-02 22:36:49 +0200
6+
comments: false
7+
categories:
8+
---
9+
10+
Testing code often demands faking the "real world". [IoC](https://en.wikipedia.org/wiki/Inversion_of_control) plays a huge role in here where you flip the dependency from a concrete implementation to an interface.
11+
12+
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.
13+
14+
On iOS, to use the camera, one has to use the machinery that comes with [`AVFoundation`](https://developer.apple.com/documentation/avfoundation/cameras_and_media_capture).
15+
16+
<!--more-->
17+
18+
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.
19+
20+
#### What are you talking about?
21+
22+
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](https://www.hackingwithswift.com/example-code/media/how-to-scan-a-qr-code) you have a great article.
23+
24+
```swift
25+
enum CameraError: Error {
26+
case invalidMetadata
27+
}
28+
29+
protocol CameraOutputDelegate: class {
30+
func qrCode(read code: String)
31+
func qrCode(failed error: CameraError)
32+
}
33+
34+
final class Camera: NSObject {
35+
private let session: AVCaptureSession
36+
private let metadataOutput: AVCaptureMetadataOutput
37+
private weak var delegate: CameraOutputDelegate?
38+
39+
public init(
40+
session: AVCaptureSession = AVCaptureSession(),
41+
metadataOutput: AVCaptureMetadataOutput = AVCaptureMetadataOutput(),
42+
delegate: CameraOutputDelegate?
43+
) {
44+
self.session = session
45+
self.metadataOutput = metadataOutput
46+
47+
super.init()
48+
49+
self.metadataOutput.setMetadataObjectsDelegate(self, queue: .main)
50+
}
51+
}
52+
53+
extension Camera: AVCaptureMetadataOutputObjectsDelegate {
54+
public func metadataOutput(
55+
_ output: AVCaptureMetadataOutput,
56+
didOutput metadataObjects: [AVMetadataObject],
57+
from connection: AVCaptureConnection
58+
) {
59+
guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
60+
let code = object.stringValue, object.type == .qr else {
61+
delegate?.qrCode(failed: .invalidMetadata)
62+
return
63+
}
64+
65+
delegate?.qrCode(read: code)
66+
}
67+
}
68+
```
69+
70+
When the detection happens, you can compute from framework-provided values, by implementing the following method from [`AVCaptureMetadataOutputObjectsDelegate`](https://developer.apple.com/documentation/avfoundation/avcapturemetadataoutputobjectsdelegate/1389481-metadataoutput) and say we want to exercise our program in a way that we ensure that the `CameraOutputDelegate` methods are properly called, given what
71+
72+
The problem here is that all of these classes are provided by the framework and you can't `init` them.
73+
74+
```swift
75+
final class CameraOutputSpy: CameraOutputDelegate {
76+
var qrCodeReadCalled: Bool?
77+
var qrCodePassed: String?
78+
var qrCodeFailedCalled: Bool?
79+
var qrCodeErrorPassed: CameraError?
80+
81+
func qrCode(read code: String) {
82+
qrCodeReadCalled = true
83+
qrCodePassed = code
84+
}
85+
func qrCode(failed error: CameraError) {
86+
qrCodeFailedCalled = true
87+
qrCodeErrorPassed = error
88+
}
89+
}
90+
91+
let delegate = CameraOutputSpy()
92+
93+
let camera = Camera(
94+
session: AVCaptureSession(),
95+
metadataOutput: AVCaptureMetadataOutput(),
96+
delegate: delegate
97+
)
98+
99+
camera.metadataOutput(
100+
AVCaptureMetadataOutput(),
101+
didOutput: [AVMetadataMachineReadableCodeObject()], // error: 'init()' is unavailable
102+
from: AVCaptureConnection() //error: 'init()' is unavailable
103+
)
104+
```
105+
106+
#### 🍸 `Swizzle` to the rescue
107+
108+
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](https://nshipster.com/method-swizzling/) to "fill this gap".
109+
110+
This is only possible because in `Objective-C` the method to call when a message is sent to an object is resolved at runtime.
111+
112+
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.
113+
114+
```swift
115+
struct Swizzler {
116+
private let `class`: AnyClass
117+
118+
init(_ class: AnyClass) {
119+
self.`class` = `class`
120+
}
121+
122+
func injectNSObjectInit(into selector: Selector) {
123+
let original = [
124+
class_getInstanceMethod(`class`, selector)
125+
].compactMap { $0 }
126+
127+
let swizzled = [
128+
class_getInstanceMethod(`class`, #selector(NSObject.init))
129+
].compactMap { $0 }
130+
131+
zip(original, swizzled)
132+
.forEach {
133+
method_setImplementation($0.0, method_getImplementation($0.1))
134+
}
135+
}
136+
}
137+
```
138+
139+
With that in hand, now we can:
140+
141+
1. Create a `private init` that will hold the implemetation of `NSObject.init`.
142+
2. Create our "designated initializer", capturing the parameters our test needs.
143+
3. Do the swizzle dance.
144+
145+
```swift
146+
final class FakeMachineReadableCodeObject: AVMetadataMachineReadableCodeObject {
147+
var code: String?
148+
var dataType: AVMetadataObject.ObjectType = .qr
149+
150+
override var stringValue: String? {
151+
return code
152+
}
153+
154+
override var type: AVMetadataObject.ObjectType {
155+
return dataType
156+
}
157+
158+
// 1
159+
@objc private convenience init(fake: String) { fatalError() }
160+
161+
private class func fake(fake: String, type: AVMetadataObject.ObjectType = .qr) -> FakeMachineReadableCodeObject? {
162+
let m = FakeMachineReadableCodeObject(fake: fake)
163+
m.code = fake
164+
m.dataType = type
165+
166+
return m
167+
}
168+
169+
// 2
170+
static func createFake(code: String, type: AVMetadataObject.ObjectType) -> FakeMachineReadableCodeObject? {
171+
// 3
172+
Swizzler(self).injectNSObjectInit(into: #selector(FakeMachineReadableCodeObject.init(fake:)))
173+
return fake(fake: code, type: type)
174+
}
175+
}
176+
```
177+
178+
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.
179+
180+
```swift
181+
let delegate = CameraOutputSpy()
182+
183+
let camera = Camera(
184+
session: AVCaptureSession(),
185+
metadataOutput: AVCaptureMetadataOutput(),
186+
delegate: delegate
187+
)
188+
189+
camera.metadataOutput(
190+
QRMetadataOutputFake(), // plain ol' subclass, not really important
191+
didOutput: [
192+
FakeMachineReadableCodeObject.createFake(code: "interleaved2of5 value", type: . interleaved2of5)!
193+
FakeMachineReadableCodeObject.createFake(code: "QR code value", type: .qr)!
194+
],
195+
from: AVCaptureConnection(
196+
inputPorts: [],
197+
output: AVCaptureOutput.createFake! // Another swizzle
198+
)
199+
)
200+
201+
XCTAssertEqual(delegate.qrCodeReadCalled, true)
202+
XCTAssertEqual(delegate.qrCodePassed, "QR code value")
203+
XCTAssertNil(delegate.qrCodeFailedCalled)
204+
XCTAssertNil(delegate.qrCodeErrorPassed)
205+
206+
```
207+
208+
As you can see, you can also check if your [`sut`](https://en.wikipedia.org/wiki/System_under_test) handles just QR code.
209+
210+
P.S: You can use this technique along side with other collaborators, like `AVCaptureDevice`, `AVCaptureInput` and `AVCaptureOutput`.

0 commit comments

Comments
 (0)