为什么单元测试在我的团队搞不起来

其实从我使用 PHP 5 开始,我就是知道有 PHPUnit 这样的包存在。简单看了看说明,以及对应的 API,说实在,我对单元测试是一点不理解的,我不明白这有什么用处。

认识

说起来,我现在作为一个程序员,已经工作了十三年,虽然虚度了很多光阴,但是有不少道理还是渐渐明白了。

如果单元测试在你的团队里搞不起来,可能是从你 —— Leader 开始,就没有认识到单元测试的重要性以及能从中得到的好处。

当年,PHP 7 推出的时候,引起了很大的轰动,因为性能有一个显著的改善,第二则是,鸟哥,一个程序员,作为 PHP 主要的贡献者之一,推动了这次变革。无数 PHP 程序员欢欣鼓舞。这个时候,你作为 Web 开发组长,有没有立即做出一个决定将 PHP 升级到 PHP 7?多数是没有,因为你吃不准,会不会有风险,你的第一责任还是对公司负责,对产品负责,然后才轮到技术选型的问题。那么,你怎样才算确认了,升级是安全的呢?

可能大厂很多知名的项目已经升级了,大厂那么多年积重难返的项目都升级了,就证明了没问题,所以我们可以升级,做出这个决定就不再困难了。但是,大厂升级成功,你的团队就一定能够升级成功么?未必,如果你干过这种事情,你就知道还有多少艰难险阻。

那么到底怎么才能又快又好地做到这件事情呢?

最近这几年,我负责用 Flutter 开发一款 App,是公司内部办公使用的 OA App,这款 App 只有我一个程序员。我发现 Flutter 有一个特点,就是升级的速度特别快,我刚开始开发这个 App 的第一个版本的时候 2.x 刚刚发布不久,网上到处搜到的都是 1.x 相关的教程和文档。

现在,区区两年多,Flutter 的版本已经到 3.13.x 了,框架版本剧烈升级,然后依赖的几个个包也都跟着剧烈升级,往往都是大版本升级,向下还不兼容。那么,请问这个时候,你敢不敢升级?还是刚才那个问题,你怎么又快又准地确认自己能不能升级呢?

团队里入职了一个新人,打开屎山,框架框架不熟,类库类库不熟,还有很多团队的私人订制,当他战战兢兢写完了代码,他敢不敢上线呢?

单元测试,可以让这些场景前面踟蹰不前的团队,有了一点点可以依赖的工具。

定义

单元测试,是对源代码的最小单元进行的最基础的测试。一般来说,我们测试的单位都是一个函数或者一个类方法。

创建很多个用例,尽可能覆盖到这段代码里的所有逻辑分支。就是简单得确定所有的逻辑分支都是实现正确的。

通过这个定义,我们不难看出,在上面的几个场景里,如果能保证代码里的所有单元都是正确的,我们能很大程度上确认,我们依赖的所有函数和方法,基本逻辑都是正确的,而整体的逻辑就是由一个个函数和方法共同组成的。

实操

不过,虽然知道很多道理,还是过不好这一生。当我第一次在团队里实践单元测试的时候,我发现举步维艰。

单元测试要求你的方法应该没有副作用,或者其副作用可控。首先来解释一下什么是副作用,这可能不是一个专业术语,而是我自己发明的说法。比如一个方法,常被用来举例子,就是 add 方法,入参有两个,1 和 2,返回值就是 3。这样的方法,就叫做没有副作用。

你控制了方法的入参,检验出参,就可以完全确定这个方法的功能,这种方法是非常适合进行单元测试的。哪怕你有很多种出参,也并不能测试。

可是当你实际操作的时候,你会发现,你刚写好的代码,大概 80% 都不符合这个定义。比如,还是 add 方法,输入 1 和 2,然后,它会连接数据库,从里面读取一个配置,这个配置如果是 “二进制”,就返回 111,如果配置是“十进制”,就返回 3。

这时候,你就很难写一个简单的单元,你得准备一个数据库,事先在数据库里放入对应的配置项,才能配合检验你的逻辑。但是,准备数据库和在数据库里放入对应配置项,就是一个代价很高的事情,数据库不是一个凭空就能出现的东西,它本身就是一个服务。

这时候就要考虑到”副作用“ 是否 ”可控制“ 的问题。比如我能不能让那个数据库的查询按我指定的方式返回呢?如果能,就是可控制,如果不能,就是不可控。

往往你会发现,数据库的查询代码,是一个单例模式,有个类叫 DbService,然后,通过这个单例,直接得到了数据库的连接,然后执行了查询。你得赶在数据库的单例构建之前,介入你的测试逻辑,才能拦截。而 DbService 是全局单例,在整个系统启动的时候就创建了,就意味着,你得模拟整个系统的启动,才能有效测试一个简单的 add。你可能就直接放弃了。

就好像你看见一个细细的绳子,你拉啊拉,发现你拉住的是一头大象的尾巴尖,最后揪出整整一头大象。太可怕了。

而你整个团队写的代码,是一座大象的乐园。几乎每个方法,都引用了一个或者几个这样的 Service,这时候,其实你的选择,只有放弃。

TDD

