如何在Swift中模拟对象

5 篇文章 0 订阅

对于任何一门编程语言,当你编写单元测试时,模拟对象(Mock Object)都是一门关键的技术。 在模拟对象时,我们实际上是在创建它的一个“假”的版本,这个假的对象使用与真实对象相同的API,这让我们更容易地在测试用例中进行断言(Assert)和验证结果。

无论我们是在测试网络代码,或则测试依赖于加速度计等硬件传感器的代码,还是测试使用位置服务等系统API的代码,对象模拟都可以让我们更轻松地编写测试,并以更可可靠的方式,更快地运行这些测试。

但是,也存在可能不需要进行对象模拟的情况。例如有时候,在我们的测试中需要包含真实对象,以便让我们编写在实际条件下运行的测试。 现在,我们来看看模拟对象的几种不同情况,什么时候应该使用模拟?什么时候应该避免它?来使我们的测试更容易编写,读取和运行。

为什么需要模拟对象(Mock Object)?

首先,让我们来看一下一个实际的例子,假设我们正在构建一个NetworkManager,它允许我们从给定的URL加载数据:

class NetworkManager {
    func loadData(from url: URL,
                  completionHandler: @escaping (NetworkResult) -> Void) {
        let task = URLSession.shared.dataTask(with: url) { data, _, error in
            // 创建并返回Result枚举对象值 .success 或者 .failure 
            let result = data.map(NetworkResult.success) ?? .failure(error)
            completionHandler(result)
        }

        task.resume()
    }
}

现在,我们要编写测试来验证在正确的情况下返回.success和.error。 要做到这一点,我们可以简单地调用我们的loadData API并等待返回结果,但这两者都要求我们的测试使用Internet连接运行,这样测试运行起来会慢很多(因为我们必须等待 要求执行真正的请求)。

现在,让我们使用Mock来代替真实的API请求。 我们在这里要做的是,让NetworkManager在我们的测试代码中使用假会话(Session),这个假会话不会通过网络执行任何请求,而是让我们准确地控制网络的行为方式。

局部模拟

对象模拟有两种不同的风格 - 局部模拟完全模拟。 在进行局部模拟时,我们修改现有类型,以便在测试中仅部分表现不同,而在完全模拟时,您将替换整个实现。

如果我们想局部模拟它返回的URLSession和URLSessionDataTask,我们可以创建实际对象的子类,每个子类都覆盖我们期望被调用的方法,以便返回我们可以在测试中控制的特定结果。 让我们从创建一个模拟数据任务开始,该任务在回调时只运行一个闭包:

// 我们通过继承原类来创建一个部分模拟的子类
class URLSessionDataTaskMock: URLSessionDataTask {
    private let closure: () -> Void

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

    // 重载‘resume’方法,直接调用回调closure
    override func resume() {
        closure()
    }
}

现在让我们对URLSession做同样的事情,但是这次我们将覆盖dataTask方法以返回我们的模拟类的实例,如下所示:

class URLSessionMock: URLSession {
    typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void

    // 下面的属性
    // 我们需要模拟的session对象返回的数据或错误对象
    var data: Data?
    var error: Error?

    override func dataTask(
        with url: URL,
        completionHandler: @escaping CompletionHandler
    ) -> URLSessionDataTask {
        let data = self.data
        let error = self.error

        return URLSessionDataTaskMock {
            completionHandler(data, nil, error)
        }
    }
}

Okay,我们的模拟对象准备就绪! 现在我们要向NetworkManager添加依赖注入,以便让我们注入一个模拟的Session,来替代URLSession.shared:

class NetworkManager {
    private let session: URLSession

    // 使用默认参数(= .shared)可以避免修改我们主app的代码
    init(session: URLSession = .shared) {
        self.session = session
    }

    func loadData(from url: URL,
                  completionHandler: @escaping (NetworkResult) -> Void) {
        let task = session.dataTask(with: url) { data, _, error in
            let result = data.map(NetworkResult.success) ?? .failure(error)
            completionHandler(result)
        }

        task.resume()
    }
}

最后,让我们编写第一个测试,验证如果从网络请求返回Data,则返回成功的结果:

class NetworkManagerTests: XCTestCase {
    func testSuccessfulResponse() {
        // 设置我们的mock对象
        let session = URLSessionMock()
        let manager = NetworkManager(session: session)

        // 创建返回数据,并赋值给模拟的session对象
        let data = Data(bytes: [0, 1, 0, 1])
        session.data = data

        // 创建一个URL
        let url = URL(fileURLWithPath: "url")

        // 执行请求并验证结果
        var result: NetworkResult?
        manager.loadData(from: url) { result = $0 }
        XCTAssertEqual(result, .success(data))
    }
}

