主页 一些关于开发的杂谈话题 - 测试
Post
Cancel

一些关于开发的杂谈话题 - 测试

最近接手了一些陈旧项目的维护工作,需要把一部分质量很烂的代码进行重构甚至重写。在这个过程期间,我也有机会对一些开发中比较重要的而且通用的知识进行了一点重新的思考和整理,在这里想把它们用个两三篇文章,以杂谈的方式记录一下。这些内容在我刚入门程序开发的时候困扰过我一段时间,所以虽然可能对于已经有多年经验的大佬们用处不大,但是希望新入行的同学们能通过这些话题得到一些启发,如果能减少走弯路的时间,那就更好了。

今天的第一个话题是有关测试的。在以前,我也写过一些关于测试的文章,不过更多的还是对某个特定框架的使用。我自己本身也在很长一段时间内保持了给包括框架和 app 写测试的习惯,并来回倒腾过不少不同风格的测试。在这篇短文里,我想对一些基本的问题和想法的变化进行解释。

为什么要写测试?你会给项目和代码写测试吗?

这是一个每次我去参加各种技术分享会,在结束后的自由交流环节经常会被问到的问题。

我很理解由于工期紧张、需求变动频繁等原因,导致的对测试有意无意的忽视。但在这里,我还是想给出一个关于写测试的理由的答案。如果整篇文章只有一句话值得被记住,那就是:

合理的测试保证了开发者的生活品质不受工作带来的负面影响。

乍一听似乎有点无厘头,测试所额外耗费的时间难道没有对生活带来影响吗?但是这确实是一句实话:测试给我带来的最大收益,就是对于自己代码质量的信心。保证测试的通过,也就是保证了代码的最低限正确行为。时间流逝,在代码不断变化的过程中,曾经正确且明显的部分,会随着项目复杂度的上升,与各部件逐渐耦合;甚至由于外部依赖的变化,而变得无法理解和修改。在每次提交时都能保证持续运行的测试,则是对抗这种“磨损”的最佳手段。

我会给我接手的代码编写测试,但是要强调,测试的目标绝不是尽可能高的覆盖率,而是在测试能带来的代码信心和测试所需要消耗的时间精力之间寻找平衡

测试些什么

具体来说,我会对这些内容进行测试:

  • 关于 Model 的测试,特别是涉及到算法或者逻辑的部分。比如各类排序,日期时间的解析,数据模型的编解码,状态发生器的运算等。
  • 可能随着外部条件变化而改变,但不加以特定场景就难以重现或发现的部分。比如大量线程同时操作某个对象,特殊的输入导致的 edge case 等。
  • 能够将运行时才能确认的代码提前到测试期间确定的部分。比如对于网络请求的处理和流程,或者一些需要预先设置复杂条件才能到达的代码路径。

不测试什么

作为打工人,虽然心酸,但是公司一般不太会因为我们写了漂亮的测试代码而多付工资。我们的收入还是来源于实际的产品代码,所以凡是维护成本过高,需求变化过快的代码,一般情况下我不太会去书写测试。包括:

  • 出了问题可以甩锅给 Apple 的代码:几乎所有的 View 代码,像是布局啊,view 的属性设置啊,控件的点击拖动啊这类。这类代码绝大部分时候依赖 UI 框架的实现,测试在很多时候意义不大。如果想要确保 view 的正确,更多的时候我们可以转而去测试 view model。
  • 出了问题可以甩锅给 QA 的代码:几乎所有的胶水代码,也就是传统意义上 view controller 层级的代码。因为一旦有需求变更,这部分代码非常容易发生剧烈变动,而且也难以进行自动化测试。更多时候更倾向于交给 QA 团队。
  • 出了问题可以甩锅给同事的代码:其他人的框架和代码。如果没有特殊的理由,我选择信任别人的代码,这包括同事写的项目里的代码和各种第三方依赖。对于同事的代码,如果同时满足:非常关键+没有测试+质量稀烂,那么我可能会考虑适当补一些测试,甚至重构一下让自己稍微安心;对于第三方依赖,如果质量不佳或者没有测试,那我可能会选择建议换一个类似的,或者自己造一个更好的。
  • 出了问题可以甩锅给三体人的代码:那些几乎不可能出错的代码,比如只是进行了赋值的某个类的初始化方法,简单的 getter/setter 等。

测试的风格和测试框架

BDD, TDD 或者随心所欲?

