# 08 | 代码现代化:你的代码可测吗? 你好,我是姚琪琳。 从今天开始,我将用三讲来介绍代码现代化的主要模式。它们大体的脉络是这样的: 1.先对代码做可测试化重构,并添加测试; 2.在测试的保护下,安全地重构; 3.在测试的保护下,将代码分层。 我们今天先来看看如何让代码变得可测,这是遗留系统现代化的基本功,希望你重视起来。 一个软件的自动化测试,可以从内部表达这个软件的质量,我们通常管它叫做**内建质量(Build Quality In)**。 然而国内的开发人员普遍缺乏编写自动化测试的能力,一方面是认为没什么技术含量,另一方面是觉得质量是测试人员的工作,与自己无关。然而你有没有想过,正是因为这样的误区,才导致软件的质量越来越差,系统也一步步沦为了遗留系统。 我虽然在第六节课分享了可以不加测试就重构代码的方法,但添加测试再重构的方法更加扎实,一步一个脚印。 ## 你的代码可测吗? 我们先来看看不可测的代码都长什么样,分析出它们不可测的原因,再“按方抓药”。 可测的代码很相似,而不可测的代码各有各的不可测之处。我在[第二节课](https://time.geekbang.org/column/article/506570)举过一个不可测代码的例子,现在我们来一起复习一下: ```java public class EmployeeService { public EmployeeDto getEmployeeDto(long employeeId) { EmployeeDao employeeDao = new EmployeeDao(); // 访问数据库获取一个Employee Employee employee = employeeDao.getEmployeeById(employeeId); // 其他代码 } } ``` 这段代码之所以不可测,是因为在方法内部直接初始化了一个可以访问数据库的Dao类,要想测试这个方法,就必须访问数据库了。倒不是说所有测试都不能连接数据库,但大多数直连数据库的测试跑起来都太慢了,而且数据的准备也会相当麻烦。 这属于不可测代码的第一种类型:**在方法中构造了不可测的对象**。 我们再来看一个例子,与上面的代码非常类似: ```java public class EmployeeService { public EmployeeDto getEmployeeDto(long employeeId) { // 访问数据库获取一个Employee Employee employee = EmploeeDBHelper.getEmployeeById(employeeId); // 其他代码 } } ``` 这段代码同样是不可测的,它**在方法中调用了不可测的静态方法**,因为这个静态方法跟前面的实例方法一样,也访问了数据库。 除了不能在测试中访问真实数据库以外,也不要在测试中访问其他需要部署的中间件、服务等,它们也会给测试带来极大的不便。 在测试中,我们通常把被测的元素(可能是组件、类、方法等)叫做SUT(System Under Test),把SUT所依赖的组件叫做DOC(Depended-on Component)。导致**SUT无法测试的原因**,通常都是**DOC在当前的测试上下文中不可用**。 DOC不可用的原因通常有三种: 1.不能访问。比如DOC访问了数据库或其他需要部署的中间件、服务等,而本地环境没有这些组件,也很难部署这些组件。 2.不是当前测试期望的返回值。即使本地能够访问这些组件,但它们却无法返回我们想要的值。比如我们想要获取ID为1的员工信息,但数据库中却并没有这条数据。 3.执行这些DOC的方法会引发不想要的副作用。比如更新数据库,会破坏已有数据,影响其他测试。另外连接数据库,还会导致测试执行时间变长,这也是一种副作用。 要让SUT可测,就得让DOC可用,有哪些办法呢? ## 如何让代码变得可测? 其实很简单,就是要让DOC的行为可变。这种变化可以让DOC在测试时不再直接访问数据库或其他组件,从而变得“可以访问”、“能返回期望的值”以及“不会产生副作用”。 如何才能让DOC的行为可变呢?如果DOC是静态的或是在SUT内构造的,那自然不可改变。所以,我们要让DOC的构造和SUT本身分离,SUT只需使用外部构造好的DOC的实例,而不用关心它的构造。 这种**可以让DOC的行为发生改变的位置**,叫做**接缝(seam)**,这是Michael Feathers在《修改代码的艺术》这本书里提出来的。 接缝这个隐喻非常形象,如果是一整块没有接缝的布料,你就无法做任何改动,它始终就是一块平面的布料。有了接缝,你不但可以连接不同的布料,还可以改变一块布的方向,从平面变得立体。有了接缝,身为“裁缝”的我们才可以充分发挥想象力,制作出各种丰富多彩的成品。 我把这种在**SUT中创建接缝从而使SUT变得可测的方式**,叫做**提取接缝模式**。 想要用好这个模式,我们需要了解何处下剪子,针法选什么合适。也就是接缝的位置和类型,下面我们就结合代码例子分别看看。 ### 接缝的位置 我在第二节课介绍了一种提取接缝模式的应用,也就是把EmployeeDao提取成EmployeeService的字段,并通过EmployeeService的**构造函数注入**进来。 ```java public class EmployeeService { private EmployeeDao employeeDao; public EmployeeService(EmployeeDao employeeDao) { this.employeeDao = employeeDao; } public EmployeeDto getEmployeeDto(long employeeId) { Employee employee = employeeDao.getEmployeeById(employeeId); // 其他代码 } } ``` 除了构造函数,接缝也可以位于**方法参数**中,即: ```java public class EmployeeService { public EmployeeDto getEmployeeDto(long employeeId, EmployeeDao employeeDao) { Employee employee = employeeDao.getEmployeeById(employeeId); // 其他代码 } } ``` 如果你使用了依赖注入工具(比如Spring),也可以给字段加@Autowired注解,这样接缝的位置就成了**字段**。对于这三种接缝位置,我更倾向于构造函数,因为它更方便,而且与具体的工具无关。 学习完接缝的位置,我们再来看看接缝的类型。 ### 接缝的类型 **接缝的类型**是指,**通过什么样的方式来改变DOC的行为**。第二节课中,提取完接缝后,我创建了一个EmployeeDao的子类,这个子类重写了getEmployeeById的默认行为,从而让这个DOC返回了我们“期望的值”。 ```java public class InMemoryEmployeeDao extends EmployeeDao { @Override public Employee getEmployeeById(long employeeId) { return null; } } ``` 我把这种**通过继承DOC来改变默认行为**的接缝类型叫做**对象接缝**。 除此之外,还可以将原来的EmployeeDao类重命名为DatabaseEmployeeDao,并提取出一个EmployeeDao接口。然后再让InMemoryEmployeeDao类实现EmployeeDao接口。 ```java public interface EmployeeDao { Employee getEmployeeById(long employeeId); } ``` 在EmployeeService中,我们仍然通过构造函数来提供这个接缝,代码基本上可以保持不变。这样,我们和对象接缝一样,只需要在构造EmployeeService的时候传入InMemoryEmployeeDao就可以改变默认的行为,之后的测试也更方便。 这种**通过将DOC提取为接口,并用其他实现类来改变默认行为**的接缝类型,就叫做**接口接缝**。 如果你的代码依赖的是一个接口,那么这种依赖或者说耦合就是很松散的。在接口本身不发生改变的前提下,不管是修改实现了该接口的类,还是添加了新的实现类,使用接口的代码都不会受到影响。 ### 新生和外覆 提取了接缝,你就可以对遗留代码添加测试了。但这时你可能会说,虽然接缝很好,但是很多复杂的代码依赖了太多东西,一个个都提取接缝的话,需要很长时间,但无奈工期太紧,不允许这么做啊。 不要紧,《修改代码的艺术》中还介绍了两种不用提取接缝就能编写可测代码的模式,也就是**新生(sprout)**和**外覆(wrap)**。 假设我们有这样一段代码,根据传入的开始和结束时间,计算这段时间内所有员工的工作时间: ```java public class EmployeeService { public Map getWorkTime(LocalDate startDate, LocalDate endDate) { EmployeeDao employeeDao = new EmployeeDao(); List employees = employeeDao.getAllEmployees(); Map workTimes = new HashMap<>(); for(Employee employee : employees) { WorkTimeDao workTimeDao = new WorkTimeDao(); int workTime = workTimeDao.getWorkTimeByEmployeeId(employee.getEmployeeId(), startDate, endDate); workTimes.put(employee.getEmployeeId(), workTime); } return workTimes; } } ``` 我知道这段代码有很多槽点,但更痛的现实状况是:你根本没有时间去优化,因为新的需求已经来了,并且明天就要提测。 需求是这样的,业务人员拿到工时的报表后发现,有很多员工的工时都是0,原来他们早就离职了。现在要求你修改一下代码,过滤掉那些离职的员工。 如果不需要写测试,这样的需求对你来说就是小事一桩,你一定轻车熟路。 你可以在EmployeeDao中添加一个新的查询数据库的方法getAllActiveEmployees,只返回在职的Employee。也可以仍然使用getAllEmployees,并在内存中进行过滤。 ```java public class EmployeeService { public Map getWorkTime(LocalDate startDate, LocalDate endDate) { EmployeeDao employeeDao = new EmployeeDao(); List employees = employeeDao.getAllEmployees() .stream() .filter(e -> e.isActive()) .collect(toList()); // 其他代码 } } ``` 这样的修改不仅在遗留系统中,即使在所谓的新系统中,也是十分常见的。需求要求加一个过滤条件,那我就加一个过滤条件就好了。 然而,这样的代码仍然是不可测的,你加了几行代码,但你加的代码也是不可测的,系统没有因你的代码而变得更好,反而更糟了。 更好的做法是添加一个**新生方法**,去执行过滤操作,而不是在原来的方法内去过滤。 ```java public class EmployeeService { public Map getWorkTime(LocalDate startDate, LocalDate endDate) { EmployeeDao employeeDao = new EmployeeDao(); List employees = filterInactiveEmployees(employeeDao.getAllEmployees()); // 其他代码 } public List filterInactiveEmployees(List employees) { return employees.stream().filter(e -> e.isActive()).collect(toList()); } } ``` 这样一来,新生方法是可测的,你可以对它添加测试,以验证过滤逻辑的正确性。原来的方法虽然仍然不可测,但我们也没有让它变得更糟。 除了**新生**,你还可以使用**外覆**的方式来让新增加的功能可测。比如下面这段计算员工薪水的代码。 ```java public class EmployeeService { public BigDecimal calculateSalary(long employeeId) { EmployeeDao employeeDao = new EmployeeDao(); Employee employee = employeeDao.getEmployeeById(); return SalaryEngine.calculateSalaryForEmployee(employee); } } ``` 如果我们现在要添加一个新的功能,有些调用端在计算完薪水后,需要立即给员工发短信提醒,而且其他调用端则保持不变。你脑子里可能有无数种实现方式,但最简单的还是直接在这段代码里添加一个**新生**方法,用来通知员工。 ```java public class EmployeeService { public BigDecimal calculateSalary(long employeeId, bool needToNotify) { EmployeeDao employeeDao = new EmployeeDao(); Employee employee = employeeDao.getEmployeeById(); BigDecimal salary = SalaryEngine.calculateSalaryForEmployee(employee); notifyEmployee(employee, salary, needToNotify); return salary; } } ``` 这的确非常方便,但将needToNotify这种标志位一层层地传递下去,是典型的代码坏味道[FlagArgument](https://martinfowler.com/bliki/FlagArgument.html)。你也可以在调用端根据情况去通知员工,但那样对调用端的修改又太多太重,是典型的霰弹式修改。 最好的方式是在原有方法的基础上**外覆**一个新的方法calculateSalaryAndNotify,它会先调用原有方法,然后再调用通知方法。 ```java public BigDecimal calculateSalary(long employeeId) { // ... } public BigDecimal calculateSalaryAndNotify(long employeeId) { BigDecimal salary = calculateSalary(employeeId); notifyEmployee(employeeId, salary); return salary; } public void notifyEmployee(long employeeId, BigDecimal salary) { // 通知员工 } ``` 通过这样的修改,调用端只需要根据情况选择调用哪个方法即可,这样的改动量最少。同时你还可以单独测试notifyEmployee,以确保这部分逻辑是正确的。 通过新生和外覆两种模式,我们新编写的代码就是可测的了。而通过提取接缝,旧代码的可测试化重构也可以基本搞定。接下来,我将通过构造函数注入和接口接缝演示一下,如何为这个EmployeeService编写测试。 ## 为代码添加测试 我们先来回顾一下现在EmployeeService的完整代码: ```java public class EmployeeService { private EmployeeDao employeeDao; public EmployeeService(EmployeeDao employeeDao) { this.employeeDao = employeeDao; } public EmployeeDto getEmployeeDto(long employeeId) { Employee employee = employeeDao.getEmployeeById(employeeId); if (employee == null) { throw new EmployeeNotFoundException(employeeId); } return convertToEmployeeDto(employee); } } ``` 我们要添加的测试是当EmployeeDao的getEmployeeById方法返回一个null的时候,EmployeeService的getEmployeeDto方法会抛出一个异常。 ```java public class EmployeeServiceTest { @Test public void should_throw_employee_not_found_exception_when_employee_not_exists() { EmployeeService employeeService = new EmployeeService(new InMemoryEmployeeDao()); EmployeeNotFoundException exception = assertThrows(EmployeeNotFoundException.class, () -> employeeService.getEmployeeDto(1L)); assertEquals(exception.getEmployeeId(), 1L); } } ``` 我们在测试中使用的InMemoryEmployeeDao,实际上就是一种**测试替身(Test Double)**。但是它只返回了null,有点单一,想测试正常的情况就没法用了。如果想让这个方法返回不同的值,再添加一个EmployeeDao的实现着实有点麻烦。这时可以使用Mock框架,让它可以针对不同的测试场景返回不同的值。 ```java @Test public void should_return_correct_employee_when_employee_exists() { EmployeeDao mockEmployeeDao = Mockito.mock(EmployeeDao.class); when(mockEmployeeDao.getEmployeeById(1L)).thenReturn(givenEmployee("John Smith")); EmployeeService employeeService = new EmployeeService(mockEmployeeDao); EmployeeDto employeeDto = employeeService.getEmployeeDto(1L); assertEquals(1L, employeeDto.getEmployeeId()); assertEquals("John Smith", employeeDto.getName()); } ``` 这里我们使用了Mockito这个Java中最流行的Mock框架。想了解更多关于测试替身和Mock框架的知识,可以参考郑晔老师的[专栏文章](https://time.geekbang.org/column/article/408762)。 好了,代码也可测了,我们也知道怎么写测试了,那么应该按什么样的思路去添加测试呢?上面这种简单的例子,我相信你肯定是知道要怎么加测试,但是遗留系统中的那些“祖传”代码真的是什么样的都有,对于这种复杂代码,应该怎么去添加测试呢? ### 决策表模式 我们以著名的[镶金玫瑰重构道场](https://github.com/emilybache/GildedRose-Refactoring-Kata)的代码为例,来说明如何为复杂遗留代码添加测试。 ```java public void updateQuality() { for (int i = 0; i < items.length; i++) { if (!items[i].name.equals("Aged Brie") && !items[i].name.equals("Backstage passes to a TAFKAL80ETC concert")) { if (items[i].quality > 0) { if (!items[i].name.equals("Sulfuras, Hand of Ragnaros")) { items[i].quality = items[i].quality - 1; } } } else { if (items[i].quality < 50) { items[i].quality = items[i].quality + 1; if (items[i].name.equals("Backstage passes to a TAFKAL80ETC concert")) { if (items[i].sellIn < 11) { if (items[i].quality < 50) { items[i].quality = items[i].quality + 1; } } if (items[i].sellIn < 6) { if (items[i].quality < 50) { items[i].quality = items[i].quality + 1; } } } } } if (!items[i].name.equals("Sulfuras, Hand of Ragnaros")) { items[i].sellIn = items[i].sellIn - 1; } if (items[i].sellIn < 0) { if (!items[i].name.equals("Aged Brie")) { if (!items[i].name.equals("Backstage passes to a TAFKAL80ETC concert")) { if (items[i].quality > 0) { if (!items[i].name.equals("Sulfuras, Hand of Ragnaros")) { items[i].quality = items[i].quality - 1; } } } else { items[i].quality = items[i].quality - items[i].quality; } } else { if (items[i].quality < 50) { items[i].quality = items[i].quality + 1; } } } } } ``` 这是非常典型的遗留代码,if/else满天飞,可谓眼花缭乱;而且分支的规则不统一,有的按名字去判断,有的按数量去判断。 对于这种分支条件较多的代码,我们可以梳理需求文档(如果有的话)和代码,找出所有的路径,根据每个路径下各个字段的数据和最终的值,制定一张决策表,如下图所示。 ![图片](https://static001.geekbang.org/resource/image/63/d1/6357f689162820db3d22623e38aacbd1.jpg?wh=1920x1667) 比如第一行,我们要测的是,系统每天会自动给所有商品的保质期和品质都减1,那么给出的条件是商品类型为normal,保质期为4天,品质为1,所期望的行为是保质期和品质都减少1。而第二行则是测试,当保质期减为0之后,品质会双倍地减少。以此类推,我们一共梳理出18个测试场景。 你会看到,这种决策表不但清晰地提供了所有测试用例,而且给出了相应的数据,你可以很轻松地基于它来构建整个方法的测试。 ## 测试的类型和组织 说完了如何添加测试,我们接下来看看可以添加哪些类型的测试。 Mike Cohn在十几年前曾经提出过著名的“[测试金字塔](https://martinfowler.com/bliki/TestPyramid.html)”理论,将测试划分为三个层次。从上到下分别是:UI测试、服务测试和单元测试。它们累加在一起,就像一个金字塔一样。需要指出的是,遗留系统很难应用测试金字塔,但我们还是有必要先来看看这三层测试都包含哪些内容。 ![图片](https://static001.geekbang.org/resource/image/4d/48/4da7cb2739ac48ff731184f91c728f48.jpg?wh=1920x1106) 其中,最顶层的UI测试是指从页面点击到数据库访问的端到端测试,用自动化的方式去模拟用户或测试人员的行为。 早年间的所谓自动化测试大多都属于这种。但这样的测试十分不稳定,一个简单的页面调整就可能导致测试失败。 尽管现在很多工具推出了headless的方式,可以绕开页面,但它们仍然运行得很慢。而且还需要与很多服务、工具集成起来,环境的搭建也是个问题。所以UI测试位于测试金字塔的顶端,即只需要少量的这种测试,来验证服务和中间件等相互之间的访问是正常的。 需要指出的是,UI测试并不是针对前端元素或组件的测试。后者其实是前端的单元测试。 中间这层的服务测试也是某种意义的端到端测试,但它避开了UI的复杂性,而是直接去测试UI会访问的API。也有人管这种测试叫集成测试。它可以直接访问数据库,也可以用H2或SQLite等文件或内存数据库代替;它也可以直接访问其他服务,也可以用[Moco](https://github.com/dreamhead/moco)等工具来模拟服务的行为。 这种测试的好处是,可以测试API级别的端到端行为,不管内部的代码如何重构,只要API的契约保持不变,测试就不需要修改。 最底层的单元测试就是对某个方法的测试,平时开发同学写的大多是这方面的测试。测试金字塔的建议是,尽可能多地写单元测试,它编写成本低、运行速度快,是整个测试金字塔的基座。 对于方法内用到的DOC,你可以用测试替身来替换。对于在多大程度上使用测试替身,有两种不同的观点。 一种观点认为不管任何依赖都应该使用测试替身来代替;一种观点则认为只要DOC不访问数据库、不访问文件系统、不访问进程外的服务或中间件,就可以不用测试替身。前者的学名叫solitary unit test,我管它叫“**社恐症单元测试**”;后者学名叫做sociable unit test,我管它叫“**交际花单元测试**”。 到底应该使用哪种类型的单元测试呢?这一点并没有定论,支持每种类型的人都不少。我个人更倾向于交际花单测,因为这样写起来更容易,而且对重构很友好。 ## 遗留系统中的测试策略 学完了测试金字塔,你是不是已经准备按照由少到多的关系,在遗留系统中补测试了呢?先别急,遗留代码有很多特点,导致它们并不适合完全应用测试金字塔来组织测试。 首先,遗留系统的很多业务逻辑位于数据库的存储过程或函数中,代码只是用来传递参数而已。这样一来单元测试根本测不到什么东西。你也不能在服务测试(或API测试)中使用内存数据库,因为要在这些数据库中复制原数据库中的存储过程或函数,可能会有很多语法不兼容。 其次,遗留系统的很多业务还位于前端页面中,位于JSP、PHP、ASP的标签之中。这部分逻辑也是没法用单元测试来覆盖的。而服务测试脱离了页面,显然也无法覆盖。 因此,如果你的遗留系统在前端和数据库中都有不少业务逻辑,就可以多写一些UI测试,它们可以端到端地覆盖这些业务逻辑。你可以从系统中最重要的业务开始编写UI测试。 然而,UI测试毕竟十分不稳定,运行起来也很慢,当数量上来后这些缺点就会被放大。这时候你可以多编写一些服务测试。 对于数据库中的业务逻辑,你可以搭建一些基础设施,让开发人员可以在测试中直连数据库,并方便地编写集成测试。这些基础设施包括: * 一个数据库镜像,可以快速在本地或远端做初始化;需要将数据库容器化,满足开发和测试人员使用个人数据库的需求。 * 一个数据复制工具,可以方便地从其他测试环境拉取数据到这个镜像,以方便数据的准备;可以考虑通过CI/CD来实现数据的复制。 除此之外,你可能还需要一个数据对比工具,用来帮你在重构完代码后,比较数据库的状态。比如将一个存储过程或函数中的逻辑迁移到Java代码中的时候,要验证迁移的正确性,只跑通集成测试是远远不够的,还要全方位地比较数据库中相关表的数据,以防漏掉一些不起眼的地方。 对于前端中的业务逻辑,你可以先重构这些逻辑,将它们迁移到后端中(我将在第十三节课详细讲解如何迁移),然后再编写单元测试或服务测试。 这时的测试策略有点像一个钻石的形状。 ![图片](https://static001.geekbang.org/resource/image/5e/c5/5ea17f429b5b6f1a3397238448b49ac5.jpg?wh=1920x1066) 确定好了测试的类型,还有一些测试编写方面的小细节我想跟你分享。 第一个细节是测试的命名。关于测试命名,不同书籍中都有不同的推荐,但我更倾向于像[第四节课](https://time.geekbang.org/column/article/508559)中介绍的那样,用“实例化需求”的方式,从业务角度来命名测试,使得测试可以和代码一起演进,成为活文档。 第二个细节是测试的组织。当测试变多时,如果不好好对测试进行分组,很快就会变得杂乱无章。这样的测试即使是活文档,也会增加认知负载。 最好的方法是,将单个类的测试都放在同一个包中,将不同方法的测试放在单独的测试类里。而对于同一个方法,要先写它Happy path的测试,再写Sad path。记住一个口诀:**先简单,再复杂;先正常,再异常**。也就是测试的场景要先从简单的开始,逐步递进到复杂的情况;而测试的用例要先写正常的Case,再逐步递进到异常的Case。 ## 小结 今天学习的知识比较密集,需要好好总结一下。 我们首先学习了**接缝的位置**和**接缝的类型**。接缝的位置是指那些可以让DOC的行为发生改变的位置,有构造函数、方法参数、字段三种;而接缝的类型则是说改变DOC行为的方式,包括对象接缝和接口接缝。 遗留代码中有了接缝,就可以着手写测试了。然而复杂的遗留代码很难去梳理清楚头绪,我想你推荐用**决策表**的方式,将测试用例一一列出来。 在遗留系统中,如果存储过程中包含大量的业务逻辑,传统的金字塔型的测试策略可能并不适合,你可以多写一些端到端的UI测试,以及与数据库交互的集成测试(服务测试)。这时的测试策略呈现一个钻石的形状。 ![图片](https://static001.geekbang.org/resource/image/5e/c5/5ea17f429b5b6f1a3397238448b49ac5.jpg?wh=1920x1066) 最后我想说的是,自动化测试是代码不可或缺的一部分,忽视了测试,即使是新系统,也在向着遗留系统的不归路上冲刺。然而技术社区对于测试的态度总是十分漠视,多年来不曾改观。 有一次我的同事在某语言群里询问,是否有人愿意给一个开源框架添加测试,然而大家想的却是什么“技术进步性”。 ![图片](https://static001.geekbang.org/resource/image/9d/2f/9dbed940418bc71d3ebc11fbc0a3d02f.jpg?wh=1920x1351) 开发人员如果忽视编写自动化测试,就放弃了将质量内建到软件(也就是自己证明自己质量)的机会,把质量的控制完全托付给了测试人员。这种靠人力去保证质量的方式,永远也不可能代表“技术先进性”。 有的时候你可能觉得,我就是写了一行代码,加不加测试无所谓吧?反正原来也没有测试。但是,希望你不要这么想,更不要这么做。犯罪心理学中有一个“破窗效应”,意思是说如果一栋楼有几扇窗户是破的,用不了几天所有的窗户都会破掉。这是一个加速熵增的过程,没有测试的系统,就是那座破了窗户的大楼。 你要记住的是“童子军原则”,也就是当露营结束离开的时候,要打扫营地,让它比你来的时候更干净。你写了一行代码,并把这一行代码的测试加上,你就没有去打破一扇新的窗户,而是让系统比你写代码之前变得更好了。这便是引入了一个负熵,让你的系统从无序向着有序迈出了一步。 莫以恶小而为之,莫以善小而不为。 ## 思考题 感谢你听完我的絮絮叨叨,希望今天的课程能唤醒你写测试的意愿。今天的思考题请你分享一段你项目中的代码,并聊一聊你准备如何给它添加测试,别忘了给代码脱敏。 如果你觉得今天的课程对你有帮助,请把它分享给你的同事和朋友,我们一起来写测试吧。