在没有现成后端 API 的情况下开发 iOS 应用程序

在这里插入图片描述
在客户端开发过程中,希望有一个稳定的后端API。Internet 上的大多数教程都使用现成的 API,例如MovieDB或OpenWeatherAPI。但在现实世界中,您并不总是拥有现成可用的后端 API。后端和客户端团队都可以在敏捷开发过程中同时开发一个特性。

即使您的后端 API 已准备好使用,API 也可能存在一些稳定性/可访问性问题。几年前,我在一家大银行的测试环境中经历了几个多星期的稳定性问题。我几乎无法从测试环境中收到成功的响应。

我能听到你说“所以在这种情况下我无能为力。我必须等待开发过程”。你没有错,但是在现实生活的发展过程中,你需要想办法继续下去。

如果我对你说“在这种情况下你不必停止开发”怎么办?

答案很简单,你们中的大多数人可能都听说过,测试驱动开发 (TDD)。

TDD(测试驱动开发)

“测试驱动开发( TDD ) 是一种软件开发过程,它依赖于在软件完全开发之前将软件需求转换为测试用例,并通过针对所有测试用例反复测试软件来跟踪所有软件开发。”

正如维基百科在上面的句子中明确解释的那样,在 TDD 过程中,首先编写测试,然后实现实际代码。在常规开发过程中,任何客户端开发人员都会检查 API 文档并根据该 API 设计应用程序。因此,如果后端 API 尚未准备好使用或在开发时不可用,我们可以使用单元测试在客户端中模拟相同的请求和响应。

承认吧,你们中的一些人已经这样回应了 🙂 让我们想象一下。假设我们正在从 API 获取天气数据。常规流程是通过休息请求请求天气数据,然后期望有一个包含天气数据的 JSON 响应。

在这里插入图片描述
让我们看看在单元测试中如何在没有后端 API 的情况下实现这一目标。在单元测试中,您应该模拟您的网络层并返回您的预期响应。这里的嘲讽术语表示“不要使用实际的网络层,使用你在测试中实现的假返回机制。”。

在这里插入图片描述
所以,如果我能以某种方式返回预期的响应,那么我就不需要在没有后端的情况下停止开发。

测试驱动的 iOS 开发

至此,我们了解了 TDD 背后的逻辑。让我们看看我们如何在 iOS 开发中做到这一点。

设计依赖注入网络层

在这里插入图片描述
简而言之,依赖注入是一种创建独立于依赖项的对象的技术。从单元测试的角度来看,依赖注入允许我们使用模拟数据。您可以将此技术想象成类似于将间谍派往您想要发现和测量的未知领域。为了能够向某个领土派遣间谍,该国家首先需要允许该人旅行。同样,如果我们希望我们的模拟对象在测试中使用,我们首先需要允许我们的模拟对象通过依赖注入传递到真实对象中。

不动手就学不到任何东西。让我们动手做一个示例项目。

国家应用

您可以在 Internet 上找到很多免费使用的 API,出于本教程的目的,我选择Countries API。您可以获取与国家/地区相关的数据,例如;名称、区域、标志等。让我们创建一个新的 Xcode 项目并添加两个名为 Networking 和 Scenes 的文件夹。

在这里插入图片描述
好的开始!在网络层,我们需要有一个执行 API 请求的类,我们称之为RequestHandler。如您所知,API 可以为数十个甚至数百个(可能更多)端点提供服务。但是这种做法的简单性让我们专注于 2 个 API;

获取所有可用国家的列表 (getAllCountries)
查询国家详情(queryCountryByName)
要管理这些端点,让我们创建一个名为APIRoute的枚举文件。APIRoute 将以易于使用的方式创建端点。

在这里插入图片描述
让我们开始创建 APIRoute。

import Foundation

enum APIRoute {
    // Endpoints that view layers will call.
    case getAllCountries
    case querryCountryByName(name:String)

    // This func creates request with the given parameters.
    func asRequest() -> URLRequest? {
        return nil
    }
}

因此,正如我们所同意的那样,我们创建了一个名为 APIRoute 的枚举。此时,我们将只添加枚举案例和asRequest方法头,而没有任何实际实现。因为我们正在按照 TDD 建议的方式进行开发。现在我们可以实现 RequestHandler。

