KVO Quick Spec unit tests
Swift
If you’re using KVO, how can you test the changes on your classes/structs?
Introduction
Quick tests can still use the expectations you’ve used via XCTest. So let’s start there.
Let’s start with a very simple class with one dynamic property – remember, to use KVO it needs to be marked dynamic and the class needs to subclass NSObject.
1 2 3 |
public class TimeEvent : NSObject { public dynamic var startBeat:Double = 0 } |
Now here’s a QuickSpec to test it.
I do the usual add/remove observer chacha along with the required callback (observeValue), set up an expectation then fulfill it in the callback. If there is a timeout, the test fails.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
class TimeEventTests: QuickSpec { var observerContext = 0 var expectation:XCTestExpectation? override func spec() { describe("TimeEvent") { fcontext("observe") { let event = TimeEvent(1.0, 1.0) expectation = expectation(description: "Example") beforeEach { event.addObserver(self, forKeyPath: #keyPath(TimeEvent.startBeat), options: [.new], context: &self.observerContext) } afterEach { event.removeObserver(self, forKeyPath: #keyPath(TimeEvent.startBeat)) } it("changes") { event.startBeat = 4.1 expect(event.startBeat).toEventually(equal(4.1)) self.waitForExpectations(timeout: 5) { (error) -> Void in if let error = error { print("error waiting: \(error)") fail("Timed out") } } } } } } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { print("\(#function)") print("keyPath \(keyPath)") guard context == &observerContext else { super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) print("oops wrong context") return } guard let keyPath = keyPath else { print("oops keyPath is nil") return } switch keyPath { case #keyPath(TimeEvent.startBeat): if let e = object as? TimeEvent { print("start beat changed to \(e.startBeat)") } if let newValue = change?[.newKey] as? Double { print("start beat newValue \(newValue)") } expectation?.fulfill() default: super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) } } |
keyValueObservingExpectation
That works. But what a pain. Great if you’re paid by LOC.
Luckily, there is an expectation – keyValueObservingExpectation – just for KVO. You don’t need to add/remove observers and “manually” fulfill expectations.
Here is an example.
1 2 3 4 5 6 7 8 9 10 11 |
keyValueObservingExpectation(for: event, keyPath: #keyPath(TimeEvent.startBeat)) { (object:Any, change:[AnyHashable : Any]) in if let newValue = change[NSKeyValueChangeKey.newKey] as? Double { print(" newValue \(newValue)") if newValue == 4.1 { return true } } return false } |
Or even better, since you’re just testing a value, you can use this version:
1 2 3 |
_ = keyValueObservingExpectation(for: event, keyPath: #keyPath(TimeEvent.startBeat), expectedValue: 4.1) |
Here is the much simpler full example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
class TimeEventTests: QuickSpec { override func spec() { describe("TimeEvent") { fcontext("observe") { let event = TimeEvent(1.0, 1.0) _ = keyValueObservingExpectation(for: event, keyPath: #keyPath(TimeEvent.startBeat), expectedValue: 4.1) it("changes") { event.startBeat = 4.1 self.waitForExpectations(timeout: 5) { (error) -> Void in if let error = error { print("error waiting: \(error)") } fail("Timed out") } expect(event.startBeat).to(equal(4.1)) } } } } } |
Groovy, huh?
Summary
Using keyValueObservingExpectation will simplify your testing for KVO.