Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I mock URLSession?

Tags:

I am looking at an iOS application written in Swift 4. It has a fairly simple network layer using URLSession, however the application has no unit tests and before I start a refactor, I am keen to address this by introducing a number of tests.

Before I can do this, I must be able to mock out URLSession so I do not create real network requests during testing. I cannot see in the current implementation how I can achieve this? Where is an entrypoint for my to inject URLSession in my tests.

I have extracted the network code and created a simple app using the same logic, which looks like this:

Endpoint.swift

import Foundation

protocol Endpoint {
    var baseURL: String { get }
}

extension Endpoint {
    var urlComponent: URLComponents {
        let component = URLComponents(string: baseURL)
        return component!
    }

    var request: URLRequest {
        return URLRequest(url: urlComponent.url!)
    }
}

struct RandomUserEndpoint: Endpoint {
    var baseURL: String {
        return RandomUserClient.baseURL
    }
}

APIClient.swift

import Foundation

enum Either<T> {
    case success(T), error(Error)
}

enum APIError: Error {
    case unknown, badResponse, jsonDecoder
}

enum HTTPMethod: String {
    case get = "GET"
    case put = "PUT"
    case post = "POST"
    case patch = "PATCH"
    case delete = "DELETE"
    case head = "HEAD"
    case options = "OPTIONS"
}

protocol APIClient {
    var session: URLSession { get }
    func get<T: Codable>(with request: URLRequest, completion: @escaping (Either<T>) -> Void)
}

extension APIClient {
    var session: URLSession {
        return URLSession.shared
    }

   func get<T: Codable>(with request: URLRequest, completion: @escaping (Either<T>) -> Void) {
        let task = session.dataTask(with: request) { (data, response, error) in
            guard error == nil else { return completion(.error(error!)) }
            guard let response = response as? HTTPURLResponse, 200..<300 ~= response.statusCode else { completion(.error(APIError.badResponse)); return }

            guard let data = data else { return }

            guard let value = try? JSONDecoder().decode(T.self, from: data) else { completion(.error(APIError.jsonDecoder)); return }

            DispatchQueue.main.async {
                completion(.success(value))
            }
        }
        task.resume()
    }

}

RandomUserClient.swift

import Foundation

class RandomUserClient: APIClient {
    static let baseURL = "https://randomuser.me/api/"

    func fetchRandomUser(with endpoint: RandomUserEndpoint, method: HTTPMethod, completion: @escaping (Either<RandomUserResponse>)-> Void) {
        var request = endpoint.request
        request.httpMethod = method.rawValue
        get(with: request, completion: completion)
    }

}

RandomUserModel.swift

import Foundation

typealias RandomUser = Result

struct RandomUserResponse: Codable {
    let results: [Result]?
}

struct Result: Codable {
    let name: Name
}

struct Name: Codable {
    let title: String
    let first: String
    let last: String
}

A very simple app to consume this code can be something like

class ViewController: UIViewController {

    let fetchUserButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("FETCH", for: .normal)
        button.setTitleColor(.black, for: .normal)
        button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 36)
        button.addTarget(self, action: #selector(fetchRandomUser), for: .touchUpInside)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.isEnabled = true
        return button
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .white

        view.addSubview(fetchUserButton)
        NSLayoutConstraint.activate([
            fetchUserButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            fetchUserButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }

    @objc func fetchRandomUser() {
        let client = RandomUserClient()
        fetchUserButton.isEnabled = false
        client.fetchRandomUser(with: RandomUserEndpoint(), method: .get) { [unowned self] (either) in
            switch either {
            case .success(let user):
                guard let name = user.results?.first?.name else { return }
                let message = "Your new name is... \n\(name.first.uppercased()) \(name.last.uppercased())"
                self.showAlertView(title: "", message: message)
                self.fetchUserButton.isEnabled = true
            case .error(let error):
                print(error.localizedDescription)
            }
        }
    }

    func showAlertView(title: String, message: String) {
        let alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert)
        alert.addAction(UIAlertAction(title: "Close", style: UIAlertAction.Style.default, handler: nil))
        self.present(alert, animated: true, completion: nil)
    }
}

Ideally I would like a way to mock out URLSession so I can test it correctly however I am unsure how I can achieve this with the current code.

like image 421
Harry Blue Avatar asked Oct 02 '18 13:10

Harry Blue


1 Answers

In this case it may actually make more sense if you assert around RandomUserClient.

You extend RandomUserClient and have it accept an instance of URLSession, which itself is injected into your APIClient.

class RandomUserClient: APIClient {
    var session: URLSession
    static let baseURL = "https://randomuser.me/api/"

    init(session: URLSession) {
        self.session = session
    }

    func fetchRandomUser(with endpoint: RandomUserEndpoint, method: HTTPMethod, completion: @escaping (Either<RandomUserResponse>)-> Void) {
        var request = endpoint.request
        request.httpMethod = method.rawValue

        get(with: request, session: session, completion: completion)
    }

}

Your view controller would need to be updated so RandomUserClient is initialised something like lazy var client = RandomUserClient(session: URLSession.shared)

Your APIClient protocol and extension would also need to be refactored to accept the new injected dependancy of URLSession

protocol APIClient {
    func get<T: Codable>(with request: URLRequest, session: URLSession, completion: @escaping (Either<T>) -> Void)
}

extension APIClient {
    func get<T: Codable>(with request: URLRequest, session: URLSession, completion: @escaping (Either<T>) -> Void) {
        let task = session.dataTask(with: request) { (data, response, error) in
            guard error == nil else { return completion(.error(error!)) }
            guard let response = response as? HTTPURLResponse, 200..<300 ~= response.statusCode else { completion(.error(APIError.badResponse)); return }

            guard let data = data else { return }

            guard let value = try? JSONDecoder().decode(T.self, from: data) else { completion(.error(APIError.jsonDecoder)); return }

            DispatchQueue.main.async {
                completion(.success(value))
            }
        }
        task.resume()
    }

}

Notice the addition of session: URLSession.

like image 191
nodediggity Avatar answered Oct 21 '22 11:10

nodediggity