代码现代化
Hi,我是阿昌
,今天学习记录的是关于代码现代化
的内容。
代码现代化的主要模式。
大体的脉络是这样的:
- 先对代码做可测试化重构,并添加测试;
- 在测试的保护下,安全地重构;
- 在测试的保护下,将代码分层。
先来看看如何让代码变得可测
,这是遗留系统现代化的基本功,希望重视起来。
一个软件的自动化测试,可以从内部表达这个软件的质量,通常管它叫做内建质量
(Build Quality In)。
然而国内的开发人员普遍缺乏编写自动化测试的能力,一方面是认为没什么技术含量
,另一方面是觉得质量是测试人员的工作,与自己无关
。
然而有没有想过,正是因为这样的误区,才导致软件的质量越来越差,系统也一步步沦为了遗留系统。
一、你的代码可测吗?
先来看看不可测的代码都长什么样,分析出它们不可测的原因,再“按方抓药”。可测的代码很相似,而不可测的代码各有各的不可测之处。
在自动化测试举过一个不可测代码的例子,现在来一起复习一下:
public class EmployeeService {
public EmployeeDto getEmployeeDto(long employeeId) {
EmployeeDao employeeDao = new EmployeeDao();
// 访问数据库获取一个Employee
Employee employee = employeeDao.getEmployeeById(employeeId);
// 其他代码
}
}
这段代码之所以不可测,是因为在方法内部直接初始化了一个可以访问数据库的 Dao 类,要想测试这个方法,就必须访问数据库了。
倒不是说所有测试都不能连接数据库,但大多数直连数据库的测试跑起来都太慢了,而且数据的准备也会相当麻烦。
这属于不可测代码的第一种类型:在方法中构造了不可测的对象
。
再来看一个例子,与上面的代码非常类似:
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 不可用的原因通常有三种:
- 不能访问。比如 DOC 访问了数据库或其他需要部署的中间件、服务等,而本地环境没有这些组件,也很难部署这些组件。
- 不是当前测试期望的返回值。即使本地能够访问这些组件,但它们却无法返回我们想要的值。比如我们想要获取 ID 为 1 的员工信息,但数据库中却并没有这条数据。
- 执行这些 DOC 的方法会引发不想要的副作用。比如更新数据库,会破坏已有数据,影响其他测试。另外连接数据库,还会导致测试执行时间变长,这也是一种副作用。
要让 SUT 可测,就得让 DOC 可用,有哪些办法呢?
二、如何让代码变得可测?
要让 DOC 的行为可变
。这种变化可以让 DOC 在测试时不再直接访问数据库或其他组件,从而变得“可以访问”、“能返回期望的值”以及“不会产生副作用”。
如何才能让 DOC 的行为可变呢?如果 DOC 是静态的或是在 SUT 内构造的,那自然不可改变。所以,要让 DOC 的构造和 SUT 本身分离
,SUT 只需使用外部构造好的 DOC 的实例,而不用关心它的构造。
这种可以让 DOC 的行为发生改变的位置,叫做接缝(seam)
,这是 Michael Feathers 在《修改代码的艺术》
这本书里提出来的。
接缝这个隐喻非常形象,如果是一整块没有接缝的布料,就无法做任何改动,它始终就是一块平面的布料。
有了接缝,不但可以连接不同的布料,还可以改变一块布的方向,从平面变得立体。
有了接缝,身为“裁缝”才可以充分发挥想象力,制作出各种丰富多彩的成品。
把这种在 SUT 中创建接缝从而使 SUT 变得可测的方式,叫做提取接缝模式
。
想要用好这个模式,需要了解何处下剪子,针法选什么合适。
也就是接缝的位置和类型
,下面就结合代码例子分别看看。
三、接缝的位置
提取接缝模式的应用,也就是把 EmployeeDao 提取成 EmployeeService 的字段,并通过 EmployeeService 的构造函数注入
进来。
public class EmployeeService {
private EmployeeDao employeeDao;
public EmployeeService(EmployeeDao employeeDao) {
this.employeeDao = employeeDao;
}
public EmployeeDto getEmployeeDto(long employeeId) {
Employee employee = employeeDao.getEmployeeById(employeeId);
// 其他代码
}
}
除了构造函数,接缝也可以位于方法参数
中,即:
public class EmployeeService {
public EmployeeDto getEmployeeDto(long employeeId, EmployeeDao employeeDao) {
Employee employee = employeeDao.getEmployeeById(employeeId);
// 其他代码
}
}
如果使用了依赖注入工具(比如 Spring),也可以给字段加 @Autowired
注解,这样接缝的位置就成了字段。
对于这三种接缝位置,更倾向于构造函数,因为它更方便,而且与具体的工具无关。
学习完接缝的位置,再来看看接缝的类型。
四、接缝的类型
接缝的类型是指,通过什么样的方式来改变 DOC 的行为。
提取完接缝后,创建了一个 EmployeeDao 的子类,这个子类重写了 getEmployeeById 的默认行为,从而让这个 DOC 返回了“期望的值”。
public class InMemoryEmployeeDao extends EmployeeDao {
@Override
public Employee getEmployeeById(long employeeId) {
return null;
}
}
把这种通过继承
DOC 来改变默认行为的接缝类型叫做对象接缝
。
除此之外,还可以将原来的 EmployeeDao 类重命名为 DatabaseEmployeeDao,并提取出一个 EmployeeDao 接口。
然后再让 InMemoryEmployeeDao 类实现 EmployeeDao 接口。
public interface EmployeeDao {
Employee getEmployeeById(long employeeId);
}
在 EmployeeService 中,仍然通过构造函数来提供这个接缝,代码基本上可以保持不变。
这样,和对象接缝一样,只需要在构造 EmployeeService 的时候传入 InMemoryEmployeeDao 就可以改变默认的行为,之后的测试也更方便。
这种通过将 DOC 提取为接口,并用其他实现类来改变默认行为的接缝类型,就叫做接口接缝
。
如果代码依赖的是一个接口,那么这种依赖或者说耦合就是很松散的。
在接口本身不发生改变的前提下,不管是修改实现了该接口的类,还是添加了新的实现类,使用接口的代码都不会受到影响。
五、新生和外覆
提取了接缝,你就可以对遗留代码添加测试了。
但这时可能会说,虽然接缝很好,但是很多复杂的代码依赖了太多东西,一个个都提取接缝的话,需要很长时间,但无奈工期太紧,不允许这么做啊。
不要紧,《修改代码的艺术》
中还介绍了两种不用提取接缝就能编写可测代码的模式,也就是新生(sprout)
和外覆(wrap)
。
假设有这样一段代码,根据传入的开始和结束时间,计算这段时间内所有员工的工作时间:
public class EmployeeService {
public Map<Long, Integer> getWorkTime(LocalDate startDate, LocalDate endDate) {
EmployeeDao employeeDao = new EmployeeDao();
List<Employee> employees = employeeDao.getAllEmployees();
Map<Long, Integer> 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,并在内存中进行过滤
。
public class EmployeeService {
public Map<Long, Integer> getWorkTime(LocalDate startDate, LocalDate endDate) {
EmployeeDao employeeDao = new EmployeeDao();
List<Employee> employees = employeeDao.getAllEmployees()
.stream()
.filter(e -> e.isActive())
.collect(toList());
// 其他代码
}
}
这样的修改不仅在遗留系统中,即使在所谓的新系统中,也是十分常见的。需求要求加一个过滤条件,那就加一个过滤条件就好了。
然而,这样的代码仍然是不可测的,加了几行代码,但加的代码也是不可测的,系统没有因代码而变得更好,反而更糟了。
更好的做法是添加一个新生方法,去执行过滤操作,而不是在原来的方法内去过滤。
public class EmployeeService {
public Map<Long, Integer> getWorkTime(LocalDate startDate, LocalDate endDate) {
EmployeeDao employeeDao = new EmployeeDao();
List<Employee> employees = filterInactiveEmployees(employeeDao.getAllEmployees());
// 其他代码
}
public List<Employee> filterInactiveEmployees(List<Employee> employees) {
return employees.stream().filter(e -> e.isActive()).collect(toList());
}
}
这样一来,新生方法是可测的,可以对它添加测试,以验证过滤逻辑的正确性。
原来的方法虽然仍然不可测,但也没有让它变得更糟。
除了新生,还可以使用外覆的方式来让新增加的功能可测。
比如下面这段计算员工薪水的代码。
public class EmployeeService {
public BigDecimal calculateSalary(long employeeId) {
EmployeeDao employeeDao = new EmployeeDao();
Employee employee = employeeDao.getEmployeeById();
return SalaryEngine.calculateSalaryForEmployee(employee);
}
}
如果现在要添加一个新的功能,有些调用端在计算完薪水后,需要立即给员工发短信提醒,而且其他调用端则保持不变。
脑子里可能有无数种实现方式,但最简单的还是直接在这段代码里添加一个新生方法,用来通知员工。
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。
也可以在调用端根据情况去通知员工,但那样对调用端的修改又太多太重,是典型的霰弹式修改。
最好的方式是在原有方法的基础上外覆一个新的方法 calculateSalaryAndNotify,它会先调用原有方法,然后再调用通知方法
。
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 的完整代码:
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 方法会抛出一个异常。
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 框架
,让它可以针对不同的测试场景返回不同的值。
@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 框架。
好了,代码也可测了,也知道怎么写测试了,那么应该按什么样的思路去添加测试呢?
上面这种简单的例子,相信你肯定是知道要怎么加测试,但是遗留系统中的那些“祖传”代码真的是什么样的都有,对于这种复杂代码,应该怎么去添加测试呢?
七、决策表模式
以著名的镶金玫瑰重构道场的代码为例,来说明如何为复杂遗留代码添加测试。
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 满天飞,可谓眼花缭乱;而且分支的规则不统一,有的按名字去判断,有的按数量去判断。对于这种分支条件较多的代码,可以梳理需求文档(如果有的话)和代码,找出所有的路径,根据每个路径下各个字段的数据和最终的值,制定一张决策表,如下图所示。
比如第一行,要测的是,系统每天会自动给所有商品的保质期和品质都减 1,那么给出的条件是商品类型为 normal,保质期为 4 天,品质为 1,所期望的行为是保质期和品质都减少 1。而第二行则是测试,当保质期减为 0 之后,品质会双倍地减少。
以此类推,一共梳理出 18 个测试场景。这种决策表不但清晰地提供了所有测试用例,而且给出了相应的数据,可以很轻松地基于它来构建整个方法的测试。
八、测试的类型和组织
说完了如何添加测试,接下来看看可以添加哪些类型的测试。
Mike Cohn 在十几年前曾经提出过著名的“测试金字塔”理论,将测试划分为三个层次。
从上到下分别是:
- UI 测试
- 服务测试
- 单元测试。
它们累加在一起,就像一个金字塔一样。
需要指出的是,遗留系统很难应用测试金字塔,但还是有必要先来看看这三层测试都包含哪些内容。
其中,最顶层的 UI 测试是指从页面点击到数据库访问的端到端测试,用自动化的方式去模拟用户或测试人员的行为。
早年间的所谓自动化测试大多都属于这种。但这样的测试十分不稳定,一个简单的页面调整就可能导致测试失败。
尽管现在很多工具推出了 headless 的方式,可以绕开页面,但它们仍然运行得很慢。而且还需要与很多服务、工具集成起来,环境的搭建也是个问题。
所以 UI 测试位于测试金字塔的顶端,即只需要少量的这种测试,来验证服务和中间件等相互之间的访问是正常的。
需要指出的是,UI 测试并不是针对前端元素或组件的测试。后者其实是前端的单元测试。
中间这层的服务测试也是某种意义的端到端测试,但它避开了 UI 的复杂性,而是直接去测试 UI 会访问的 API。
也有人管这种测试叫集成测试。它可以直接访问数据库,也可以用 H2 或 SQLite 等文件或内存数据库代替;
它也可以直接访问其他服务,也可以用Moco等工具来模拟服务的行为。
这种测试的好处是,可以测试 API 级别的端到端行为,不管内部的代码如何重构,只要 API 的契约保持不变,测试就不需要修改。
最底层的单元测试就是对某个方法的测试,平时开发同学写的大多是这方面的测试。
测试金字塔的建议是,尽可能多地写单元测试,它编写成本低、运行速度快,是整个测试金字塔的基座。
对于方法内用到的 DOC,可以用测试替身来替换。对于在多大程度上使用测试替身,有两种不同的观点。
-
一种观点认为不管任何依赖都应该使用测试替身来代替;
-
一种观点则认为只要 DOC 不访问数据库、不访问文件系统、不访问进程外的服务或中间件,就可以不用测试替身。
前者的学名叫 solitary unit test,管它叫“社恐症单元测试”;后者学名叫做 sociable unit test,管它叫“交际花单元测试”。
到底应该使用哪种类型的单元测试呢?这一点并没有定论,支持每种类型的人都不少。
个人更倾向于交际花单测,因为这样写起来更容易,而且对重构很友好。
九、遗留系统中的测试策略
学完了测试金字塔,是不是已经准备按照由少到多的关系,在遗留系统中补测试了呢?
先别急,遗留代码有很多特点,导致它们并不适合完全应用测试金字塔来组织测试。
首先,遗留系统的很多业务逻辑位于数据库的存储过程或函数中,代码只是用来传递参数而已。这样一来单元测试根本测不到什么东西。也不能在服务测试(或 API 测试)中使用内存数据库,因为要在这些数据库中复制原数据库中的存储过程或函数,可能会有很多语法不兼容。
其次,遗留系统的很多业务还位于前端页面中,位于 JSP、PHP、ASP 的标签之中。这部分逻辑也是没法用单元测试来覆盖的。而服务测试脱离了页面,显然也无法覆盖。
因此,如果遗留系统在前端和数据库中都有不少业务逻辑,就可以多写一些 UI 测试,它们可以端到端地覆盖这些业务逻辑。可以从系统中最重要的业务开始编写 UI 测试。
然而,UI 测试毕竟十分不稳定,运行起来也很慢,当数量上来后这些缺点就会被放大。这时候可以多编写一些服务测试。
对于数据库中的业务逻辑,可以搭建一些基础设施,让开发人员可以在测试中直连数据库,并方便地编写集成测试。
这些基础设施包括:
- 一个数据库镜像,可以快速在本地或远端做初始化;需要将数据库容器化,满足开发和测试人员使用个人数据库的需求。
- 一个数据复制工具,可以方便地从其他测试环境拉取数据到这个镜像,以方便数据的准备;可以考虑通过 CI/CD 来实现数据的复制。
除此之外,可能还需要一个数据对比工具,用来帮你在重构完代码后,比较数据库的状态。比如将一个存储过程或函数中的逻辑迁移到 Java 代码中的时候,要验证迁移的正确性,只跑通集成测试是远远不够的,还要全方位地比较数据库中相关表的数据,以防漏掉一些不起眼的地方。
对于前端中的业务逻辑,可以先重构这些逻辑,将它们迁移到后端中,然后再编写单元测试或服务测试。
这时的测试策略有点像一个钻石的形状。
确定好了测试的类型,还有一些测试编写方面。
- 第一个细节是
测试的命名
。关于测试命名,不同书籍中都有不同的推荐,但更倾向于像如何降低认知负载中介绍的那样,用“实例化需求”的方式,从业务角度来命名测试,使得测试可以和代码一起演进,成为活文档。 - 第二个细节是
测试的组织
。当测试变多时,如果不好好对测试进行分组,很快就会变得杂乱无章。这样的测试即使是活文档,也会增加认知负载。最好的方法是,将单个类的测试都放在同一个包中,将不同方法的测试放在单独的测试类里。而对于同一个方法,要先写它 Happy path 的测试,再写 Sad path。记住一个口诀:先简单,再复杂;先正常,再异常。
也就是测试的场景要先从简单的开始,逐步递进到复杂的情况;而测试的用例要先写正常的 Case,再逐步递进到异常的 Case。
十、总结
首先学习了接缝的位置和接缝的类型。
接缝的位置是指那些可以让 DOC 的行为发生改变的位置,有构造函数、方法参数、字段三种;而接缝的类型则是说改变 DOC 行为的方式,包括对象接缝和接口接缝。
遗留代码中有了接缝,就可以着手写测试了。然而复杂的遗留代码很难去梳理清楚头绪,想你推荐用决策表的方式,将测试用例一一列出来。
在遗留系统中,如果存储过程中包含大量的业务逻辑,传统的金字塔型的测试策略可能并不适合,可以多写一些端到端的 UI 测试,以及与数据库交互的集成测试(服务测试)。这时的测试策略呈现一个钻石的形状。
自动化测试是代码不可或缺的一部分,忽视了测试,即使是新系统,也在向着遗留系统的不归路上冲刺。
然而技术社区对于测试的态度总是十分漠视,多年来不曾改观。
在某群里询问,是否有人愿意给一个开源框架添加测试,然而大家想的却是什么“技术进步性”。
开发人员如果忽视编写自动化测试,就放弃了将质量内建到软件(也就是自己证明自己质量)的机会,把质量的控制完全托付给了测试人员。这种靠人力去保证质量的方式,永远也不可能代表“技术先进性”。
有的时候可能觉得,就是写了一行代码,加不加测试无所谓吧?反正原来也没有测试。但是,希望不要这么想,更不要这么做。
犯罪心理学中有一个“破窗效应”,意思是说如果一栋楼有几扇窗户是破的,用不了几天所有的窗户都会破掉。这是一个加速熵增的过程,没有测试的系统,就是那座破了窗户的大楼。
要记住的是“童子军原则”,也就是当露营结束离开的时候,要打扫营地,让它比你来的时候更干净。
写了一行代码,并把这一行代码的测试加上,你就没有去打破一扇新的窗户,而是让系统比你写代码之前变得更好了。
这便是引入了一个负熵,让系统从无序向着有序迈出了一步。
莫以恶小而为之,莫以善小而不为。