Whilst working at Venmo, Sam Soffes developed the Swift framework DVR based on VCR for Ruby. Used for network testing in Swift, Sam explains the basics of how DVR makes fake NSURLSession
requests, the benefits that brings, and wraps up with takeaways from the process of designing & making DVR.
I am Sam and I worked at Venmo, but am currently working as a freelancer. I am going to present network testing with DVR: how to use it, why it is useful, and some interesting concepts about the design and how I made DVR, unrelated to testing.
DVR is inspired by VCR for Ruby. DVR records networking and plays it back in tests. As a result, your tests run quickly, and you are not relying on networking or service changing.
Using DVR: Boilerplate (01:16)
This is all boilerplate: no DVR, just regular XCTest. We are going to say we have an APIClient
, and we call and getTimeline
s. I will assert that that was true. We will have an expectation, wait for the expectation, and fulfill the expectation, since it is async. This is a normal XCTest.
// Example XCTest
func testTimeline() {
let expectation = expectationWithDescription(“Networking”)
// Your network client
let client = APIClient()
client.getTimeline { success in
XCTAssertTrue(success)
expectation.fulfill()
}
waitForExpectationsWithTimeout(1, handler: nil)
}
Personally, I like XCTests (although sometimes it is a bit cumbersome). In testing, I want as little magic as possible. Adding DVR to that test is really straightforward. We are going to make a new session in the DVR module, and give it the name timeline
. We will pass that into our client, and API client will inject it.
// DVR Session
let dvr = Session(cassetteName: “timeline”)
// Pass into initializer
let client = APIClient(session: dir)
This is a really common pattern in most of my networking: I have a property for the session. As for the initializer, you can inject it if you want, or it defaults to the shared session.
class APIClient {
let session: NSURLSession
init(session: NSURLSession = NSURLSession.sharedSession()) {
self.session = session
}
}
Bear in mind: if you just do an NSURLSession
, you get terrible crashes with no information. You cannot do that, so use .sharedSession()
. This is the method in our API client - a normal URL session, dataTaskWithRequest
, which has nothing specific to DVR.
func getTimeline(completion: Bool -> Void) {
let request = NSURLRequest(URL: timelineURL)
session.dataTaskWithRequest(request) { _, _, _
completion(true)
}.resume()
}
DVR Internals (03:41)
Session
is just a class of NSURLSession
. There is a name for the cassette. You inject a backingSession
, dataTaskWithRequest
, and there are other variations. That is going to return our own SessionDataTask
, instead of the regular one.
class Session: NSURLSession {
let cassetteName: String
let backingSession: NSURLSession
init(cassetteName: StringbackingSession: NSURLSession = NSURLSession.sharedSession()) {
self.cassetteName = cassetteName
self.backingSession = backingSession
super.init()
}
override func dataTaskWithRequest(request: NSURLRequest) -> NSURLSessionDataTask {
return SessionDataTask(session: self, request: request)
}
}
Call resume()
& then magic! (04:44)
The sessionDataTask
is a subclass of the regular SessionDataTask
. It will call resume
and we are going to override resume
. Then we will need to check whether we have the cassette and the recorded version on disc; if not, we will need to record it and save it.
class SessionDataTask: NSURLSessionDataTask {
override func resume() {
// Playback cassette on disk or record
}
}
Cassettes are stored as JSON (05:07)
Here is an example of a cassette: it stores an array of interactions, which is a pair of request & response. You can serialize a request or response to JSON and initialize it back. You can take a response from the server, get that call back from a normal URL session, and save it here. If you bring it back, as a consumer, you would have no idea it came from DVR. It is just like a regular response.
{
“name” : “example”,
“interactions” : [
{
“recorded_at” : 1434688721.440751,
“request” : {
“method” : “GET”,
“url” : “http:\/\/example.com”
}
“response” : {
“body” : “hello”,
“status” : 200,
“url” : “http:\/\/example.com\/“,
“headers” : {
“Cache-Control” : “max-age=604800”,
“Content-Type” : “text\/plain”,
“Last-Modified” : “Fri, 09 Aug 2013 23:54:35 GMT”,
“Content-Length” : “5”
}
}
}
]
}
Why DVR over mocks? (06:15)
“Mocks are inherently fragile: you have to compile, you have to couple you testing code with the implementation of your test”. — Dave Abrahams, Protocol-Oriented Programming in Swift
There are many frameworks for fake network testing. Mocks are inherently fragile, and this is not great. In testing and networking, all you care about is whether you give your request, and whether you get back a response. What happens in between does not matter to almost everyone. You just want a response. Injecting a fake session to get back responses that are real responses is a reasonable thing to do.
The way you do this with DVR is that you inject a session into your client. The first time it runs, it will fatal error, and give you a big log: “We have recorded it, here is the path to it, and please add this file that we have downloaded to your project.” Unfortunately, there is no way for a running app to talk to the Xcode project, for obvious reasons. But then once you add that file to your project, you are good to go—if anyone has ideas on how to get around that, pull requests are welcome.
Injecting a session that is fake, or less than real, has other benefits. It does not have to be DVR. If you wanted to disable networking, you can make a DisabledSession, and assert whenever you make a task. I have this library, Mixpanel, for Swift, and there is a disabled flag. In development it is not hammering their API. You can use this for anything, not just testing and networking.
class DisabledSession: NSURLSession {
override func dataTaskWithRequest(request: NSURLRequest,
completionHandler: (NSData?, NSURLResponse?, NSError?) -> Void) -> NSURLSessionDataTask? {
XCTFail(“Networking disabled”)
return nil
}
}
I am designing things to be dependency injected and have very specific responsibilities, and I think that is my biggest take away from this. I am really excited about this, versus the crazy old ways of swizzling. This seems to be a good approach. Feel free to check out the code for DVR [on GitHub]](github.com/venmo/DVR).
Q&A (09:43)
Q: Do you have a mechanism for ignoring mutating fields (e.g. time stamps), and UUID session IDs? We had to hack it in VCR, which you mentioned. Sam: We had a solution, but I do not think it is built in to DVR. I think it would be easy to add something to ignore headers.
Q: What about fields in the JSON where you have time stamps? You had some the example, like the time you recorded. Sam: That is just metadata for the cassette. VCR is ridiculously full featured. This is more bare bones, because we did not need all those features.
Receive news and updates from Realm straight to your inbox