import Foundation

protocol RequestHandling {
    func request<T>(service: APIRoute, completion: @escaping (Result<T, APIError>) -> Void) where T:Decodable
}

/// Service Provider manages URLSession process
import Foundation

protocol RequestHandling {
    func request<T>(service: APIRoute, completion: @escaping (Result<T, APIError>) -> Void) where T:Decodable
}

/// RequestHandler manages URLSession process
class RequestHandler: RequestHandling {
    var urlSession:URLSession

    init(urlSession: URLSession = .shared ) {
        self.urlSession = urlSession
    }

    /// Starts resuest flow given service with required parameters and returns result in completion block.
    /// - Parameters:
    ///   - service: Service Type
    ///   - decodeType: Decoder Type to return response
    ///   - completion: Completion with Service Result
    func request<T>(service: APIRoute, completion: @escaping (Result<T, APIError>) -> Void) where T : Decodable {
        // There is no actual implemantation so far.
    }
}

/// Customized APIErrors for the app
enum APIError: Error {
    case jsonConversionFailure
    case invalidData
    case invalidRequest
    case responseUnsuccessful(Error)
    var localizedDescription: String {
        switch self {
        case .invalidData: return "Invalid Data"
        case .responseUnsuccessful: return "Response Unsuccessful"
        case .invalidRequest: return "Invalid Request Type"
        case .jsonConversionFailure: return "JSON Conversion Failure"
        }
    }
}

首先,我们添加RequestHandling协议来定义函数头。然后我们添加 RequestHandler 类。RequestHandler 类必须实现请求功能,但此时,我们只添加标头。

您可能意识到 init 函数默认获取 URLSession。这是依赖注入的示例。我们允许稍后实现的上层可以注入它们自己的 URLSession。让我们现在添加服务。

此类的最后一部分有一个用于轻松管理错误的枚举。

所以我们完成了我们的依赖注入网络层。让我们在QuickType的帮助下创建响应模型。

import Foundation

// MARK: - AllCountriesResponseModelElement
struct AllCountriesResponseModelElement: Codable {
    let name: Name?
    let tld: [String]?
    let cca2, ccn3, cca3, cioc: String?
    let independent: Bool?
    let status: Status?
    let unMember: Bool?
    let idd: Idd?
    let capital, altSpellings: [String]?
    let region: Region?
    let subregion: String?
    let languages: [String: String]?
    let translations: [String: Translation]?
    let latlng: [Double]?
    let landlocked: Bool?
    let borders: [String]?
    let area: Double?
    let demonyms: Demonyms?
    let flag: String?
    let maps: Maps?
    let population: Int?
    let gini: [String: Double]?
    let fifa: String?
    let car: Car?
    let timezones: [String]?
    let continents: [Continent]?
    let flags, coatOfArms: CoatOfArms?
    let startOfWeek: StartOfWeek?
    let capitalInfo: CapitalInfo?
    let postalCode: PostalCode?
}

enum Side: String, Codable {
    case sideLeft = "left"
    case sideRight = "right"
}

enum Continent: String, Codable {
    case africa = "Africa"
    case antarctica = "Antarctica"
    case asia = "Asia"
    case europe = "Europe"
    case northAmerica = "North America"
    case oceania = "Oceania"
    case southAmerica = "South America"
}

// MARK: - Aed
struct Aed: Codable {
    let name, symbol: String?
}

// MARK: - BAM
struct BAM: Codable {
    let name: String?
}

enum Region: String, Codable {
    case africa = "Africa"
    case americas = "Americas"
    case antarctic = "Antarctic"
    case asia = "Asia"
    case europe = "Europe"
    case oceania = "Oceania"
}

enum StartOfWeek: String, Codable {
    case monday = "monday"
    case sunday = "sunday"
    case turday = "turday"
}

enum Status: String, Codable {
    case officiallyAssigned = "officially-assigned"
    case userAssigned = "user-assigned"
}

typealias AllCountriesResponseModel = [AllCountriesResponseModelElement]
import Foundation