在 Swift 社区中,BDD 的风格并不像 Ruby 或者 JS 社区中那样流行。个人感觉这一方面可能是由于作为生态的控制者的 Apple 并没有提供第一方的 BDD 风格的测试框架支持,如果你想要尝试 BDD 的测试风格,可能需要依赖一些第三方的测试框架,比如 Quick/Nimble;另一方面,则是 Apple 开发社区相对前端来说还是更偏向保守,相比起 XCTest 中传统的断言方式,BDD 并没有什么统治地位的优势或者所谓的 killer feature。我个人不是很喜欢 BDD 的语法方式,但如果以学习为目的折腾折腾增长见识的话,作为尝试也还不错。

最为严格 TDD 倡导我们先写测试,然后再进行实际编码实现。我在一些开发任务中实际尝试了这种做法,得到过几点结论:

  1. 严格地按照先编写测试,然后再编写实际代码的方式,确实可以帮助梳理代码结构,达到更合理的架构
  2. 但是由于先写测试,在不具备实际实现代码的情况下,测试显然不可能编译通过。这需要我来回在测试代码和实现代码中进行切换。对于开发熟练工来说,这是一种不必要的折磨。
  3. 所以我一般选择执行“不那么严格”的 TDD:先写一部分最基础的类型和方法,但一旦某个类型初具规模 (比如拥有了三四个方法),或者遇到类型间的交互,就立刻停下来开始编写测试。编写的测试应该不仅仅只涵盖已有的实现,还应该尽力去定义其他还没有进行实现的部分,回归到严格 TDD 的路径。这样可以通过在测试中的“实际用例”,来确定所需要的接口定义。

实践中,我觉得这种非严格的 TDD 更符合人性和直觉,它的关键在于需要在测试代码和实现代码之间找到开发效率的平衡。如果你以前没有使用 TDD 的开发方式,那么其实一开始的时候还是建议使用最严格的方式,之后在逐步过渡到平衡阶段,会更容易对全局有所掌控。

如果你对 TDD 完全没有了解,那么其实可以先读读看 Kent Beck 的关于测试驱动开发的书 (Test Driven Development: By Example),这本书可以说是 TDD 的“圣经”了。

关于 AI 在测试中的应用

KiteTabnine,到如今的 GitHub Copilot,甚至更加通用的 ChatGPT,使用 AI 辅助编程逐渐流行,并且已经在很多方面展现了非凡的潜力。比如用 GitHub Copilot 为某个方法写测试,对于一些简单的方法,已经能够达到不错的效果了:

生成测试代码,其实是 AI 辅助编程的一个非常有用的场景:它们一般足够简单 (如果不够简单,往往则表示代码设计可能出现了问题),拥有非常好的可预期性,而且存在大量重复。这些都是 AI 所擅长的领域,也能节省开发者的大量时间。

而反过来,也许也会有新的发现:我们能不能就做一些简单的事情,比如编写测试,然后让 AI 根据测试去完成实际的实现 (也就是相对困难的部分)。一来,这非常符合 TDD 的精神和工作流程;二来,这能更好地解放开发者。当然,现在版本的 AI 虽然大部分时候能给出解决方案,但看起来还并不能非常完美地完成这项任务:因为像 ChatGPT 这类 LLM 模型,只具有文本生成能力而非实际思考的能力,你至少必须花上很多时间去 review AI 写出的代码,去真正理解并维护这些代码。在当下,这要花费大量的时间,可能还不如自己去编写相关代码,所以更明智的做法是让 AI 安份扮演副驾驶 (copilot) 的角色,仅在小片段上提供意见。但是可能随着 AI 不断的学习和训练,这种情况会被彻底改变,也许在未来,AI 才是主驾,而人类开发者则变成副驾。

如果那一天到来,那么编程就不再是程序员所需要掌握的核心技能了。

Apple 平台的测试,XCTest 和其他框架

Apple 在自家平台提供的 XCTest 是非常成熟的测试框架了,应该也是 iOS 相关开发中使用最广泛的框架。要注意,使用什么测试框架,和你使用哪种测试风格,并没有直接关系。在尝试一整圈以后,我个人现在在 Apple 平台进行测试时,几乎只使用 XCTest 了。Apple 官方支持、逐年更新以适应语言和框架新特性 (比如 async 的支持)、以及稳定的性能和表现,是做出这个选择的重要原因。

其他的测试框架个人涉足不深,就不过多介绍了。如果是在 Apple 平台的话,个人建议优先从 XCTest 入手:这是最简单也是和 IDE 结合最直观的测试框架。等积累一定经验后,再尝试对比其他框架。

Unit Test、Feature Test 和 UI Test

个人情况:

  • 优先保证 Unit Test
  • 酌情适当 Feature Test
  • 几乎不写 UI Test

