Mock session with typealias URLSession.DatataskPublisher.Output

Purpose :

For this example, an application uses a service to retrieve data from an API.

This service uses the Combine framework. We will use the URLSession dataTaskPublisher which will publish the data (Output) when the task is completed, or an error (Failure).

The dataTaskPublisher will provide :

  • data from the API with a classic URLSession
  • or predefined data in a json file, with a mocked URLSession.

To implement URLSession differently, we will create a URLSessionProtocol.

The service will take a URLSession as a parameter.

Environment :

  • Xcode 14.0.1
  • Swift 5.7
  • Combine

Menu

  • Project creation
  • json file
  • Data Model
  • URLSession Protocol
  • Service
  • URLSessionMock class
  • Unit tests
  • Real calls

Project creation

We create a new iOS project CombineMockURLSession, with Interface SwiftUI and taking care to check the Include Tests box.

The api.themoviedb.org API requires an API key. You can create your own by following the instructions in the documentation.

json file

According to the documentation, to retrieve the upcoming movies, we use https://api.themoviedb.org/3/movie/upcoming?api_key={your apiKey}.

With Postman, we can test the API and retrieve data for our json file, by copying the data provided in the response.

In the test target, we create a DataMock folder, in which we add a movies.json file. Be careful to check the test target.

Let's paste the data from Postman.

{
"dates": {
    "maximum": "2022-11-15",
    "minimum": "2022-10-27"
},
"page": 1,
"results": [
    {
        "adult": false,
        "backdrop_path": "/1DBDwevWS8OhiT3wqqlW7KGPd6m.jpg",
        "genre_ids": [
            53
        ],
        "id": 985939,
        "original_language": "en",
        "original_title": "Fall",
        "overview": "For best friends Becky and Hunter, life is all about conquering fears and pushing limits. But after they climb 2,000 feet to the top of a remote, abandoned radio tower, they find themselves stranded with no way down. Now Becky and Hunter’s expert climbing skills will be put to the ultimate test as they desperately fight to survive the elements, a lack of supplies, and vertigo-inducing heights",
        ...
    },
    {
    "adult": false,
        ...
    },
    {
    "adult": false,
        ...
    },
        ...
],
"total_pages": 23,
"total_results": 452
}

Make sure in the file inspector, that Target Membership is checked for the tests.

Data Model

From this data we can define the data model, knowing that for our example we keep only the title, the pitch, and the total of occurrence, so the keys total_results, an array results with the keys title and overview.

In the project target, add a Swift file named MovieResponse.

import Foundation

struct MovieResponse: Codable {
    let results: [Movie]
    let total_results: Int
}

struct Movie: Codable, Equatable, Identifiable {
    let id: Int
    let title: String
    let overview: String
}

URLSession Protocol

We will use the URLSession class to retrieve data from the API, and a mocked URLSession class, URLSessionMock, for unit testing.

Both classes will conform to a URLSessionProtocol.

This protocol includes a typealias for the Output type of the publisher, i.e. the data to be published, and a function which returns a publisher with the two associated types: the typealias for the Output, and an error of type URLError for the failure.

import Combine

protocol URLSessionProtocol {

    typealias APIResponse = URLSession.DataTaskPublisher.Output
    func response(for request: URLRequest) -> AnyPublisher<APIResponse, URLError>

}

Service

In the project target, let's create a new swift file MovieService, with a class MovieService, which has as parameter URLSessionProtocol.

This class includes an init() to specify the session, and a generic function that returns a publisher with its two associated types:

  • a value type: URLSession.DataTaskPublisher.Output, here generic
  • an error type: URLSession.DataTaskPublisher.Failure, here MovieError

Let's create an enum for the MovieError type.

enum MovieError: Error {
    case decode
    case invalidURL
    case unauthorized
    case unknown
}

And implement the class :

import Combine

class MovieService<T: URLSessionProtocol> {

    let session: T
    init(session: T) {
        self.session = session
    }

