我如何模拟 URLSession?
How can I mock URLSession?
我正在查看用 Swift 4 编写的 iOS 应用程序。它有一个使用 URLSession 的相当简单的网络层,但是该应用程序没有单元测试,在我开始重构之前,我我热衷于通过引入一些测试来解决这个问题。
在执行此操作之前,我必须能够模拟 URLSession
,这样我就不会在测试期间创建真实的网络请求。我在当前实施中看不到如何实现这一目标?我在测试中注入 URLSession 的入口点在哪里。
我提取了网络代码并使用相同的逻辑创建了一个简单的应用程序,如下所示:
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
}
使用此代码的一个非常简单的应用程序可能类似于
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)
}
}
理想情况下,我想要一种模拟 URLSession
的方法,这样我就可以正确地测试它,但是我不确定如何使用当前代码实现它。
在这种情况下,如果您在 RandomUserClient
左右断言实际上可能更有意义。
你扩展 RandomUserClient
并让它接受一个 URLSession
的实例,它本身被注入你的 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)
}
}
您的视图控制器需要更新,以便 RandomUserClient 被初始化为 lazy var client = RandomUserClient(session: URLSession.shared)
您的 APIClient 协议和扩展也需要重构以接受新注入的 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()
}
}
注意添加 session: URLSession
。
我正在查看用 Swift 4 编写的 iOS 应用程序。它有一个使用 URLSession 的相当简单的网络层,但是该应用程序没有单元测试,在我开始重构之前,我我热衷于通过引入一些测试来解决这个问题。
在执行此操作之前,我必须能够模拟 URLSession
,这样我就不会在测试期间创建真实的网络请求。我在当前实施中看不到如何实现这一目标?我在测试中注入 URLSession 的入口点在哪里。
我提取了网络代码并使用相同的逻辑创建了一个简单的应用程序,如下所示:
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
}
使用此代码的一个非常简单的应用程序可能类似于
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)
}
}
理想情况下,我想要一种模拟 URLSession
的方法,这样我就可以正确地测试它,但是我不确定如何使用当前代码实现它。
在这种情况下,如果您在 RandomUserClient
左右断言实际上可能更有意义。
你扩展 RandomUserClient
并让它接受一个 URLSession
的实例,它本身被注入你的 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)
}
}
您的视图控制器需要更新,以便 RandomUserClient 被初始化为 lazy var client = RandomUserClient(session: URLSession.shared)
您的 APIClient 协议和扩展也需要重构以接受新注入的 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()
}
}
注意添加 session: URLSession
。