// MARK: - CountryResponseModelElement
struct CountryResponseModelElement: Codable {
    let name: Name?
    let tld: [String]?
    let cca2, ccn3, cca3, cioc: String?
    let independent: Bool?
    let status: String?
    let unMember: Bool?
    let idd: Idd?
    let capital, altSpellings: [String]?
    let region, subregion: String?
    let languages: Languages?
    let translations: [String: Translation]?
    let latlng: [Int]?
    let landlocked: Bool?
    let borders: [String]?
    let area: Int?
    let demonyms: Demonyms?
    let flag: String?
    let maps: Maps?
    let population: Int?
    let gini: Gini?
    let car: Car?
    let timezones, continents: [String]?
    let flags, coatOfArms: CoatOfArms?
    let startOfWeek: String?
    let capitalInfo: CapitalInfo?
    let postalCode: PostalCode?
}

// MARK: - CapitalInfo
struct CapitalInfo: Codable {
    let latlng: [Double]?
}

// MARK: - Car
struct Car: Codable {
    let signs: [String]?
    let side: String?
}

// MARK: - CoatOfArms
struct CoatOfArms: Codable {
    let png: String?
    let svg: String?
}

// MARK: - Gbp
struct Gbp: Codable {
    let name, symbol: String?
}

// MARK: - Demonyms
struct Demonyms: Codable {
    let eng, fra: Eng?
}

// MARK: - Eng
struct Eng: Codable {
    let f, m: String?
}

// MARK: - Gini
struct Gini: Codable {
    let the2017: Double?

    enum CodingKeys: String, CodingKey {
        case the2017 = "2017"
    }
}

// MARK: - Idd
struct Idd: Codable {
    let root: String?
    let suffixes: [String]?
}

// MARK: - Languages
struct Languages: Codable {
    let eng: String?
}

// MARK: - Maps
struct Maps: Codable {
    let googleMaps, openStreetMaps: String?
}

// MARK: - Name
struct Name: Codable {
    let common, official: String?
    let nativeName: NativeName?
}

// MARK: - NativeName
struct NativeName: Codable {
    let eng: Translation?
}

// MARK: - Translation
struct Translation: Codable {
    let official, common: String?
}

// MARK: - PostalCode
struct PostalCode: Codable {
    let format, regex: String?
}

typealias CountryResponseModel = [CountryResponseModelElement]

所以我们有一个没有实际逻辑的可测试网络层。这意味着我们可以开始编写单元测试了。我们开始做吧!

在CountriesAppTests组、TestUtilities和ServiceTests下创建 2 个组。

在这里插入图片描述
在TestUtilities组中创建一个名为JSONTestHelper的文件并添加以下代码。

import Foundation

/// This is a helper class for Unit Tests to load required response
class JSONTestHelper {

    /// Reads local json file from test resources
    /// - Parameter name: File name without extension
    /// - Returns: Data represantation of file
    func readLocalFile(name: String) -> Data? {
        do {
            let bundle = Bundle(for: type(of: self))
            if let filePath = bundle.path(forResource: name, ofType: "json"){
                let jsonData = try String(contentsOfFile: filePath).data(using: .utf8)
                return jsonData
            }
        } catch {
            fatalError("Failed to get json")
        }
        return nil
    }

    /// Decodes given jsonData to desired object
    /// - Parameters:
    ///   - decodeType: Generic Decodable type
    ///   - jsonData: JSON Data
    /// - Returns: Generic Decodable Type
    func decode<T>(decodeType:T.Type, jsonData:Data) -> T where T:Decodable {
        let decoder = JSONDecoder()

        do {
            let response = try decoder.decode(T.self, from: jsonData)
            return response
        } catch {
            fatalError("Failed to get decodable type")
        }
    }

    /// Reads json file and converts it to desired object
    /// - Parameters:
    ///   - decodeType: Generic Decodable type
    ///   - name: File name without extension
    /// - Returns: Generic Decodable Type
    func readAndDecodeFile<T>(decodeType:T.Type, name: String) -> T where T:Decodable {
        guard let data = readLocalFile(name: name) else {
            fatalError("Data is nil")
        }
        return decode(decodeType: decodeType, jsonData: data)
    }
}