我们现在有一个测试,用于验证我们的NetworkManager是否能够成功响应?! 厉害了,但还有很大的改进空间。 使用局部模拟,就像我们上面所做的那样,有时会很有用。但是,它有两个主要缺点:

  1. 它要求我们编写相当多的Mock代码,因为我们需要主动覆盖我们期望被调用的所有代码路径。
  2. 我们只是部分修改对象。 这意味着我们正在做一些相当困难的假设,假设我们了解关于对象如何在内部工作的,以及我们如何在我们自己的代码中使用它们。如果我们正在Mock的对象发生了变化 - 特别是当涉及像URLSession这样的系统类时, 这些假设很快就会导致不稳定的测试和误报。

完全模拟(Complete Mocking)

让我们改为使用完全模拟,这意味着我们将用完全模拟的实现,替换整个URLSession类。 要做到这一点,我们不能像创建部分模拟时那样对URLSession进行子类化,而是将我们需要的API抽象为协议:

protocol NetworkSession {
    func loadData(from url: URL,
                  completionHandler: @escaping (Data?, Error?) -> Void)
}

然后我们通过使用extension来使URLSession遵循协议接口:

extension URLSession: NetworkSession {
    func loadData(from url: URL,
                  completionHandler: @escaping (Data?, Error?) -> Void) {
        let task = dataTask(with: url) { (data, _, error) in
            completionHandler(data, error)
        }

        task.resume()
    }
}

最后,我们将使NetworkManager在其初始化程序中接受符合NetworkSession的对象,而不是URLSession实例:

class NetworkManager {
    private let session: NetworkSession

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

    func loadData(from url: URL,
                  completionHandler: @escaping (NetworkResult) -> Void) {
        session.loadData(from: url) { data, error in
            let result = data.map(NetworkResult.success) ?? .failure(error)
            completionHandler(result)
        }
    }
}

使用完全模拟的最大好处是,通过简单地实现NetworkSession协议,我们现在可以更轻松地为我们的测试创建模拟:

class NetworkSessionMock: NetworkSession {
    var data: Data?
    var error: Error?

    func loadData(from url: URL,
                  completionHandler: @escaping (Data?, Error?) -> Void) {
        completionHandler(data, error)
    }
}

没有关于URLSession内部的假设,我们现在在NetworkManager和它的底层会话之间有一个更强大的API契约?。

使用完全模拟时要记住的一件事是,保持协议尽可能的简单 (一种方法是尽可能地分解和组合协议),否则你最终必须在你的模拟中实现许多方法和功能。在理想情况下,模拟应该是超级简单的,根本不应该包含任何逻辑。

避免模拟对象

现在我们已经了解了在Swift中实现模拟的各种方法,让我们看看我们实际上想要避免模拟的一个例子。当习惯于Mocking等技术时,有时你会觉得它能包治百病,所以会在各种情况下使用它。虽然大多数测试确实从模拟中受益,并且更容易单独测试给定的类,但并不总是必要的。

假设我们正在构建一个FileLoader,它允许我们从文件系统加载文件。为此,我们需要为给定的文件名解析文件系统URL,为此,我们将使用应用程序的Bundle。 Bundle API类似于我们之前使用的URLSession API,因为它是基于单例的,通常通过访问其共享实例来使用 - 在本例中为.main。所以,我们最初的想法可能是做与URLSession完全相同的事情 - 为它创建一个协议,然后进行模拟。

但是,当涉及到Bundle时,这实际上并不是必需的,实际上,模拟会给我们的测试代码增加不必要的复杂度。我们可以做的是简单地使用我们的测试套件的bundle,并包含我们想要在该包中加载的任何文件。在Xcode中,我们可以创建一个文件 - 让我们称之为TestFile.txt - 并将其添加到我们的测试target中。然后,我们通过在其初始化程序中为我们的测试用例类提供Bundle,让FileLoader使用我们的测试包,如下所示:

class FileLoaderTests: XCTestCase {
    func testReadingFileAsString() throws {
        // 将测试用例类作为参数,初始化Bundle,这样系统会使用测试bundle而不是主程序bundle
        let bundle = Bundle(for: type(of: self))
        let loader = FileLoader(bundle: bundle)

        let string = try loader.stringFromFile(named: "TestFile.txt")
        XCTAssertEqual(string, "I'm a test file!\n")
    }
}

因此,并不总是需要模拟,如果我们可以避免它们(并且仍然编写好的和稳定的测试),它有时可以使测试代码更简单!?

结论

“模拟或着不模拟,这是个问题。。。”? 我希望这篇文章能让你深入了解如何在Swift中应用各种模拟技术。我的建议是了解我们可以使用的各种技术,然后在您认为最合适的地方应用它们。

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值