    func get<T: Decodable>(dataType: T.Type) -> AnyPublisher<T, MovieError> {

    }
}
  • Let's implement the get() function so that it returns a publisher provided by the response function of the session (conforming to the URLSessionProtocol).
      `return session.response(for `URLRequest`)`
    
  • Fill in the url with the apiKey
  • Let's process the response, converting the failure case to an error, and the success case to decode the received data.
  • We finish by wrapping the publisher in the type AnyPublisher.
class MovieService<T: URLSessionProtocol> {

    var apiKey = "xxxxxxxxx"
    var urlString = "https://api.themoviedb.org/3/movie/upcoming?api_key=\(apiKey)"

    func get<T: Decodable>(dataType: T.Type) -> AnyPublisher<T, MovieError> {
        guard let url = URL(string: urlString) else {
          return Fail(outputType: T.self, failure: MovieError.invalidURL).eraseToAnyPublisher()
         }
         let request = URLRequest(url: url)

         return session.response(for request)
         .mapError { _ in
             MovieError.unauthorized
        }
        .flatMap { output in
            return Just(output.data)
            .decode(type: T.self, decoder: JSONDecoder())
            .mapError { _ in
                MovieError.decode
            }
        } 
        .eraseToAnyPublisher()
     }
}

URLSessionMock class

In the test target, create a new swift file URLSessionMock, with the class URLSessionMock.

Import the project and conform the class to the URLSessionProtocol.

Implement the response() function to get back a publisher with mocked data from the json file.

import Combine
@testable import CombineMockURLSession

class URLSessionMock: URLSessionProtocol {

    var jsonName = "movies.json"

    func response(for request: URLRequest) -> AnyPublisher<APIResponse, URLError> {
        let response = HTTPURLResponse(
            url: request.url!,
            statusCode: 200,
            httpVersion: "HTTP/1.1",
            headerFields: nil)!

        let file = Bundle(for: type(of: self)).path(forResource: jsonName, ofType: nil) ?? ""

        let url = URL(fileURLWithPath: file)

        guard let data = try? Data(contentsOf: url) else {
            return Just((data: Data(), response: response))
                .setFailureType(to: URLError.self)
                .eraseToAnyPublisher()
        }

        return Just((data: data, response: response))
            .setFailureType(to: URLError.self)
            .eraseToAnyPublisher()
    }
}

Unit tests

The service is implemented, as well as the URLSessionMock class to simulate an API response.

Let's test the service.

To make the unit tests run faster, in the scheme let's disable the UITest tests in the project.

Enable Code Coverage in the Options of the Test tab of the scheme to see the unit test coverage.

In the unit test folder, add a Unit test Case file named MovieServiceTest. Delete all the elements of this created class.

The file created by default with the CombineMockURLSessionTests project can be deleted.

Import Combine and the project.

import XCTest
import Combine
@testable import CombineMockURLSession

class MovieServiceTest: XCTestCase {

}

To test the service, we will use :

  • a mocked session
  • a MovieService with a mocked session as parameter
  • a publisher of type AnyCancellable
  • a response that will deliver the received data
import XCTest
import Combine
@testable import CombineMockURLSession

class MovieServiceTest: XCTestCase {

    var urlSession: URLSessionMock?
    var movieService: MovieService<URLSessionMock>?

    var publisher: AnyCancellable?
    var response: Publishers.ReceiveOn<AnyPublisher<MovieResponse, MovieError>, DispatchQueue>?
}

Unit test of the service with a good answer

For this first unit test :

  • create an instance of URLSessionMock
  • create an instance of the MovieService() service with the URLSessionMock instance
  • get the data with the get() function of the service.
  • check that the received data correspond to the expected ones, (file movies.json)
import XCTest
import Combine
@testable import CombineMockURLSession

class MovieServiceTest: XCTestCase {

    var urlSession: URLSessionMock?
    var movieService: MovieService<URLSessionMock>?