这个助手的目的非常简单。我们将在测试中读取本地 JSON 响应,这个助手使我们的工作变得简单。

在TestUtilities组中创建另一个名为MockURLProtocol的文件并添加以下代码。

import Foundation

class MockURL: URLProtocol {
    static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))?

    override class func canInit(with request: URLRequest) -> Bool {
      // To check if this protocol can handle the given request.
      return true
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
      // Here you return the canonical version of the request but most of the time you pass the orignal one.
      return request
    }

    override func startLoading() {

        guard let handler = MockURLProtocol.requestHandler else {
           fatalError("Handler is unavailable.")
         }

         do {
           // 2. Call handler with received request and capture the tuple of response and data.
           let (response, data) = try handler(request)

           // 3. Send received response to the client.
           client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)

           if let data = data {
             // 4. Send received data to the client.
             client?.urlProtocol(self, didLoad: data)
           }

           // 5. Notify request has been finished.
           client?.urlProtocolDidFinishLoading(self)
         } catch {
           // 6. Notify received error.
           client?.urlProtocol(self, didFailWithError: error)
         }
    }

    override func stopLoading() {
      // This is called if the request gets canceled or completed.
    }
}

顾名思义,此类的目的是创建一个模拟 URLRequest 类以在我们的测试中使用。我们只是在这里实现基础框架的 URLProtocol 类。使用这个模拟类,我们不会发送实际请求,我们可以在测试中返回预期的响应。现在我们准备好了,让我们编写一些单元测试。

所有国家列表

让我们从所有国家列表 API 开始。在CountriesAppTests中创建一个名为ServiceTests的新组,然后在其中创建一个名为AllCountries的新组。现在创建一个名为AllCountriesSuccessResponse.json的空文件,并放入来自 API的实际响应。如果我将来自 AllCountries 响应的原始响应放在 Medium 中,你将无法阅读这篇文章,因为它有超过 50.000 行。我将从中获得 3 个国家,现在已经足够了。

现在我们可以创建实际的测试类了。在 AllCountries 组中双击创建文件并选择“单元测试用例类”,如下图所示。然后将其命名为AllCountriesServiceTests。

在这里插入图片描述
Xcode 自动向我们的测试类添加一些样板函数,如下所示:

import XCTest

final class AllCountriesServiceTests: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func testExample() throws {
        // This is an example of a functional test case.
        // Use XCTAssert and related functions to verify your tests produce the correct results.
        // Any test you write for XCTest can be annotated as throws and async.
        // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
        // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
    }

    func testPerformanceExample() throws {
        // This is an example of a performance test case.
        self.measure {
            // Put the code you want to measure the time of here.
        }
    }

}

注释解释功能非常清楚,但让我们回顾一下功能。

setupWithError将在每次测试之前调用。如果我们想在每次测试执行之前准备一些东西,我们应该放入这个函数。
tearDownWithError将在每次测试后调用。如果我们想在每次测试执行后清除一些东西,我们应该放入这个函数。
testExample是我们的测试功能。这是我们添加每个测试用例的地方。
testPerformanceExample是一个示例测试,用于测量执行期间花费的时间。
让我们像这样修改测试类。

// 1
import XCTest
@testable import Countries

final class AllCountriesServiceTests: XCTestCase {
    // 2 
    var sut: RequestHandling!
    // 3 
    var expectation: XCTestExpectation!
    // 4
    let apiURL = URL(string: "https://restcountries.com/v3.1/all?")!

