Skip to content

Instantly share code, notes, and snippets.

@AbdullahAshi
Created July 20, 2023 06:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save AbdullahAshi/e410dfcf16ef9b141acfec638680e384 to your computer and use it in GitHub Desktop.
Save AbdullahAshi/e410dfcf16ef9b141acfec638680e384 to your computer and use it in GitHub Desktop.
generic network service using completion handlers, protocol driven, dependency injection applied. Unit tests included
//
// NetworkService.swift
//
// Created by Abdullah Al-Ashi.
//
import Foundation
//MARK: - URLSession
typealias DataTaskResult = (Data?, URLResponse?, Error?) -> Void
protocol URLSessionDataTaskProtocol {
func resume()
}
extension URLSessionDataTask: URLSessionDataTaskProtocol {}
protocol URLSessionProtocol {
func dataTask(with url: URL, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol
func dataTask(with request: URLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol
}
extension URLSession: URLSessionProtocol {
func dataTask(with url: URL, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol {
return ( dataTask(with: url, completionHandler: completionHandler) as URLSessionDataTask) as URLSessionDataTaskProtocol
}
func dataTask(with request: URLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol {
return ( dataTask(with: request, completionHandler: completionHandler) as URLSessionDataTask) as URLSessionDataTaskProtocol
}
}
//MARK: - NetworkService
typealias NetworkCompletionHandler<Model: Codable> = (Result<Model?,Error>) -> Void
protocol NetworkServiceProtocol {
func get<Model: Codable>(url: URL, completion: @escaping NetworkCompletionHandler<Model>)
}
final class NetworkService: NetworkServiceProtocol {
enum HttpMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
private let urlSession: URLSessionProtocol
static let shared: NetworkServiceProtocol = NetworkService()
init(urlSession: () -> URLSessionProtocol = {
let sessionConfig = URLSessionConfiguration.ephemeral
sessionConfig.timeoutIntervalForRequest = 10.0
sessionConfig.timeoutIntervalForResource = 60.0
sessionConfig.requestCachePolicy = .reloadIgnoringLocalCacheData
return URLSession(configuration: sessionConfig)
}) {
self.urlSession = urlSession()
}
func get<Model: Codable>(url: URL, completion: @escaping NetworkCompletionHandler<Model>) {
request(url: url, method: .get, completion: completion)
}
private func request<Model: Codable>(url: URL,
method: HttpMethod,
completion: @escaping NetworkCompletionHandler<Model>){
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = method.rawValue
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
let task = urlSession.dataTask(with: urlRequest) { data, response, error in
guard error == nil else {
completion(.failure(error!))
return
}
guard let response = (response as? HTTPURLResponse) else {
completion(.failure(NetworkError.invalidResponse("response is nil")))
return
}
guard 200..<399 ~= response.statusCode else {
completion(.failure(NetworkError.invalidResponse("unsuccessful status code")))
return
}
guard let data = data else {
completion(.failure(NetworkError.invalidResponse("data is nil")))
return
}
do {
let object = try JSONDecoder().decode(Model.self, from: data)
completion(.success(object))
} catch {
do {
let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String : Any]
print(json ?? ["": ""])
} catch {
print(error)
}
completion(.failure(NetworkError.serializationError(error)))
}
}
task.resume()
}
}
//
// NetworkServiceTests.swift
//
// Created by Abdullah Al-Ashi
//
@testable import //TODO: add project name here
import XCTest
class NetworkServiceTests: XCTestCase {
let anyUrl = URL(string: "https://www.google.com")!
let mockUrlSession = MockURLSession()
lazy var networkService = NetworkService(urlSession: { return mockUrlSession })
func testSuccess() throws {
let response: Response = .init(id: "123451")
let data: Data = try JSONEncoder().encode(response)
mockUrlSession.set(mockInfo: (data: data, statusCode: 200, error: nil))
let expectation = XCTestExpectation(description: "error should be nil, and response shouldn't be nil")
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in
guard case .success(let value) = result else {
return XCTFail("Expected to be a success but got a failure with \(result)")
}
XCTAssertEqual(value?.id, "123451")
expectation.fulfill()
})
self.wait(for: [expectation], timeout: 3.0)
}
func testUnsuccessfulStatus() throws {
mockUrlSession.set(mockInfo: (data: Data(), statusCode: 404, error: nil))
let expectation = XCTestExpectation(description: "error should not be nil, and response should be nil")
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in
guard case .failure(let error) = result else {
return XCTFail("Expected to get an error but got a success \(result)")
}
XCTAssertEqual(error as! NetworkError, NetworkError.invalidResponse("unsuccessful status code"))
expectation.fulfill()
})
self.wait(for: [expectation], timeout: 3.0)
}
func testErrorResponse() throws {
mockUrlSession.set(mockInfo: (data: nil, statusCode: 404, error: NetworkError.unexpectedError))
let expectation = XCTestExpectation(description: "error should not be nil, and response should be nil")
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in
guard case .failure(let error) = result else {
return XCTFail("Expected to get an error but got a success \(result)")
}
XCTAssertEqual(error as! NetworkError, NetworkError.unexpectedError)
expectation.fulfill()
})
self.wait(for: [expectation], timeout: 3.0)
}
func testTimeoutError() throws {
mockUrlSession.set(mockInfo: (data: nil, statusCode: 404, error: NetworkError.unexpectedError))
let expectation = XCTestExpectation(description: "error timout should occur")
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in
guard case .failure(_) = result else {
return XCTFail("Expected to get an error but got a success \(result)")
}
DispatchQueue.global().asyncAfter(deadline: .now() + 30, execute: {
expectation.fulfill()
})
})
let myresult = XCTWaiter().wait(for: [expectation], timeout: 3)
XCTAssertEqual(myresult, XCTWaiter.Result.timedOut)
}
func testResponseIsNil() throws {
mockUrlSession.set(mockInfo: nil)
let expectation = XCTestExpectation(description: "error should not be nil, and response should be nil")
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in
guard case .failure(let error) = result else {
return XCTFail("Expected to get an error but got a success \(result)")
}
XCTAssertEqual(error as! NetworkError, NetworkError.invalidResponse("response is nil"))
expectation.fulfill()
})
self.wait(for: [expectation], timeout: 3.0)
}
func testDataIsNil() throws {
mockUrlSession.set(mockInfo: nil)
let expectation = XCTestExpectation(description: "error should not be nil, and data should be nil")
mockUrlSession.set(mockInfo: (data: nil, statusCode: 200, error: nil))
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in
guard case .failure(let error) = result else {
return XCTFail("Expected to get an error but got a success \(result)")
}
XCTAssertEqual(error as! NetworkError, NetworkError.invalidResponse("data is nil"))
expectation.fulfill()
})
self.wait(for: [expectation], timeout: 3.0)
}
func testDecodingFail() throws {
struct MalformedResponse: Codable {
let di: String
}
let data: Data = try JSONEncoder().encode(MalformedResponse(di: "123451"))
let expectation = XCTestExpectation(description: "error should be nil, and response shouldn't be nil")
mockUrlSession.set(mockInfo: (data: data, statusCode: 200, error: nil))
networkService.get(url: anyUrl, completion: { (result: Result<Response?, Error>) in
guard case .failure(let error) = result else {
return XCTFail("Expected to get an error but got a success \(result)")
}
XCTAssertEqual(error as! NetworkError, NetworkError.serializationError(error))
expectation.fulfill()
})
self.wait(for: [expectation], timeout: 3.0)
}
}
// MARK: - Test Model
private extension NetworkServiceTests {
struct Response: Codable {
enum CodingKeys: CodingKey {
case id
}
let id: String
}
}
class MockURLSession: URLSessionProtocol {
typealias MockInfo = (data: Data?, statusCode: Int, error: Error?)
private (set) var nextDataTask = MockURLSessionDataTask()
private var mockInfo: MockInfo?
private var mockResponse: HTTPURLResponse {
return HTTPURLResponse(url: URL(string: "https://www.google.com")!, statusCode: mockInfo!.statusCode, httpVersion: "HTTP/1.1", headerFields: nil)!
}
func dataTask(with url: URL, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol {
nextDataTask.resume()
if let mockInfo = mockInfo {
completionHandler(mockInfo.data, mockResponse, mockInfo.error)
} else {
completionHandler(Data(), nil, nil)
}
return nextDataTask
}
func dataTask(with request: URLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol {
nextDataTask.resume()
if let mockInfo = mockInfo {
completionHandler(mockInfo.data, mockResponse, mockInfo.error)
} else {
completionHandler(Data(), nil, nil)
}
return nextDataTask
}
func set(mockInfo: MockInfo?) {
self.mockInfo = mockInfo
}
}
class MockURLSessionDataTask: URLSessionDataTaskProtocol {
private (set) var resumeCalled = false
func resume() {
resumeCalled = true
}
}
extension URLResponse {
static func mock(url: URL = URL(string: "https://www.google.com")!,
statusCode: Int = 200,
httpVersion: String? = nil,
headerFields: [String: String]? = nil) -> URLResponse {
return HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: httpVersion, headerFields: headerFields)!
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment