TDD iOS Network API Call with Xcode XCTest

Test Driven Development (TDD) is one of the approach that software developers can use in Software Development. In TDD, developers plan the features of the software they want to create, then before writing implementation of the feature, they write test cases for each features of the software.

At the beginning the test case obviously will fails because the code is not yet implemented. This phase is usually called the red phase. The developers then write the code to make sure the test case pass successfully and does not break any components or current test cases, the code in this phase does not have to be fully optimized and efficient. This phase is called the green phase. After this, the developer should refactor the implementation of the code by cleaning, maintaining the codebase and optimize the efficiency of the code. This cycle then should be repeated as new test case is added. Each unit of test should be made as small and isolated as possible to make it easier to read and maintain.

In this article, we are going to build a simple Unit Test for Network API using TDD with Xcode XCTest Framework. The Network API encapsulates a network call to a server to get list of movies in JSON, then encode it to array of Movie Swift class. The network test needs to perform fast without making actual network request, to achieve this we will create mock objects and stubs to simulate the server calls and response.

What We Will Build

  • APIRepository class: This class encapsulates our network request call to the server to get list of movies
  • APIRepositoryTests class: XCTest subclass that we will use to write test cases for the APIRepository class
  • MockURLSession class: URLSession subclass that acts as mock object to test the URL passed and MockURLSessionDataTask created with stub Data, URLResponse, and Error objects
  • MockURLSessionDataTask class: URLSessionDataTask subclass that acts as mock object that stores the stub Data, URLResponse, Error, completionHandler object from network call, it overrides the resume call and invoke the completionHandler stub passing the stub objects.

Beginning Test Driven Development

Test Case 1 — Get Movies From API Sets up URL Host and Path as Expected

The first test case we will create is to test that the get movies method sets up URL Host and Path within the correct expectation. Begin by creating APIRepositoryTests Unit Test Class within the Test module. Don’t forget to add ‘@testable import project-name’ to include the project module within the test module. To run the tests in Xcode, you can use the shortcut Command+U.

Create a method called testGetMoviesWithExpectedURLHostAndPath(), inside the function we set instantiate APIRepository class object, as we type the compiler complaints about unresolved identifier APIRepository because we the APIRepository class does not exists yet.

import XCTest
@testable import APITest
class APIRepositoryTests: XCTestCase {
  func testGetMoviesWithExpectedURLHostAndPath() {
let apiRespository = APIRepository()
}
}

To make the test compiles, create a new File called APIRepository.swift containing the APIRepository class

import Foundation
class APIRepository {}

Next, inside the testGetMoviesWithExpectedURLHostAndPath we will call the APIRepository method to get movies from the network passing the completion handler.

func testGetMoviesWithExpectedURLHostAndPath() {
let apiRespository = APIRepository()
apiRepository.getMovies { movies, error in }
}

The compiler will complain that APIRepository has no member getMovies, inside the APIRepository class add the function of getMovies and Create Movie class in a new File to make the test compiles.

import Foundation
class Movie: Codable {
let title: String
let detail: String
}
class APIRepository {
func getMovies(completion: @escaping ([Movie]?, Error?) -> Void) {
}
}

To test the URL Host and Pathname we need a way to cache the URL when the URLSession invokes the dataTask (with:, completionHandler:) passing the URL containing the hostname and path of the Server API in our test. To do this we will create a MockURLSession class that subclass the URLSession class and add a stored properties containing the URL. We then override the dataTask (with:, completionHandler:) method and assign the url to the instance property.

class MockURLSession: URLSession {
var cachedUrl: URL?
  override func dataTask(with url: URL, completionHandler:      @escaping(Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
self.cachedUrl = url
return URLSessionDataTask()
}
}

Inside our test case, we will assign the MockURLSession to the APIRepository session property, then after calling getMovies, we use the XCTAssertEqual to check the APIRepository session url host and pathname to our expectation.

func testGetMoviesWithExpectedURLHostAndPath() {
let apiRespository = APIRepository()
let mockURLSession = MockURLSession()
apiRespository.session = mockURLSession
apiRespository.getMovies() { movies, error in }
XCTAssertEqual(mockURLSession.cachedUrl?.host, "mymovieslist.com")
XCTAssertEqual(mockURLSession.cachedUrl?.path, "/topmovies")
}

The compiler will fails, to make the test compiles just add the session property inside the APIRepository class

class APIRepository {
var session: URLSession!
...
}

Run the test, the test will fail because the XCTAssertEqual. We need to implement the getMovies method passing the correct URL to the sessionDataTask to pass the test. After this, the test will finally pass successfully.

func getMovies(completion: @escaping ([Movie]?, Error?) -> Void)
guard let url = URL(string: "https://mymovieslist.com/topmovies")
else { fatalError() }
session.dataTask(with: url) { (_, _, _) in }
}