    override func setUpWithError() throws {
        // 5
        let configuration = URLSessionConfiguration.default
        configuration.protocolClasses = [MockURLProtocol.self]
        let urlSession = URLSession.init(configuration: configuration)
        // 6
        sut = RequestHandler(urlSession: urlSession)
        // 7
        expectation = expectation(description: "Test Expectation")
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    // 8
    func test_givenAllCountriesRequest_whenResponseSuccessfull_thenShouldContainRequiredResponseModel() throws {
        // 9
        let successData = JSONTestHelper().readLocalFile(name: "AllCountriesResponse")

        // 10
        MockURLProtocol.requestHandler = { request in
            guard let url = request.url, url == self.apiURL else {
        // 11
                throw fatalError("URLS are not matching. Expected: \(self.apiURL), Found: \(request.url)")
          }
        // 12
          let response = HTTPURLResponse(url: self.apiURL, statusCode: 200, httpVersion: nil, headerFields: nil)!
          return (response, successData)
        }

        // 13
        sut.request(service: .getAllCountries) {
            (result : Result<AllCountriesResponseModel, APIError>) in
            switch result {

            case .success(let response):
            // 14
                XCTAssertEqual(response.count, 3, "Total number of countries should be 250")
                let firstCountry = response[0]
                XCTAssertEqual(firstCountry.name?.common, "Bulgaria", "First country name is not matching")
                XCTAssertEqual(firstCountry.region, "Europe", "First country region is not matching")
                XCTAssertEqual(firstCountry.startOfWeek, "monday", "Start of the week not matching")
            case .failure(let error):
            // 15
                XCTFail("Error was not expected: \(error.localizedDescription)")
            }
            // 16
            self.expectation.fulfill()
        }
        // 17
        wait(for: [expectation], timeout: 1.0)

    }

    // 18
    func test_givenAllCountriesRequest_whenResponseFails_thenShouldReturnFail() throws {

        MockURLProtocol.requestHandler = { request in
         // 19 For error case we can use empty data
            let emptyData = Data()
            let response = HTTPURLResponse(url: self.apiURL, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (response, emptyData)
        }

        // 20
        sut.request(service: .getAllCountries) {
            (result : Result<AllCountriesResponseModel, APIError>) in
            switch result {

            case .success(_):
            // 21
                XCTFail("Success was not expected")
            case .failure(let error):
            // 22
                XCTAssertEqual(error.localizedDescription, APIError.jsonConversionFailure.localizedDescription)
            }
            // 23
            self.expectation.fulfill()
        }
        // 24
        wait(for: [expectation], timeout: 1.0)
    }

}

上面有很多代码。让我们通过评论号一一调查。

我们应该添加import XCTTest使用 XCTest 框架。@testable import 此外,我们应该将应用程序导入为具有格式的可测试软件。
对我们要测试的变量使用“ sut ”(被测软件)。这是突出我们的测试范围的常见做法。在这个测试类中,我们专注于 RequestHandling 协议的测试,这就是为什么它的引用被命名为sut的原因。
期望是 XCTest 框架的强大工具。如果我们想要测试异步代码,期望允许我们等待它完成。这对于完成块尤其有用。
这是我们要发送请求的 url 路径。在我们的测试中,我们将使用它来验证’我们是否将请求发送到正确的路径?’ 题。
这是我们的MockURLProtocol 发挥魔力的地方。首先,我们使用默认属性创建URLSessionConfiguration 。然后我们说 URLSessionConfiguration 将使用我们的 MockURLProtocol 作为protocolClass。因此,这将导致所有请求都传递给 MockURLProtocol 类(在 MockURLProtocol 中,我们表现得好像它已发送到互联网,但实际上并非如此)。之后,我们将urlSession使用该配置创建该配置。
使用刚刚创建的 urlSession(我们可以说它是模拟 urlSession)初始化 ReqesutHandler。
使用描述初始化期望。
test我们必须在 XCTTest 中以前缀开始测试函数,这是强制性的。我们可以只保留测试函数名称,testExample但这不是推荐的方式。一个项目中可以有数百个测试用例。如果其中任何一个测试失败,我们应该很快了解原因。使用Given-When-Then 模式定义测试函数的推荐方法。对于这个例子,我们说“给所有国家请求”,“当我们收到成功的响应时”,“然后期望看到所需的响应模型”。这是理解测试用例的好模式。
我们正在使用 JSONTestHelper 来加载我们使用示例成功响应创建的 json 文件。
此时我们正在模拟请求。当 URLSession 应该发送请求时,将触发此块。
此时我们只是确保 URL 路径是正确的。如果我们使用正确的枚举大小写调用 APIRoute,这应该与我们在测试类开头定义的 apiURL 相匹配。
我们正在使用从 json 加载的示例成功数据创建成功响应。
现在我们用 sut 对象调用实际函数。此时开始执行实际代码。
单元测试应该有断言来验证输出是对还是错。我们的request函数应该联系 API 并接收 json 数据,然后它应该给我们解码的响应(AllCountriesResponseModel)。如果请求函数正常工作,我们应该拥有具有所需属性的 AllCountriesResponseModel 对象。如名称、地区或 startOfTheWeek。所以我们可以断言这些属性并期望它们等于我们提供的 JSON 数据。例如,我们提供了一个包含 3 个对象的 JSON 数组,因此响应计数应为 3。我们在 JSON 中拥有的第一个国家/地区是保加利亚,因此该数组的第一个对象的通用名称应为保加利亚。使用这种方法,我们可以断言和验证多个属性。
成功的 JSON 数据不应该有错误结果。XCTFail 确保我们不应该在我们期望获得成功结果的地方收到失败结果。
当我们收到一个失败或成功的案例时,我们应该履行期望。XCTest 应满足所有期望。如果没有达到预期,XCode 将向我们显示一条失败消息。
现在我们说我们希望这个测试应该等待 1 秒,并在 1 秒完成之前收到一个 fulfill。通过这一步和上面的步骤,我们允许 XCTest 等待异步操作完成。
在这一步,我们正在创建另一个测试用例。我们为成功案例创建了一个测试,现在应该至少有一个失败案例。我们再次使用 Given-When-Then 模式。
我们再次使用 MockProtocol 来模拟 URLSession。唯一的区别是我们不需要成功的数据。我们可以只传递空数据来使案例失败。如果提供的数据为空,我们的函数应该会出现 json 对话失败。
我们再次使用 sut 对象调用实际函数。
在这种情况下,我们不期望成功响应。如果我们收到成功响应,我们应该使用 XCTFail 使测试失败。
我们应该断言错误消息以验证我们是否正确。在这个测试中我们提供了空数据,所以我们应该有jsonConversationFailure错误信息。
再次满足要求。
再次等待期待。
如果你到达这一点,你就是英雄!

现在是运行测试的时候了。在 Xcode 中运行测试很容易。在每个类定义行中,您将看到一个菱形。这是运行该类中所有测试用例的按钮。此外,每个测试都有一个运行按钮来运行单个测试用例。

在这里插入图片描述
运行测试后,您将看到一个带有“测试失败”消息的红色屏幕,如下所示。
在这里插入图片描述
不要担心这是意料之中的,因为我们还没有在应用程序中实现任何实际逻辑。现在让我们实现逻辑。

首先更改 APIRoute 枚举,如下所示:

import Foundation

enum APIRoute {
    // Endpoints that view layers will call.
    case getAllCountries
    case querryCountryByName(name:String)

    // Base url of the Countries API
    private var baseURLString: String { "https://restcountries.com/v3.1/" }

    // Computed property for URL generation
    // Required parameters appending to this property.
    private var url: URL? {
        switch self {
        case .getAllCountries:
            // Adding 'all' to the base url provide us the path we want.
            // https://restcountries.com/v3.1/all
            return URL(string: baseURLString + "all")
        case .querryCountryByName(name: let name):
            //TODO: This is incomplete now
            return URL(string: "")
        }
    }

    // URL body params. (In this article we won't use it)
    private var parameters: [URLQueryItem] {
        switch self {
        default:
            return []
        }
    }

    // This func creates request with the given parameters.
    // We will use this request in the RequestHandler to execute requests.
    func asRequest() -> URLRequest? {
        guard let url = url else {
            print("Missing URL for route: \(self)")
            return nil
        }

        var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
        components?.queryItems = parameters

        guard let parametrizedURL = components?.url else {
            print("Missing URL with parameters for url: \(url)")
            return nil
        }

        return URLRequest(url: parametrizedURL)
    }
}

我认为上面的代码很简单。计算url属性将创建所需的路径。asRequest函数将使用给定的 URL 路径创建所需的 URLRequest。我们现在可以使用 asRequest 函数来创建所需的 URLRequest。

现在是更新 RequestHandler 的时候了:

import Foundation

protocol RequestHandling {
    func request<T>(service: APIRoute, completion: @escaping (Result<T, APIError>) -> Void) where T:Decodable
}

/// Service Provider manages URLSession process
class RequestHandler: RequestHandling {
    var urlSession:URLSession

    init(urlSession: URLSession = .shared ) {
        self.urlSession = urlSession
    }

    /// Starts resuest flow given service with required parameters and returns result in completion block.
    /// - Parameters:
    ///   - service: Service Type
    ///   - decodeType: Decoder Type to return response
    ///   - completion: Completion with Service Result
    func request<T>(service: APIRoute, completion: @escaping (Result<T, APIError>) -> Void) where T : Decodable {
      // 1. Create the request using APIRoute 
     guard let request = service.asRequest() else {
      // 2. Return error in case we don't have such request
            completion(.failure(.invalidRequest))
            return
        }
      // 3. Call the execute function to execute the request.
        execute(request) { result in
            switch result {
            case .success(let data):
                let decoder = JSONDecoder()
                do {
            // 4. Decode the success response with generic Decodable.
                    let response =  try decoder.decode(T.self, from: data)
                    print("Successfull response received, response:\(response)")
             // 5. Return the success completion
                    completion(.success(response))
                }
                catch let error{
                    print("Failed to decode received data :\(error)")
            // 6. Return the fail completion with jsonConversionFailure
                    completion(.failure(.jsonConversionFailure))
                }
            case .failure(let error):
            // 7. Return the fail completion with error we receive from execute function.
                completion(.failure(error))
            }
        }
    }

    /// Executes given request.
    /// - Parameters:
    ///   - request: URLRequest
    ///   - deliveryQueue: DispatchQueue of the request, default is main.
    ///   - completion: Completion block.
    private func execute(_ request:URLRequest,
                             deliveryQueue:DispatchQueue = DispatchQueue.main,
                             completion: @escaping ((Result<Data, APIError>) -> Void)) {

        // 8. Start the data task using URLSession
        urlSession.dataTask(with: request) { data, response , error in

            if let error = error {
                deliveryQueue.async{
                    print("Error recevied on request, error:\(error)")
            // 9. In case we receive error from API return with responseUnsuccessful error
                    completion(.failure(.responseUnsuccessful(error)))
                }
            }else if let data = data {
                deliveryQueue.async{
                    completion(.success(data))
                }
            }else {
                deliveryQueue.async{
                    print("Invalid data received, response:\(response)")
            // 11. In case we don't receive a data return with invalidData error
                    completion(.failure(.invalidData))
                }
            }
        }.resume()
    }

}

/// Customized APIErrors for the app
enum APIError: Error {
    case jsonConversionFailure
    case invalidData
    case invalidRequest
    case responseUnsuccessful(Error)
    var localizedDescription: String {
        switch self {
        case .invalidData: return "Invalid Data"
        case .responseUnsuccessful: return "Response Unsuccessful"
        case .invalidRequest: return "Invalid Request Type"
        case .jsonConversionFailure: return "JSON Conversion Failure"
        }
    }
}

我在上面的代码中添加了所需的注释。简而言之,我们现在有一个用于网络请求的实际工作类。所以我们已经实现了实际的request功能,现在让我们重新运行测试。这是结果。

在这里插入图片描述
如您所见,我们的测试现已通过。如果我们的代码以任何方式不正确,我们将看不到绿色结果。

当所有的测试都是绿色的时候,生活是美好的!

国家详情

我们已经完成了所有国家列表的测试和实施部分。现在是时候对国家详细信息进行相同的练习了。别担心,它会更快。

在ServiceTests中创建一个名为CountryDetaillsTests的新组。现在创建一个名为DetailSuccessResponse.json的空文件并放入来自 API的实际响应。
现在我们可以创建测试类了。我们称它为DetailsS​​erviceTests并将下面的代码放入其中。

import XCTest
@testable import Countries

final class DetailsServiceTests: XCTestCase {

    var sut: RequestHandling!
    var expectation: XCTestExpectation!

    let apiURL = URL(string: "https://restcountries.com/v3.1/name/turkey?")!
    let countryName = "turkey"

    override func setUpWithError() throws {
        // Configuration required to Mock API requests.
        let configuration = URLSessionConfiguration.default
        configuration.protocolClasses = [MockURLProtocol.self]
        let urlSession = URLSession.init(configuration: configuration)

        sut = RequestHandler(urlSession: urlSession)
        expectation = expectation(description: "Expectation")
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    /// In this case we are inserting success data via mock and expect decoded response.
    func test_givenDetailsRequest_whenResponseSuccessfull_thenShouldContainRequiredResponseModel() throws {
        // Name of the sample response
        let data = JSONTestHelper().readLocalFile(name: "DetailSuccessResponse")

        MockURLProtocol.requestHandler = { request in
            guard let url = request.url, url == self.apiURL else {
                throw fatalError("URLS are not matching. Expected: \(self.apiURL), Found: \(request.url)")
          }

          let response = HTTPURLResponse(url: self.apiURL, statusCode: 200, httpVersion: nil, headerFields: nil)!
          return (response, data)
        }

        // Start test by calling the actual function
        sut.request(service: .querryCountryByName(name: countryName)) {
            (result : Result<CountryResponseModel, APIError>) in

            switch result {

            case .success(let response):
                XCTAssertEqual(response.first?.name?.common, "Turkey", "Common names are not matching")
                XCTAssertEqual(response.first?.name?.official, "Republic of Turkey", "Official names are not matching")
            case .failure(let error):
                XCTFail("Error was not expected: \(error.localizedDescription)")
            }
            self.expectation.fulfill()
        }
        wait(for: [expectation], timeout: 1.0)
    }

    /// In this case we are inserting fail case via mock and expect fail return.
    func test_givenDetailsRequest_whenResponseFailed_thenShouldReturnFail() throws {
        // For error case we can use empty data
        let data = Data()

        MockURLProtocol.requestHandler = { request in
            let response = HTTPURLResponse(url: self.apiURL, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (response, data)
        }

        sut.request(service: .querryCountryByName(name: countryName)) {
            (result : Result<AllCountriesResponseModel, APIError>) in
            switch result {

            case .success(_):
                XCTFail("Success was not expected")
            case .failure(let error):
                XCTAssertEqual(error.localizedDescription, APIError.jsonConversionFailure.localizedDescription)
            }
            self.expectation.fulfill()
        }

        wait(for: [expectation], timeout: 1.0)
    }

}

您已经注意到步骤几乎相同。唯一的区别是我正在使用.querryCountryByNameAPIRoute 并期望看到CountryResponseModel解码后的响应。让我们运行测试。

在这里插入图片描述
Xcode 大喊我们的测试失败了,因为当我期待一个成功的结果时,我得到了一个失败的结果。错误是“无效的请求类型”。请记住,我们在 RequestHandler 中抛出此错误,以防我们 APIRoute 的 asRequest 函数无法创建请求。让我们看一下 APIRoute 的计算 URL 参数。

    private var url: URL? {
        switch self {

        case .getAllCountries:
            return URL(string: baseURLString + "all")
        case .querryCountryByName(name: let name):
        //TODO: This is incomplete now
            return URL(string: "")
        }
    }
你看到 TODO 评论了吗?我们还没有定义 queryCountryByName 案例的路径。让我们解决这个问题
    private var url: URL? {
        switch self {

        case .getAllCountries:
            return URL(string: baseURLString + "all")
        case .querryCountryByName(name: let name):
            return URL(string: baseURLString + "name/" + name)
        }
    }
 ```

   让我们再运行一​​次测试。

   ![在这里插入图片描述](https://img-blog.csdnimg.cn/d5acbaf65b8449479420d386179accf9.png)
我们有详细的成功结果🥳

# 结论
这种做法对我们来说是一次漫长的教育之旅。但相信我,今天您开始学习开发 iOS 应用程序的宝贵方法。在接下来的文章中,我将继续使用 TDD 方法开发此应用程序。

https://github.com/emndeniz/Countries

发表回复