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 theresponse
function of the session (conforming to theURLSessionProtocol
).`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.