Test Case 2 — Get Movies From API Successfully Returns List of Episodes

The next test is to test when the API to get movies has a successful response, the completion handler should be invoked passing the list of movies. To test asynchronous code in Xcode we can use expectation and waitForExpectation function to be fulfilled within the specified timeout we pass. After the expectation is fulfilled a completion handler will be invoked that we can use to assert the result of the fulfilment from the asynchronous code. Create testGetMoviesSuccessReturnsMovies function inside the APIRepositoryTestClass and the following code:

func testGetMoviesSuccessReturnsMovies() {
let apiRespository = APIRepository()
let mockURLSession = MockURLSession()
apiRespository.session = mockURLSession
let moviesExpectation = expectation(description: "movies")
var moviesResponse: [Movie]?

apiRespository.getMovies { (movies, error) in
moviesResponse = movies
moviesExpectation.fulfill()
}
waitForExpectations(timeout: 1) { (error) in
XCTAssertNotNil(moviesResponse)
}
}

The test will fails because we have not implemented the getMovies completion handler to serialize the JSON from the response into Movie class. But how do we test this without an actual Server that returns the data?. We can use MockDataTask and passing stubs for the data. The MockDataTask is an URLSessionDataTask subclass that can be initialized using the stubs Data, URLResponse, and Error object then cached inside the instance properties. It also stores the completionHandler as instance property, so it can be invoked when the override resume method is called.

class MockTask: URLSessionDataTask {
private let data: Data?
private let urlResponse: URLResponse?
private let error: Error?

var completionHandler: ((Data?, URLResponse?, Error?) -> Void)
init(data: Data?, urlResponse: URLResponse?, error: Error?) {
self.data = data
self.urlResponse = urlResponse
self.error = error
}
override func resume() {
DispatchQueue.main.async {
self.completionHandler?(self.data, self.urlResponse, self.error)
}
}
}

We will the MockTask as the instance property of the MockURLSession class and we also create initializer that accepts the Data, URLResponse, and Error object then instantiate the mockTask object using the parameters. Inside the dataTask override method we also assign the completionHandler to the mockTask completion handler property for it to be invoked when the resume is called.

class MockURLSession: URLSession {
...
private let mockTask: MockTask
init(data: Data?, urlResponse: URLResponse?, error: Error?) {
mockTask = MockTask(data: data, urlResponse: urlResponse, error:
error)

override func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
self.cachedUrl = url
mockTask.completionHandler = completionHandler
return dataTask
}
}

Now inside the test we create stub json Data, then initialize the MockSession passing the stub Data. Run the test, it fails because we haven’t implemented the getMovies completion handler to handle the response and serialize the JSON data to the Movie object.

func testGetMoviesSuccessReturnsMovies() {
let jsonData = "[{\"title\": \"Mission Impossible Fallout\",\"detail\": \"A Tom Cruise Movie\"}]".data(using: .utf8)
let apiRespository = APIRepository()
let mockURLSession = MockURLSession(data: jsonData, urlResponse: nil, error: nil)
apiRespository.session = mockURLSession
let moviesExpectation = expectation(description: "movies")
var moviesResponse: [Movie]?

apiRespository.getMovies { (movies, error) in
moviesResponse = movies
moviesExpectation.fulfill()
}
waitForExpectations(timeout: 1) { (error) in
XCTAssertNotNil(moviesResponse)
}
}

To make the test pass successfully, implements the completionHandler in get movies from APIRepository class:

func getMovies(completion: @escaping ([Movie]?, Error?) -> Void) {
guard let url = URL(string: "https://mymovieslist.com/topmovies")
else { fatalError() }
  session.dataTask(with: url) { (data, response, error) in
guard
let data = data else { return }
let movies = try! JSONDecoder().decode([Movie].self, from: data)
completion(movies, nil)
}.resume()
}

Test Case 3 — Get Movies From API with URL Response Error returns ErrorResponse

The third test case is to test when the dataTask completion handler has an Error it returns ResponseError. Create testGetMoviesWhenResponseErrorReturnsError method inside the APIRepositoryTests class:

