单元测试工程化-先写测试还是先写代码?

1,772 阅读13分钟

问题背景

在软件开发中,单元测试是保证代码质量和稳定性的重要手段之一。然而,对于单元测试应该先写测试还是先写代码,程序员们却有着不同的见解。一些开发者认为应该先写测试,这样可以先考虑代码的需求和设计,从而编写出更好的代码;而另一些开发者则认为应该先写代码,因为测试需要针对代码进行编写,如果代码先写好了,测试也会更加简单和明确。那么,究竟应该先写测试还是先写代码?

先写测试再写实现可行吗?

单元测试的实施方式并无统一规范,不同的开发者可能会按照不同的方式实现单元测试。这就导致了不同开发者对于应该先写测试还是先写代码会有不同的看法和做法。我个人的看法是,严格来说,应该先写单元测试,再写实现代码。这可能会比较违反大部分人的编程习惯:代码都没实现,就想着要测试了? 可我想说的是,还真的是这样。其实,代码不需要实现,就已经可以知道最终出来的产品是什么了。

为什么这么说呢?试想下,我们一直写的单元测试,本质上是什么?举个简单的例子,假设我们要实现一个预约会议室的需求,需求如下:

  1. 我们要预约一个会议室,预约成功时要提示用户预约成功;
  2. 当会议室被占用的时候,预约时要提示用户预约失败;
  3. 会议室的预约时长不能超过1小时

那根据这个需求,当我们来写测试用例和实现代码时,是否也应该是符合这个需求的?

根据需求,我们可能写出这样一个测试用例代码的函数定义:

@Test
public void should_book_room_fail_when_room_is_used() {

}

很明显,这个用例是符合第2个需求的。这时候依然没有实现代码。这时候我们看下要如何进行测试。

  • 我们要预约一个会议室,肯定需要有对应的服务,然后有一个预约会议室的函数来进行预约。由此我们可以得到如下代码:
@Test
public void should_book_room_fail_when_room_is_used() {
    RoomBookingService bookingSystem = new RoomBookingService();
    bookingSystem.bookRoom();
}

在这个步骤中,只是定义了实现类和函数,依然没有实现代码。

  • 我们如何知道会议室被占用?要如何观测预约的结果?那肯定存在时间段冲突才会被占用,并且预约成功还是失败,需要有结果返回。于是,可以得到如下代码:
@Test
public void should_book_room_fail_when_room_is_used() {
    RoomBookingService bookingSystem = new RoomBookingService();
    boolean result1 = bookingSystem.bookRoom(startTime, endTime); // return true
    boolean result2 = bookingSystem.bookRoom(startTime, endTime); // return false
}

  • 在上面的例子中,我们用注释写出了预期的结果,接下来就可以将它转换成断言:
@Test
public void should_book_room_fail_when_room_is_used() {
    RoomBookingService bookingSystem = new RoomBookingService();
    boolean result1 = bookingSystem.bookRoom(startTime, endTime); // return true
    boolean result2 = bookingSystem.bookRoom(startTime, endTime); // return false
    assertThat(result1).isTrue();
    assertThat(result2).isFalse();
}

到目前为止,都没有写过一行实现的代码,但却可以写出一个测试用例了。当然,要让这个测试运行通过,自然是要实现相关的逻辑才可以做到。但我想说明的是, 只要需求明确了,不需要代码实现,就可以写出对应的测试用例了,所以,先写测试再写实现代码,是完全可行的。那为什么还有很多程序员认为这样的顺序是很反直觉的呢?其实,这是思考问题的角度不一样的原因导致的。

作为技术人员,想要实现一个需求,很自然就会从如何实现这个需求 的角度思考,也就把专注力放在了怎么实现产品需求上了。但是如果从用户的角度去思考呢?自然地,从用户/产品经理的角度,更多的情况下,是想着 我最终是如何使用这个产品的。那映射到代码上,是怎么表示 如何使用产品 的呢?这就是测试用例了。毫不夸张地说,测试用例就是如何使用产品的度量

