You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

28 KiB

08 | 代码现代化:你的代码可测吗?

你好,我是姚琪琳。

从今天开始,我将用三讲来介绍代码现代化的主要模式。它们大体的脉络是这样的:

1.先对代码做可测试化重构,并添加测试;
2.在测试的保护下,安全地重构;
3.在测试的保护下,将代码分层。

我们今天先来看看如何让代码变得可测,这是遗留系统现代化的基本功,希望你重视起来。

一个软件的自动化测试,可以从内部表达这个软件的质量,我们通常管它叫做内建质量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);
    // 其他代码
  }
}

这段代码同样是不可测的,它在方法中调用了不可测的静态方法,因为这个静态方法跟前面的实例方法一样,也访问了数据库。

除了不能在测试中访问真实数据库以外,也不要在测试中访问其他需要部署的中间件、服务等,它们也会给测试带来极大的不便。

在测试中我们通常把被测的元素可能是组件、类、方法等叫做SUTSystem Under Test把SUT所依赖的组件叫做DOCDepended-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的构造函数注入进来。

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框架。想了解更多关于测试替身和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测试以及与数据库交互的集成测试服务测试。这时的测试策略呈现一个钻石的形状。

图片

最后我想说的是,自动化测试是代码不可或缺的一部分,忽视了测试,即使是新系统,也在向着遗留系统的不归路上冲刺。然而技术社区对于测试的态度总是十分漠视,多年来不曾改观。

有一次我的同事在某语言群里询问,是否有人愿意给一个开源框架添加测试,然而大家想的却是什么“技术进步性”。

图片

开发人员如果忽视编写自动化测试,就放弃了将质量内建到软件(也就是自己证明自己质量)的机会,把质量的控制完全托付给了测试人员。这种靠人力去保证质量的方式,永远也不可能代表“技术先进性”。

有的时候你可能觉得,我就是写了一行代码,加不加测试无所谓吧?反正原来也没有测试。但是,希望你不要这么想,更不要这么做。犯罪心理学中有一个“破窗效应”,意思是说如果一栋楼有几扇窗户是破的,用不了几天所有的窗户都会破掉。这是一个加速熵增的过程,没有测试的系统,就是那座破了窗户的大楼。

你要记住的是“童子军原则”,也就是当露营结束离开的时候,要打扫营地,让它比你来的时候更干净。你写了一行代码,并把这一行代码的测试加上,你就没有去打破一扇新的窗户,而是让系统比你写代码之前变得更好了。这便是引入了一个负熵,让你的系统从无序向着有序迈出了一步。

莫以恶小而为之,莫以善小而不为。

思考题

感谢你听完我的絮絮叨叨,希望今天的课程能唤醒你写测试的意愿。今天的思考题请你分享一段你项目中的代码,并聊一聊你准备如何给它添加测试,别忘了给代码脱敏。

如果你觉得今天的课程对你有帮助,请把它分享给你的同事和朋友,我们一起来写测试吧。