    var publisher: AnyCancellable?
    var response: Publishers.ReceiveOn<AnyPublisher<MovieResponse, MovieError>, DispatchQueue>?

    func testSuccessfullURLResponse() {
        urlSession = URLSessionMock()
        movieService = MovieService(session: urlSession!)

        response = movieService?.get(dataType: MovieResponse.self)
            .receive(on: DispatchQueue.main)

        publisher = response?.sink(receiveCompletion: { _ in
        // Failure case
        }, receiveValue: { movieResponse in

            let count = movieResponse.total_results
            XCTAssertEqual(count, 612)

            let overview = movieResponse.results[2].overview
            let overviewExpected = "Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods—and imprisoned just as quickly—Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the modern world."

            XCTAssertEqual(overview, overviewExpected)
        })
    }
}

If we run the tests, the service is 81% tested.

Unit test of the service with a bad answer

To simulate a bad response, let's create a badMovies file of bad data as we did for the movies json file.

Then create a unit test with a session that will process this badMovies file, and check that the error received is the expected one.

func testFailureURLResponse() {
        urlSession = URLSessionMock()
        urlSession?.jsonName = "badMovies.json"
        movieService = MovieService(session: urlSession!)

        response = movieService?.get(dataType: MovieResponse.self)
            .receive(on: DispatchQueue.main)

        publisher = response?.sink(receiveCompletion: { movieError in
            switch movieError {
            case .finished: break
            case .failure(let error):
                XCTAssertEqual(error, .decode)
            }
        }, receiveValue: { error in
            XCTFail("failure URLSession: \(error)")
        })
    }

The service is now 89% tested, there are still two untested error cases.

Real calls

If the application is running, we get a nice `Hello World!

To query the API, we'll conform URLSession to the URLSessionProtocol, and implement the response function so that it returns a dataTaskPublisher.

import Combine

protocol URLSessionProtocol {

    typealias APIResponse = URLSession.DataTaskPublisher.Output
    func response(for request: URLRequest) -> AnyPublisher<APIResponse, URLError>
}

extension URLSession: URLSessionProtocol {

    func response(for request: URLRequest) -> AnyPublisher<APIResponse, URLError> {
        dataTaskPublisher(for: request).eraseToAnyPublisher()
    }
}

ViewModel

Create a ViewModel to put the data in an array.

The service is instantiated with URLSession.shared.

import SwiftUI
import Combine

class MoviesViewModel: ObservableObject {

    var service = MovieService(session: URLSession.shared)

    @Published var movies: [Movie] = []
    var cancellable = Set<AnyCancellable>()

    func requestMovies() {

        let response = service.get(dataType: MovieResponse.self)
        response
        .receive(on: DispatchQueue.main)
        .sink { error in
            print("error =", error)
        } receiveValue: { movieResponse in
            let moviesIn = movieResponse.results

            moviesIn.forEach { movie in
                self.movies.append(movie)
            }
        }
        .store(in: &cancellable)
    }
}

The view

Let's create a list that will simply display the title of the movie.

import SwiftUI

struct ContentView: View {

    @ObservedObject var viewModel: MoviesViewModel

    var body: some View {
        NavigationView(content: {
            List {
                ForEach(viewModel.movies, id: \.id) { movie in
                    Text(movie.title)
                        .foregroundColor(.primary)
                        .font(.title3)
                }
            }
            .navigationTitle("Movie")
        })
        .onAppear {
            viewModel.requestMovies()
        }
    }
}

Set the ViewModel as a parameter of the ContentView() for the CombineMockURLSessionAPP file.

Now the application displays a list of movie titles from the API.

Conclusion

The list is really not pretty, but the purpose of this example is to test the service with a URLSessionMock class.

In this example we display only the title. But the other data are available in the movies.json file to have a complete list and even a detailed view.

This class can also be used in the case of a project whose back-end is not yet operational, to build views with data, as in the following example

Thanks for your attention, and enjoy code.