我看过不少鼓吹 TDD 的文章,认为从更长远的角度上,TDD 显著提高了软件的质量。这种做法就是先写测试,后写逻辑。

这样做,从我的认识上,最少能得到一个好处,就是你在写测试的时候,就想好了怎么样写代码才能实现对这个方法的测试。所以这样的代码,一定是最容易测试,废话,因为测试是先于代码出现的。

不过,这种方法,一个是真的很难上手,一个是代价真的不低。没有几个商业公司,能提供时间和成本供一个不熟练掌握这个方法的团队去尝试,或者供一个本来不会的团队去改用这种方式。

这种方法发明之初,就不是因为它提供的好处不足,而是大家多数没可能半路出家。

就算像我这样压力不大的团队,想要执行都千难万难。新项目一般都很急,人力不充分,而老项目都积重难返,根本无力负担。

解决之道

首先,你要坚定一个信念,你到底想不想去执行单元测试,不要盲目追求 TDD,先从有单元测试开始,如果说 TDD 难度是 100,有单元测试的难度可能是 90,就是这两者差距不是很大。所以,还是优先学习怎么让团队获得一个能进行单元测试积累的环境才是最重要的。

那么怎样的代码,才能进行单元测试呢?如前所述,消除或者控制副作用。

第一,尽量消除副作用。

如果有可能,尽量减少 Service 层。当多个数据对象进行互操作的时候,因为没有办法瞬间决定一个合理的抽象,程序员为了偷懒,往往会创建一个 Service 层,来进行业务组合,而这个 Service 层,只是美其名曰是一个层,其实,都是用类静态方法来实现,本质上,就是万恶的全局函数而已。

如果你维护一段代码看到到处散布着全局变量,不知道你会作何感想。但是,你可能很自然而然就接受到处散布着全局函数,其实这两种情况糟糕程度不相伯仲。

关键是有了 Service 层后,既然已经有大量代码都写在了这里,你会想,不如所有的代码都写在这里好了,于是一些简单的,本可以不写在这里的单一对象方法,也都写在了这里。而数据对象变成了什么东西呢?就是单纯的为了 ORM 而存在的东西。

这种薄薄的纯 ORM 往往都可以用代码生成器来生成和维护,确实也算得上是一种优点,但是如果你用万恶的 Service 层来配套,那么,这就是你项目腐烂的开始。总有一天变成完全无法维护。

尽可能使用类方法,而不是静态方法,这样,只要做到对象颗粒的初始化,就可以完成单元测试。

第二,让副作用可控。

尽量使用接口的实例来操作实体对象,而不是使用类对象。也就是用协议去编程,而不是直接耦合实现类。

当使用另一个类对象的时候,只是使用对象的接口。往往大家会偷懒,我并没有一颗复杂的对象继承关系树,我创建个接口干什么呢?如果你想测试的话,那接口就是给 Mock 对象留的。

Mock 对象也是一个对接口的实现,但是 Mock 对象可以按照你的要求直接返回值,就可以完成对目标类方法的测试。

只做到这一点还不够,还需要给 Mock 对象注入到具体类留下一个口子。这时候你才认识到 DI 设计模式的重要。有了它,你可以在对象构建的时候实现测试环境的搭建。而不是不得不启动整个应用的所有框架才能进行测试。

结论

如果你想你的团队进行单元测试的积累工作,你要搭建好所有的这些环境。包括软环境和硬环境。而这里更重要的是软环境,就是整个团队的每个成员都能形成这样的对单元测试的正确认识。

在这些认识和方法论的指导下,尽量构建一个易于进行测试的代码环境,才能在合适的时机,开始编写并积累测试用例。

附录 A

上面主要记录了一些我学习后的想法,这里记录一些具体而微的做法。

使用 DI(依赖注入)以避免 Singletons(单例)

在代码里使用单例,很难进行测试。引用了单例对象的代码,一般都是强耦合的代码,因为你不能从外面控制单例对象的创建,所以往往难以模拟单例对象的行为。

1
2
3
4
5
6
7
public class Client {  
  public int process(Params params) {
    Server server = Server.getInstance();
    Data data = server.retrieveData(params);
    ...
  }
}

将上述代码进行重构,使用依赖注入的方式:

1
2
3
4
5
6
7
8
9
10
11
12
public class Client {  
  private final Server server;

  public Client(Server server) {
    this.server = server;
  }

  public int process(Params params){
    Data data = this.server.retrieveData(params);
    ...
  }
}

进行了上述重构后,功能基本没有什么变化,只是在 Client 对象创建的时候,将 Server 的对象传入进去,而不是在里面直接引用单例,这样,你在测试的时候,可以创建一个符合 Server 接口的 Mock 对象,借以控制 Server 的行为,实现对 Client 的测试。

1
2
3
4
5
public void testProcess() {  
  Server mockServer = createMock(Server.class);
  Client c = new Client(mockServer);
  assertEquals(5, c.process(params));
}

上面是一个测试用例的例子,展示了如何在对象外面注入 Mock 的 Server 对象来进行测试。

附录 B

再附上一些很具体的指导建议:

Have a look at the Google Testing blog:

And also:

Finally, Misko Hevery wrote a guide on his blog: Writing Testable Code.

这是一篇 SO 的帖子答案