我们写的代码,最终的目的都是要提供一个接口给用户使用。前端提供的是一个GUI,图形化的用户接口;后端则是为前端提供功能层面上的接口,而在后端服务的内部,细分到每个层次,每个层次都为各自的上层服务提供接口,我们用的组件、框架为应用服务提供了接口,操作系统又为所有的应用程序提供了系统调用的接口。既然最终的效果是给我们的“用户” 提供一个接口,那要定义好接口,自然而然地就应该优先考虑用户如何使用我们提供的接口。而测试用例,就是我们模拟出来的用户。从这个角度出发,先写测试用例再写实现代码,是一个非常自然且理所应当的事情了,反而先写实现代码再写测试用例 却变得“反人类”。

如果你还对这个角度存疑,你可以试想一下,在实际的工作中,我们的测试人员,是不是大多数都对代码不甚熟悉?但是,他们也需要输出测试用例,并且也不会在程序员实现完之后才输出测试用例,往往是和程序员开发功能是 同步输出测试用例的。从这个现象看,测试用例的输出是和实现代码没有任何关联的,自然也就不存在说,必须先有实现代码,再有测试代码的问题了。

为什么先写测试再写实现代码很困难?

对于这个问题,我可能会想到有几点原因:

  1. 大多数人接受的编程教学中,总会先教你如何实现某个功能,然后运行起来,肉眼看一下效果,通过这种方法来证明自己的程序没有问题。这是一开始的编程教学中就形成的“第一形象”,因为一开始这个方式可行,也没发现有什么太大的问题,所以就一直用这样的方式。我也是这样开始的,但随着编程经验的累积,直到接触到TDD之后,我的观念才开始发生了转变。
  2. 先写测试再写代码,对开发人员的要求比较高,需要有一定的设计能力,才能在工程上执行下去。在一个团队中,程序员的人员能力分布可能是一个金字塔的形状,大多数程序员处于金字塔的底部,经验是比较浅的,处于金字塔顶部的经验较丰富的人比较少。在一个产品中,一个团队的每个成员可能都在负责不同的模块,要保证每个成员的设计能力都处于一个平均水平几乎不大可能。这个也是在工程上执行下去的阻碍之一。
  3. 需求迫在眉睫,时间紧的情况下,很可能连单元测试代码都不写。更不用谈先写单元测试还是先写代码了。
  4. 写代码前没有想清楚自己写的这段代码要做什么,想一步写一步,而先写测试代码再写实现代码要求在写代码前就要比较清楚自己实现的到底是什么, 这样自然就做不到了。

如何破局?

其实,先写测试,还是先写实现,最终的目的都是为了保证代码质量。从这个角度看,只要需求是明确的,无论先写哪个,其实都不太重要。但是先写代码再写测试,容易陷入一个误区:我既然都把代码写出来了,那测试用例是验证代码写得有没有问题的,那对代码的每个分支都要有测试保证,这样既能保证测试覆盖率,也能验证代码,一举两得。 其实这样的想法是错的。上面已经说过,测试用例是如何使用产品的度量,而不是如何实现代码的度量,这样做其实毫无意义,还浪费时间。后续需求一旦变更,无论测试还是代码,都需要大改,这是得不偿失的。

那该怎么办呢?首先要先认识到这样一个事实,代码是要按照产品需求来实现的,既然程序员都可以把实现代码写出来了,那自然需要对需求理解清楚才能写出来。也就是说,在开发过程中,其实就已经知道自己想要实现成什么样了,只是最终的实现的样子没有具象化,只是存在于脑海中的一个模糊的影像,随着自己实现的过程,才逐渐清晰且具体。

如果你认识到了这一点,那问题就转换成了,如何既避免先写代码再写测试的误区,又可以保证产品质量呢?我们可以从工作中找到答案,看看测试人员是怎么做的。他们对代码并不熟悉,但是却依然能对产品需求输出测试用例,依然能对程序员输出的代码测试出来很多问题。这是为什么?

