Skip to content

Commit

Permalink
Merge pull request #11 from Lickability/mock-url-session
Browse files Browse the repository at this point in the history
Mock URLSession Behavior for Testing
  • Loading branch information
mliberatore authored May 10, 2023
2 parents ac9998a + d709602 commit 0489b62
Show file tree
Hide file tree
Showing 9 changed files with 243 additions and 15 deletions.
2 changes: 1 addition & 1 deletion Example/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Combine
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

let controller = NetworkController(urlSession: .shared)
let controller = NetworkController()
var cancellables = Set<AnyCancellable>()

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
Expand Down
16 changes: 16 additions & 0 deletions Networking.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
objects = {

/* Begin PBXBuildFile section */
0B96DC552A0AB67B00FF0499 /* NetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B96DC542A0AB67B00FF0499 /* NetworkSession.swift */; };
0B96DC572A0AB91B00FF0499 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B96DC562A0AB91B00FF0499 /* MockNetworkSession.swift */; };
0B96DC5A2A0AC7E800FF0499 /* NetworkSessionDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B96DC582A0AC7A800FF0499 /* NetworkSessionDataTask.swift */; };
0B96DC5C2A0AC99F00FF0499 /* NetworkControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B96DC5B2A0AC99F00FF0499 /* NetworkControllerTests.swift */; };
4C2CBFAC24D4AA9800920BE2 /* NetworkRequestPerformer+JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2CBFAB24D4AA9800920BE2 /* NetworkRequestPerformer+JSON.swift */; };
F211601924884A4D00B48D52 /* PhotoRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F211601824884A4D00B48D52 /* PhotoRequest.swift */; };
F211601B24884ABF00B48D52 /* Photo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F211601A24884ABF00B48D52 /* Photo.swift */; };
Expand Down Expand Up @@ -40,6 +44,10 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
0B96DC542A0AB67B00FF0499 /* NetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSession.swift; sourceTree = "<group>"; };
0B96DC562A0AB91B00FF0499 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = "<group>"; };
0B96DC582A0AC7A800FF0499 /* NetworkSessionDataTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSessionDataTask.swift; sourceTree = "<group>"; };
0B96DC5B2A0AC99F00FF0499 /* NetworkControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkControllerTests.swift; sourceTree = "<group>"; };
4C2CBFAB24D4AA9800920BE2 /* NetworkRequestPerformer+JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetworkRequestPerformer+JSON.swift"; sourceTree = "<group>"; };
F211601824884A4D00B48D52 /* PhotoRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoRequest.swift; sourceTree = "<group>"; };
F211601A24884ABF00B48D52 /* Photo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Photo.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -103,6 +111,8 @@
F2B5BC522488378200B6A52A /* RequestBehavior.swift */,
F2B5BC54248837B400B6A52A /* NetworkRequestPerformer.swift */,
F2B5BC56248837CB00B6A52A /* NetworkController.swift */,
0B96DC542A0AB67B00FF0499 /* NetworkSession.swift */,
0B96DC582A0AC7A800FF0499 /* NetworkSessionDataTask.swift */,
4C2CBFAB24D4AA9800920BE2 /* NetworkRequestPerformer+JSON.swift */,
F264BB4D28BE9D6500969CD1 /* NetworkRequestStateController.swift */,
F264BB4F28BE9E1700969CD1 /* Publisher+Result.swift */,
Expand Down Expand Up @@ -149,6 +159,8 @@
isa = PBXGroup;
children = (
F2B5BC3D248836C600B6A52A /* NetworkingTests.swift */,
0B96DC562A0AB91B00FF0499 /* MockNetworkSession.swift */,
0B96DC5B2A0AC99F00FF0499 /* NetworkControllerTests.swift */,
F2B5BC3F248836C600B6A52A /* Info.plist */,
);
path = Tests;
Expand Down Expand Up @@ -283,6 +295,7 @@
F211601B24884ABF00B48D52 /* Photo.swift in Sources */,
F2B5BC57248837CB00B6A52A /* NetworkController.swift in Sources */,
F2B5BC49248836F300B6A52A /* HTTPMethod.swift in Sources */,
0B96DC5A2A0AC7E800FF0499 /* NetworkSessionDataTask.swift in Sources */,
F2B5BC532488378200B6A52A /* RequestBehavior.swift in Sources */,
F2B5BC55248837B400B6A52A /* NetworkRequestPerformer.swift in Sources */,
F2B5BC27248836C500B6A52A /* AppDelegate.swift in Sources */,
Expand All @@ -293,6 +306,7 @@
F264BB5028BE9E1700969CD1 /* Publisher+Result.swift in Sources */,
F2B5BC2B248836C500B6A52A /* ContentView.swift in Sources */,
4C2CBFAC24D4AA9800920BE2 /* NetworkRequestPerformer+JSON.swift in Sources */,
0B96DC552A0AB67B00FF0499 /* NetworkSession.swift in Sources */,
F2B5BC4F2488376C00B6A52A /* NetworkRequest.swift in Sources */,
F2B5BC4D2488375C00B6A52A /* NetworkError.swift in Sources */,
);
Expand All @@ -303,6 +317,8 @@
buildActionMask = 2147483647;
files = (
F2B5BC3E248836C600B6A52A /* NetworkingTests.swift in Sources */,
0B96DC5C2A0AC99F00FF0499 /* NetworkControllerTests.swift in Sources */,
0B96DC572A0AB91B00FF0499 /* MockNetworkSession.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
20 changes: 10 additions & 10 deletions Sources/Networking/NetworkController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@ import Combine
/// A default concrete implementation of the `NetworkRequestPerformer`.
public final class NetworkController {

private let urlSession: URLSession
private let networkSession: NetworkSession
private let defaultRequestBehaviors: [RequestBehavior]

/// Initializes the `NetworkController`.
///
/// - Parameters:
/// - urlSession: The `URLSession` to use to make requests. Defaults to `URLSession.shared`.
/// - networkSession: The `NetworkSession` to use to make requests. Defaults to `URLSession.shared`.
/// - defaultRequestBehaviors: The request behaviors to apply to all requests made through this controller. Defaults to an empty array.
public init(urlSession: URLSession = .shared, defaultRequestBehaviors: [RequestBehavior] = []) {
self.urlSession = urlSession
public init(networkSession: NetworkSession = URLSession.shared, defaultRequestBehaviors: [RequestBehavior] = []) {
self.networkSession = networkSession
self.defaultRequestBehaviors = defaultRequestBehaviors
}

Expand All @@ -33,9 +33,9 @@ public final class NetworkController {
return urlRequest
}

private func makeDataTask(forURLRequest urlRequest: URLRequest, behaviors: [RequestBehavior] = [], successHTTPStatusCodes: HTTPStatusCodes, completion: ((Result<NetworkResponse, NetworkError>) -> Void)?) -> URLSessionDataTask {
private func makeDataTask(forURLRequest urlRequest: URLRequest, behaviors: [RequestBehavior] = [], successHTTPStatusCodes: HTTPStatusCodes, completion: ((Result<NetworkResponse, NetworkError>) -> Void)?) -> NetworkSessionDataTask {

return urlSession.dataTask(with: urlRequest) { data, response, error in
return networkSession.makeDataTask(with: urlRequest) { data, response, error in
let result: Result<NetworkResponse, NetworkError>

if let error = error {
Expand All @@ -61,11 +61,11 @@ extension NetworkController: NetworkRequestPerformer {

// MARK: - NetworkRequestPerformer

@discardableResult public func send(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior] = [], completion: ((Result<NetworkResponse, NetworkError>) -> Void)? = nil) -> URLSessionDataTask {
@discardableResult public func send(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior] = [], completion: ((Result<NetworkResponse, NetworkError>) -> Void)? = nil) -> NetworkSessionDataTask {
let behaviors = defaultRequestBehaviors + requestBehaviors

let urlRequest = makeFinalizedRequest(fromOriginalRequest: request.urlRequest, behaviors: behaviors)
let dataTask = makeDataTask(forURLRequest: urlRequest, successHTTPStatusCodes: request.successHTTPStatusCodes, completion: completion)
let dataTask = makeDataTask(forURLRequest: urlRequest, behaviors: behaviors, successHTTPStatusCodes: request.successHTTPStatusCodes, completion: completion)

dataTask.resume()

Expand All @@ -77,7 +77,7 @@ extension NetworkController: NetworkRequestPerformer {
let behaviors = defaultRequestBehaviors + requestBehaviors
let urlRequest = makeFinalizedRequest(fromOriginalRequest: request.urlRequest, behaviors: behaviors)

return urlSession.dataTaskPublisher(for: urlRequest)
return networkSession.dataTaskPublisher(for: urlRequest)
.mapError { NetworkError.underlyingNetworkingError($0) }
.tryMap { data, response in
if let statusCode = (response as? HTTPURLResponse)?.statusCode, !request.successHTTPStatusCodes.contains(statusCode: statusCode) {
Expand All @@ -101,7 +101,7 @@ extension NetworkController: NetworkRequestPerformer {

public func send(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior]) async throws -> NetworkResponse {
try await withCheckedThrowingContinuation { continuation in
send(request) { result in
send(request, requestBehaviors: requestBehaviors) { result in
switch result {
case let .success(response):
continuation.resume(returning: response)
Expand Down
4 changes: 2 additions & 2 deletions Sources/Networking/NetworkRequestPerformer+JSON.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ extension NetworkRequestPerformer {
/// - requestBehaviors: The behaviors to apply to the given request.
/// - decoder: The JSON decoder to use when decoding the data.
/// - completion: A completion closure that is called when the request has been completed.
/// - Returns: The `URLSessionDataTask` used to send the request. The implementation must call `resume()` on the task before returning.
@discardableResult public func send<ResponseType: Decodable>(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior] = [], decoder: JSONDecoder = JSONDecoder(), completion: ((Result<ResponseType, NetworkError>) -> Void)? = nil) -> URLSessionDataTask {
/// - Returns: The `NetworkSessionDataTask` used to send the request. The implementation must call `resume()` on the task before returning.
@discardableResult public func send<ResponseType: Decodable>(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior] = [], decoder: JSONDecoder = JSONDecoder(), completion: ((Result<ResponseType, NetworkError>) -> Void)? = nil) -> NetworkSessionDataTask {
send(request, requestBehaviors: requestBehaviors) { result in
switch result {
case let .success(response):
Expand Down
4 changes: 2 additions & 2 deletions Sources/Networking/NetworkRequestPerformer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ public protocol NetworkRequestPerformer {
/// - request: The request to perform.
/// - requestBehaviors: The behaviors to apply to the given request.
/// - completion: A completion closure that is called when the request has been completed.
/// - Returns: The `URLSessionDataTask` used to send the request. The implementation must call `resume()` on the task before returning.
@discardableResult func send(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior], completion: ((Result<NetworkResponse, NetworkError>) -> Void)?) -> URLSessionDataTask
/// - Returns: The `NetworkSessionDataTask` used to send the request. The implementation must call `resume()` on the task before returning.
@discardableResult func send(_ request: any NetworkRequest, requestBehaviors: [RequestBehavior], completion: ((Result<NetworkResponse, NetworkError>) -> Void)?) -> NetworkSessionDataTask

/// Returns a publisher that can be subscribed to, that performs the given request with the given behaviors.
/// - Parameters:
Expand Down
41 changes: 41 additions & 0 deletions Sources/Networking/NetworkSession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// NetworkSession.swift
// Networking
//
// Created by Michael Liberatore on 5/9/23.
// Copyright © 2023 Lickability. All rights reserved.
//

import Foundation
import Combine

/// Describes an object that coordinates a group of related, network data transfer tasks. This protocol has a similar API to `URLSession` for the purpose of mocking.
public protocol NetworkSession {

/// Creates a task that retrieves the contents of a URL based on the specified URL request object, and calls a handler upon completion.
/// - Parameters:
/// - request: A URL request object that provides the URL, cache policy, request type, body data or body stream, and so on.
/// - completionHandler: The completion handler to call when the load request is complete. This handler is executed on the delegate queue.
/// - Returns: The new session data task.
///
/// - Note: This documentation is pulled directly from `URLSession`.
func makeDataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> NetworkSessionDataTask

/// Returns a publisher that wraps a URL session data task for a given URL request.
///
/// The publisher publishes data when the task completes, or terminates if the task fails with an error.
/// - Parameter request: The URL request for which to create a data task.
/// - Returns: A publisher that wraps a data task for the URL request.
///
/// - Note: This documentation is pulled directly from `URLSession`.
func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher
}

extension URLSession: NetworkSession {

// MARK: - NetworkSession

public func makeDataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> NetworkSessionDataTask {
return dataTask(with: request, completionHandler: completionHandler)
}
}
20 changes: 20 additions & 0 deletions Sources/Networking/NetworkSessionDataTask.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// NetworkSessionDataTask.swift
// NetworkingTests
//
// Created by Michael Liberatore on 5/9/23.
// Copyright © 2023 Lickability. All rights reserved.
//

import Foundation

/// Describes a network session task that can be performed. This protocol has a similar API to `URLSessionDataTask` for the purpose of mocking.
public protocol NetworkSessionDataTask {

/// Resumes the task, if it is suspended.
///
/// - Note: This documentation is pulled directly from `URLSessionTask`.
func resume()
}

extension URLSessionDataTask: NetworkSessionDataTask { }
54 changes: 54 additions & 0 deletions Tests/MockNetworkSession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// MockNetworkSession.swift
// NetworkingTests
//
// Created by Michael Liberatore on 5/9/23.
// Copyright © 2023 Lickability. All rights reserved.
//

import Foundation
import Networking

/// A mocked version of `NetworkSession` to be used in tests. Allows specification of success or failure cases.
class MockNetworkSession: NetworkSession {
private let result: Result<Data, Error>

/// Creates a new `MockNetworkSession`.
/// - Parameter result: The expected result of network requests performed by this mock.
init(result: Result<Data, Error>) {
self.result = result
}

// MARK: - NetworkSession

func makeDataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> NetworkSessionDataTask {
return MockNetworkSessionDataTask { [weak self] in
switch self?.result {
case .success(let data):
let response = request.url.flatMap { HTTPURLResponse(url: $0, statusCode: 200, httpVersion: nil, headerFields: nil) }
completionHandler(data, response, nil)
case .failure(let error):
let response = request.url.flatMap { HTTPURLResponse(url: $0, statusCode: 0, httpVersion: nil, headerFields: nil) }
completionHandler(nil, response, error)
case .none:
assertionFailure("`MockNetworkSession` went out of scope. Keep a reference to it for the duration of your tests.")
}
}
}

func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher {
return URLSession.DataTaskPublisher(request: request, session: .shared) // not currently mocked.
}
}

private class MockNetworkSessionDataTask: NetworkSessionDataTask {
private let resumeClosure: () -> Void

init(closure: @escaping () -> Void) {
self.resumeClosure = closure
}

func resume() {
resumeClosure()
}
}
97 changes: 97 additions & 0 deletions Tests/NetworkControllerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//
// NetworkControllerTests.swift
// NetworkingTests
//
// Created by Michael Liberatore on 5/9/23.
// Copyright © 2023 Lickability. All rights reserved.
//

import XCTest
@testable import Networking

class NetworkControllerTests: XCTestCase {

func testAsyncAwaitSendWithFailure() async throws {
let networkController = NetworkController(networkSession: MockNetworkSession(result: .failure(NetworkError.noResponse)))

do {
let _: [Photo] = try await networkController.send(PhotoRequest.photosList, requestBehaviors: [])
XCTFail("Should’ve caught an error before reaching here.")
}
catch {
guard let networkError = error as? NetworkError else {
XCTFail("Encountered an unexpected error type")
return
}

switch networkError {
case .decodingError, .noData, .noResponse, .unsuccessfulStatusCode:
XCTFail("Encountered an unexpected NetworkError type")
case .underlyingNetworkingError(let underlyingError):
guard let underlyingNetworkError = underlyingError as? NetworkError else {
XCTFail("Encountered an unexpected underlying error type")
return
}

XCTAssertEqual(underlyingNetworkError.failureReason, NetworkError.noResponse.failureReason)
}
}
}

func testAsyncAwaitSendWithSuccess() async throws {
let photos: [Photo] = [
Photo(albumId: 1, id: 1, title: "1", url: "", thumbnailUrl: ""),
Photo(albumId: 1, id: 2, title: "1", url: "", thumbnailUrl: ""),
Photo(albumId: 1, id: 3, title: "1", url: "", thumbnailUrl: "")
]

let encoder = JSONEncoder()
do {
let data = try encoder.encode(photos)
let networkController = NetworkController(networkSession: MockNetworkSession(result: .success(data)))
let photosResponse: NetworkResponse = try await networkController.send(PhotoRequest.photosList, requestBehaviors: [])
let decodedData = photosResponse.data

XCTAssertEqual(data, decodedData)
}
catch {
XCTFail("Unexpected error occurred: \(error).")
}
}

func testAsyncAwaitBehaviors() async throws {
let networkController = NetworkController(networkSession: MockNetworkSession(result: .failure(NetworkError.noResponse)))

var requestWillSendWasCalled = false
var requestDidFinishWasCalled = false

let behavior = TestBehavior {
requestWillSendWasCalled = true
XCTAssertFalse(requestDidFinishWasCalled, "We should’ve reached this point before `requestDidFinishWasCalled` became true.")
} didFinishClosure: {
requestDidFinishWasCalled = true
}

do {
let _: [Photo] = try await networkController.send(PhotoRequest.photosList, requestBehaviors: [behavior])
XCTFail("Should’ve caught an error before reaching here.")
}
catch {
XCTAssertTrue(requestWillSendWasCalled)
XCTAssertTrue(requestDidFinishWasCalled)
}
}
}

private struct TestBehavior: RequestBehavior {
let willSendClosure: () -> Void
let didFinishClosure: () -> Void

func requestWillSend(request: inout URLRequest) {
willSendClosure()
}

func requestDidFinish(result: Result<NetworkResponse, NetworkError>) {
didFinishClosure()
}
}

0 comments on commit 0489b62

Please sign in to comment.