Over the past week, I have been refactoring our networking layer here at WeVat. The unit test coverage for the error scenarios that may arise from the network was pretty low, so I thought that it was a good opportunity to cast a bigger net and assure that our network refactor didn’t introduce any pesky 🐞, 🕷 or 🐜s.
Below I will briefly explain the unit test suite we are using, and will then move onto how we are dynamically testing multiple error scenarios without writing lots of code.
OHHTTPStubs
I have used OHTTPStubs on a previous project, and it seems to be the industry standard as a network stubbing library for iOS. It provides a suite of tools to help stub out any network calls that are made using NSURLConnection, NSURLSession
or Alamofire
, so works great for our needs here.
I have also created a small wrapper around the library, to provide all of the stubbing code to us, in exactly the way we need it.
The method below gives us the ability to stub the network with a 200 and a defined JSON response.
static func stubNetwork(forService service: Service.Type) {
stub(condition: isHost(baseUrl)) { \_ in
guard let path = OHPathForFileInBundle(stubPath(forService: service), Bundle.main) else {
preconditionFailure(“Stub not found!”)
}
return OHHTTPStubsResponse(
fileAtPath: path,
statusCode: 200,
headers: \[ “Content-Type”: “application/json” \]
)
}
}
I’ll explain what is happening here
OHHTTPStubs.removeAllStubs()
.Nimble
We chose to cut down on using some of the more verbose syntax and coding structure that is provided by XCTest, and went with using the Nimble framework.
This allowed us to go from setting up expectations with
let expect = expectation(description: “Get Receipts”)...expect.fulfill()...waitForExpectations(timeout: 2) { (error) inXCTAssertTrue(asyncReceipts?.count > 0)}
to simply:
expect(asyncReceipts!.count > 0).toEventually(beTrue())
👍🏻👍🏻👍🏻
Testing 🙂 path
This should be proven in a single test case. Just set up the success stub, call the service and assert the async result has been fulfilled.
var asyncReceipts: [Receipts]?StubHelper.stubNetwork(forService: GetReceiptsService.self)
GetReceiptsService.getReceipts(forUser: UUID().uuidString) { (result) in
switch result {
case .success(let receipts):
asyncReceipts = receipts
default: break
}
}
expect(asyncReceipts).toEventuallyNot(beNil())expect(asyncReceipts!.count > 0).toEventually(beTrue())
Testing 🙁 paths
This is where the unit tests really start to prove their worth.
We can similarly write a test case for the network call, which will set up the stub for the error code we want to test, call the service, and assert that the errors are being set asynchronously.
var asyncError: Error?stubNetworkWithNoConnection()
GetReceiptsService.getReceipts(forUser: “”) { (result) in
switch result {
case .failure(let error):
asyncError = error
default: break
}
}
expect(asyncError).toEventuallyNot(beNil())expect(asyncError as! HTTPError).toEventually(equal(HTTPError.noConnection))
That’s cool. But I hear you, this seems like it can be optimised. We don’t want to repeat this code every time we want to test for a different error.
So thinking about our requirements here, we need to test for every likely error response from the server, on each of our services. Also we need to assert that the errors coming back are what we expect.
ServiceTestable protocol
protocol ServiceTestable {
func testFailures()
func setupNetworkFailureTest(withStub stub: (() -> Void), andErrorToAssert errorAssertion: HTTPError)
}
Here I have defined a protocol, which will provide a harness for how we test our service classes. By ensuring our services conform to this protocol, we can call the testFailures()
which will call out to the setupNetworkFailureTest()
function, for each scenario we need. We then inject the stub and the error we want to assert into the test.
Now where is the implementation you ask? Hold up a second and I will tell you!
Enter protocol extensions
By extending our ServiceTestable
protocol, we can give some default behaviour to our test cases.
extension ServiceTestable where Self: WeVatTests {
func testFailuresWithStatusCodes() {let failures = [
(stub: StubHelper.stubNetworkWithNotFound, error: HTTPError.notFound),
(stub: StubHelper.stubNetworkWithForbidden, error: HTTPError.invalidCredentials),
(stub: StubHelper.stubNetworkTimeout, error: HTTPError.timeout),
(stub: StubHelper.stubNetworkNoConnection, error: HTTPError.noConnection),
(stub: StubHelper.stubNetworkWithBadRequest, error: HTTPError.invalidRequest),
(stub: StubHelper.stubNetworkWithServerError, error: HTTPError.serverError),
(stub: StubHelper.stubNetworkWithUnauthorized, error: HTTPError.invalidCredentials),
(stub: StubHelper.stubNetworkWithServerTimeout, error: HTTPError.timeout)
\]
for failure in failures {
setupNetworkFailureTest(withStub: failure.stub, andErrorToAssert: failure.error)
}
}
}
This looks like a big old block o’ code, but what it will provide is pretty great. We set up an array of tuples, each array element holding
Now the classes that will conform to our ServiceTestable
protocol will look something like this:
class GetReceiptsServiceTests: WeVatTests, ServiceTestable {
override func tearDown() {
super.tearDown()
StubHelper.unStubNetwork()
}
...
func testFailures() {
testFailuresWithStatusCodes()
}
func setupNetworkFailureTest(withStub stub: (() -> Void), andErrorToAssert errorAssertion: HTTPError) {
var asyncError: Error?
stub()
GetReceiptsService.getReceipts(forUser: UUID().uuidString) { (result) in
switch result {
case .failure(let error):
asyncError = error
default: break
}
}
expect(asyncError).toEventuallyNot(beNil())
expect(asyncError as! HTTPError).toEventually(equal(errorAssertion))
}
}
I’ll walk you through what is now going on:
ServiceTestable
protocoltestFailures()
is being called by XCTest when running our unit test suitetestFailuresWithStatusCodes()
in our protocol extension.setupNetworkFailureTest
for each of the errors we want.What we have now, is an extendable test harness for our service layer. When we add another service, it is easy to hook it up to the ServiceTestable protocol, which will ensure that all error cases are tested fully. If we want to add further error scenarios, we can just append them to the array and a reference to their corresponding error stub.
Hacker Noon is how hackers start their afternoons. We’re a part of the @AMIfamily. We are now accepting submissions and happy to discuss advertising & sponsorship opportunities.
To learn more, read our about page, like/message us on Facebook, or simply, tweet/DM @HackerNoon.
If you enjoyed this story, we recommend reading our latest tech stories and trending tech stories. Until next time, don’t take the realities of the world for granted!