上面说过,测试用例是如何使用产品的度量。而测试人员输出的功能用例,恰恰是在告诉我们,应该如何具象化地使用产品。而实际上,无论是先写测试还是先写实现代码,在此之前都要做一件事情,就是要理清楚产品需求,把需求具象化。也就是说,在写测试/写实现之前,要先设计测试用例。有了一份统一了认知的测试用例,先写测试,或先写代码,其实已经不重要了,因为无论怎么做,最终达到的效果,就是我们提前输出这份测试用例设计所描述的效果。

看看这样做能否解决上述提到的两个问题:

  • 预先设计的测试用例是基于大家都确认的基础上的,如果代码是按照用例来实现功能的,自然也就在很大程度上保证产品质量。(为什么说是很大程度上,因为即使是集体的智慧也可能有漏考虑的情况,所以出bug的地方就是漏考虑的地方)
  • 既然预先设计好了用例,那么先写代码再写测试,测试代码应该要按照设计的用例来测,而不是按照实现的代码来测,实现的方式可能有很多种,但只要符合测试用例要求的输入和输出也可以了。这样就可以避免上面提到的误区。

除此之外,在动手开发前先设计测试用例,还可以顺带解决上述提到的先写测试再写代码很困难的原因:

  • 设计测试用例可以由团队内经验较深的人来做,而经验尚浅的程序员可以先尝试理解这样的做法,或者先去模仿。因为测试用例通常是经过大家评审的,所以一定程度上可以保证开发输出的测试用例不会有太大的偏差。
  • 需求很紧,没时间写测试怎么办?只要代码是按照设计好的测试用例来写的,即使没时间写测试,问题也不大。
  • 对于写代码前没想清楚的问题,测试用例都设计出来了,自然地对自己想要实现怎样的功能也会有比较清晰的认知。

如何设计测试用例?

可能有人会想,测试用例不是测试人员要做的事情吗?为什么开发也要设计测试用例呢?这里要认识到,测试也是要区分层级的。测试人员在多数情况下做的是验收测试,也就是完完全全从用户使用的角度来看功能有没有问题。而程序员要做测试用例设计,更多的是从系统、模块、类级别上的测试用例设计。也就是说,开发人员本身,既要充当系统功能的建设者,又要充当这个功能的使用者,这样才能更好地从用户层面上设计出测试用例。

那如何设计测试用例呢?测试用例设计要遵循 相互独立,完全穷尽 (MECE) 原则,才能保证需求用例不被遗漏。表现形式上,通常通过思维导图来表示需求用例,相对来说会是一个比较直观的做法。

以上面的预约会议室作为例子,我们可以输出如下测试用例:

image.png

从这个例子中,我们可以看到,思维导图的每个分叉都是相互独立,完全穷尽的。以预约时长为例,预约时长只有超过1小时,或不超过1小时这两种情况,不存在第三种情况,且相互之间不存在重叠的情况,这样的用例设计就可以认为是相互独立,完全穷尽的。

另外,对着这个测试用例,我们几乎可以写出对应的实现代码了:

public boolean bookRoom(long roomId, long startTime, long endTime) {
	// 预约时长超过1小时
	if (endTime - startTime > 3600 * 1000) {
		// 抛出超过预约时长限制异常
		throw new OutOfBookTimeLimitException();
	}

	// 预约时长不超过1小时
	Room room = roomRepo.findById(roomId);
	// 预约时间有冲突
	if (room.getEndTime() > startTime || room.getStartTime() > endTime) {
		// 抛出预约时间冲突的异常
		throw new BookTimeConflictException();
	}

	// 预约时间无冲突
	return doBookRoom(roomId, startTime, endTime);
}

由此可见,代码和实际的测试用例,几乎是对应的,即使没写测试用例,在输出的质量上也有一定的保障了。

总结

虽然我们可以通过输出测试用例设计的方式,在一定程度上可以在不写测试的情况下来保证项目质量,但是测试用例只是解决了编写测试和编写代码并非无迹可循的问题。测试代码还是不可缺少的,因为后续的需求还是会变动,测试用例也会跟着变动。缺少自动化的测试代码对于代码变更来说,是灾难性的。但是,测试代码也是代码,缺乏管理也会造成灾难。我会在接下来的文章中逐步说明如何对单元测试代码进行工程化管理。