func testGetMoviesWhenResponseErrorReturnsError() {
let apiRespository = APIRepository()
let error = NSError(domain: "error", code: 1234, userInfo: nil)
let mockURLSession = MockURLSession(data: nil, urlResponse: nil, error: error)
apiRespository.session = mockURLSession
let errorExpectation = expectation(description: "error")
var errorResponse: Error?
apiRespository.getMovies { (movies, error) in
errorResponse = error
errorExpectation.fulfill()
}
waitForExpectations(timeout: 1) { (error) in
XCTAssertNotNil(errorResponse)
}
}

The test fails, because we haven’t handle the implementation or handling response error in the completion handler. Add the implementation to check the error is nil using guard and pass the error to completion handler and return if it is exist. Run the test to make sure it pass successfully.

func getMovies(completion: @escaping ([Movie]?, Error?) -> Void) {
guard let url = URL(string: "https://mymovieslist.com/topmovies")
else { fatalError() }
  session.dataTask(with: url) { (data, response, error) in
guard
error == nil else {
completion(nil, error)
return
}
    guard let data = data else { return }
let movies = try! JSONDecoder().decode([Movie].self, from: data)
completion(movies, nil)
}.resume()
}

Test Case 4- Get Movies From API with Empty Data returns Error

This test will check when the response returns empty data, then the completion handler will be invoked passing an Error object. Create testGetMoviesWhenEmptyDataReturnsError function inside APIRepositoryTests class.

func testGetMoviesWhenEmptyDataReturnsError() {
let apiRespository = APIRepository()
let mockURLSession = MockURLSession(data: nil, urlResponse: nil, error: nil)
apiRespository.session = mockURLSession
let errorExpectation = expectation(description: "error")
var errorResponse: Error?
apiRespository.getMovies { (movies, error) in
errorResponse = error
errorExpectation.fulfill()
}
waitForExpectations(timeout: 1) { (error) in
XCTAssertNotNil(errorResponse)
}
}

The test fails, we need to handle checking the data using guard and invoke completion handler with Error object if the data does not exists. Run the test to make sure it pass.

func getMovies(completion: @escaping ([Movie]?, Error?) -> Void) {
guard let url = URL(string: "https://mymovieslist.com/topmovies")
else { fatalError() }
  session.dataTask(with: url) { (data, response, error) in
guard
error == nil else {
completion(nil, error)
return
}
    guard let data = data else { 
completion(nil, NSError(domain: "no data", code: 10, userInfo: nil))
return
}
    let movies = try! JSONDecoder().decode([Movie].self, from: data)
completion(movies, nil)
}.resume()
}

Test Case 5 — Get Movies From API with Invalid JSON returns Error

The final test for the get movies API is to handle when an invalid JSON data is passed into object serialization returns an Error. Create a testGetMoviesInvalidJSONReturnsError inside APIRepositoryTests class.

func testGetMoviesInvalidJSONReturnsError() {
let jsonData = "[{\"t\"}]".data(using: .utf8)
let apiRespository = APIRepository()
let mockURLSession = MockURLSession(data: jsonData, urlResponse: nil, error: nil)
apiRespository.session = mockURLSession
let errorExpectation = expectation(description: "error")
var errorResponse: Error?
apiRespository.getMovies { (movies, error) in
errorResponse = error
errorExpectation.fulfill()
}
waitForExpectations(timeout: 1) { (error) in
XCTAssertNotNil(errorResponse)
}
}

Run the test, the test will crash because we use try! to handle the JSONDecoder decode function to serialize the JSON into Movie Class. We need to refactor the code using do try catch block and returns the error when an Error occurs in JSON parsing.

func getMovies(completion: @escaping ([Movie]?, Error?) -> Void) {
guard let url = URL(string: "https://mymovieslist.com/topmovies")
else { fatalError() }
  session.dataTask(with: url) { (data, response, error) in
guard
error == nil else {
completion(nil, error)
return
}
    guard let data = data else { 
completion(nil, NSError(domain: "no data", code: 10, userInfo: nil))
return
}
    do {
let movies = try JSONDecoder().decode([Movie].self, from: data)
completion(movies, nil)
} catch {
completion(nil, error)
}
}.resume()
}

Run the tests to make sure all of our test passes without regression!

Conclusion

TDD in Software Development leads to a more robust and maintainable Software in long run because as a developer we can always run the tests again as we develop and add new features by detecting the regression of the code. TDD should also produce less bugs in our apps as long as we have good test coverage entire our modules.