这其实和前面“测试什么”,“不测试什么”是对应的。虽然 Feature Test 和 UI Test 有它们自己的适用范围,并能进一步保证正确性,但是相应的代价也相对较大。完备且正确的 Unit Test,加上一些关键路径上的 Feature Test,在绝大多数情况下已经能满足不让工作影响正常生活这一测试的目标。更完备的 Feature Test,甚至如果有信心维护的话,力所能及添加一些合理的 UI Test 会更好。但是过度的测试往往很容易达到边界效应:它们不会大幅改善软件的可靠性,反而容易成为严重的维护负担和开发成本。

改善测试质量的方式

注入、Mock 和 Stub

好的测试应该是顺理成章的,符合明确、简洁、稳定这些要素。代码耦合和互相依赖一直是书写测试的大敌:

  • 在针对某个方法的测试中,内部的耦合代码实际上并不属于该方法应该被测试的部分。
  • 这些耦合部分大概率需要合适的配置或者先决条件才能正常工作,而这些代码深藏于被测方法和类型里,在测试期间难以控制。
  • 部分耦合代码可能给程序状态带来强烈的副作用 (side effect),导致测试不稳定或者依赖特定顺序。

在实践中,我们有很多手段可以解耦合,其中最常用的是依赖注入:不在实现代码中直接持有依赖,而是通过一些方式,从外部将给入这个依赖。举个最简单的例子,下面这样的代码是难以测试的:

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
    var path: String { /* */ }
    var data: Data { /* */ }
    
    // 持有 FileManager.default
    func saveIfNotExiting() {
        let shouldSave = !FileManager.default.fileExists(atPath: path)
        if shouldSave {
            FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
        }
    }
}

saveIfNotExiting 中使用了 FileManager 实例,这使得 Person 和整个 FileManager 耦合在了一起。这时,对 saveIfNotExiting 的测试,不可避免地需要依赖外部状态,也即 path 上是否已经存在文件;要验证调用结果,也只能再次检查 path 上是否存在文件。这些都让测试变得不稳定。

通过结合使用 Swift protocol 和依赖注入,可以改善这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protocol SaveContext {
    func fileExists(atPath path: String) -> Bool
    @discardableResult
    func createFile(
        atPath path: String, contents data: Data?, attributes attr: [FileAttributeKey : Any]?
    ) -> Bool
}

extension FileManager: SaveContext { }

func saveIfNotExiting(saveContext: SaveContext) {
    let shouldSave = !saveContext.fileExists(atPath: path)
    if shouldSave {
        saveContext.createFile(atPath: path, contents: data, attributes: nil)
    }
}

这样,在测试时,我们只需要构建测试专用的类型,来满足 SaveContext,并将它传递给 saveIfNotExiting,就可以非常简单地设置各种测试条件并验证结果了。

pointfreeco/swift-dependencies 更进一步,对 Swift 中的依赖注入进行了非常漂亮的封装,并内置了一些常见的需要注入的类型。如果不想使用添加参数这种太过于“破坏性”的注入方式,这里提供的方案也许会更加舒适。

如果由于某种原因,不能使用基于 Protocol 的注入,那么传统的 Mock 或者 Stub 也会是不错的选择。由于 Objective-C 的动态特性,基于 Mock 来验证行为,甚至是通过 Stub 来“修改”一些预设值,曾经是相当流行的做法。在 Swift 的时代,Mock 和 Stub 的使用难度有所提升,在大部分情况下,两者都只是在测试层面解决依赖的问题,而并没有真正改善代码设计。如果有条件的话,个人更建议合理使用依赖注入,来实际地减少代码耦合。

使用纯函数

减少测试难度的另一个要点,是尽可能地书写纯函数,也就是那些和状态无关的函数。

如果确定的输入一定能够导致确定的输出,那么想要验证测试的结果,就会变得容易很多。在以 SwiftUI 为代表的 基于单向 数据流 的编程模型中,状态变更部分的代码往往被设计为纯函数,状态变更的结果再去驱动 UI 框架完成呈现和渲染。这让这些状态变更代码 (它们往往是你所编写的程序的核心部分) 可以很容易地进行测试。

这样的设计被工业界广泛验证。在设备性能相对过剩的今天,大规模的程序设计中,在非关键节点上,项目的可维护性要比牺牲可读性的性能优化更加重要。我们在设计代码时,其实也可以尽量使用纯函数,尽量简化可变状态,尽量编写覆盖率达标的测试,以期让代码和我们自己都能活得更长更久。

该博客文章由作者通过 CC BY 4.0 进行授权。

不知所谓的 2022 年终总结

深入理解 Observation - 原理,back porting 和性能