如何给遗留代码添加单元测试
0 引言
大部分程序员在大部分时候的工作需要跟遗留代码(legacy code)一起工作。通常这项工作中最费时间和精力的部分在于代码重构和修改遗留代码为其添加unit test. 这里有一些书以供参考:
(1) 重构:改善既有代码设计 refactoring2
https://book-refactoring2.ifmicro.com/docs/
(2) 修改代码的艺术: Working Effectively with Legacy Code.
https://ptgmedia.pearsoncmg.com/images/9780131177055/samplepages/0131177052.pdf
1 大型软件开发中存在的问题
软件在刚开始设计的时候总是有办法做到非常简洁明了,设计清晰,功能模块耦合低的,但是随着软件开发过程的进行,软件总不得不慢慢地、逐渐地腐坏。这种腐坏如此常见,以至于每个程序员在职业生涯的某个阶段甚至大部分阶段,都不得不花费大量时间在遗留代码(legacy code)上。软件腐坏的原因也清晰明了:需求变更。无法承受需求变更的设计不是好的设计,每个有竞争力的软件开发者的目标就是,开发出能够承受变更的设计。 这看起来是矛盾的,一方面我们希望代码的结构是设计良好的,另一方面有希望它能够满足大部分的需求变更。事实上的结果是我们常常会牺牲软件的良好设计,写出一些很丑陋的代码来适应需求的变更。当软件开发进行到一定阶段,重构变得不可避免,因为软件的架构已经被腐蚀得如此彻底,以至于再在上面做出任何改动都会变得十分痛苦。
在与遗留代码一起工作的时候,我们还是可以做点事情来延缓甚至缓慢逆转这个过程的。《working effectively with legacy code》这本书就是专注于这个问题的。遗留代码通常是指哪些我们根本就不理解的难以修改的代码。但是这个定义对于我们解决这个问题毫无帮助。因此,在本书中,作者提出了一个截然不同的新的定义:
遗留代码就是缺少测试的简单代码。作者认为缺少测试的代码就是很差的代码。不管它编得有多好,也不管它的面向对象以及封装做得有多好。有了测试,我们可以快速且有保证地修改代码的行为。相反,没有测试,我们就不知道代码是变得更好还是更糟糕。
本书的主要内容是介绍一系列给遗留代码添加单元测试的技术。 很多遗留代码往往会有非常复杂的依赖关系。当我们想要给某个函数添加单元测试时,我们必须打破这些依赖关系,把注意力几种在某一个函数上。这些打破依赖的技术会成为我之后博文的重点内容。这些内容都会链接在下面。在这之前,先介绍一下修改软件的四大原因:
2 修改软件的四大原因
(1)增加特性 (new feature, behavior change or non-behavior change are included)
(2)修改缺陷 (bug,behavior change or non-behavior change are included)
(3)改善设计 (refactor, 改变设计而不改变其行为的动作叫做重构,重构背后的理念时确保现存的行为不会改变,一点一点进行改进,如果我们在整个过程中都做到这些,就可以让软件更易于维护,而不会改变其行为) 重构和一般意义上的清理不同,我们并不只是在做低风险的工作,像重新整理源代码的格式,也不是在做侵入式和高风险的事情,像重新编写大块的代码。相反,我们会做一系列结构化的修改,并由测试提供支持,让代码更易于修改。从修改的角度来说,关键在于,当你重构的时候, 并不希望做任何功能上的改变。
(4)优化: 与重构类似,但是重构指的是程序结构,而优化是指程序所使用的某种资源,通常是时间或者内存。
3 利用反馈: 单元测试的重要性。
(1) 两种测试模型:
a. 写完代码之后,丢给测试,让他们负责发现bug; 同时利用regression cases保证没有对软件的其他部分造成功能上的影响。
b. 单元测试模型: 写完代码之后,立马跑一下被修改部分的单元测试,保证该修改是可靠的。在构建单元测试上需要花费非常大量的精力,函数级别的单元测试才能真正帮助程序员提高效率,发现bug,但是也非常费时间。同时,要对函数做隔离测试在很多时候也是一个非常困难的事情。
(2)两种测试方法的优缺点:
a.大型测试存在的问题:
a.1 跑得慢
a.2 跑挂了之后,很难判断具体挂在哪里
b.单元测试的优点
b.1 跑得快,通常单元测试的运行时间都是e-3 s 甚至 e-6 s级别,这样在运行几千个到上万个unit test cases的时候才能够保证很快跑完。
b.2 能够帮助我们定位问题。
(3)其他类型的测试经常会伪装成单元测试。如果出现以下情况,那么就不是单元测试。
a.测试会访问数据库
b.测试会通过网络通信
c.测试会访问文件系统
d.需要你做特定的工作配置环境(像编辑配置文件)来运行测试
4 感知技术:支持分离
问题: 在理想的情况,类和类之间的耦合很小,可以直接为类添加单元测试代码。但这种理想的情况往往并不存在,类与类往往会有非常复杂的耦合。类之间的依赖关系会使得让特定的一组对象拥有测试更困难。 一个例子是: 我可能想要创建一个类的对象并调用它,但是为了创建它,我需要另一个类的对象,而哪些对象有需要其他类的对象,以此类推。
我们在遗留代码工作中面临的一个大问题是依赖。类似上图这样的依赖在实际的软件中比比皆是。如果我们在编写单元测试时只需要调用其中很小一部分代码时,通常我们需要打破它与其他代码的依赖关系。但是这通常并不容易,因为通常被依赖的代码时我们能够轻易感知到活动影响力的唯一地方。如果能够用某些其他代码放在该处并彻底测试,那么我们就能够编写测试了。在面向对象的方法中,这些“其他代码片段”通常被叫做伪对象。下面举一个伪对象的例子:
(1)伪对象
问题描述:在一个销售网点系统中,我们有一个叫Sale的类。目前的测试需求是针对 Sale::scan() 方法编写单元测试。 Sale::scan() 做了以下这些事情:它会接收客户想要购买商品的条形码。当调用scan()的时候,Sale对象就需要显示所扫描商品的名称,并把价格也显示在收银台的屏幕上。
困难之处:scan() 函数调用的更新屏幕的类 ArtR56Display() 非常大,非常复杂,对其进行实例化很费时间而且看起来没有必要。
解决问题:在unit test 中重新设计类依赖关系。
a. 类设计关系: 用FakeDisplay 来替代ArtR56Display实现showLine()功能,这样Sale 在调用scan的时候就可以通过原来的代码感知到屏幕显示的内容。干的活一模一样,依赖关系破除掉了。
b. 代码
1 ///< working code 2 public interface Display 3 { 4 void showLine(String line); 5 } 6 7 public class Sale 8 { 9 private Display display; 10 11 public Sale(Display display) { 12 this.display = display; 13 } 14 15 public void scan(String barcode) { 16 ... 17 String itemLine = item.name() + " " +item.price().asDisplayText(); 18 display.showLine(itemLine); 19 ... 20 } 21 } 22 23 ///< unit test code 24 25 import junit.framwork.*; 26 27 public class FakeDisplay implements Display { 28 private String lastLine = ""; 29 30 public void showLine(String line) { 31 lastLine = line; 32 } 33 34 public String getLastLine() { 35 return lastLine; 36 } 37 } 38 39 public class SaleTest extends TestCase { 40 public void testDisplayAnItem() { 41 FakeDisplay display = new FakeDisplay(); 42 Sale sale = new Sale(display); 43 44 sale.scan("1"); 45 assertEquals("Milk $3.99", display.getLastLine()); 46 } 47 }
(2)模拟对象: 伪对象很容易编写,而且对于单元测试是非常有价值的工具。如果你需要编写大量伪对象,那么你可能就需要考虑使用一种更高级的伪对象,即模拟对象( Mock object )。 模拟对象是在内部执行断言的伪对象。以下是一个使用模拟对象的单元测试的例子:
1 import junit.framwork.*; 2 3 public class SaleTest extends TestCase 4 { 5 public void testDisplayAnItem() { 6 MockDisplay display = new MockDisplay(); 7 display.setExpectation("showLine", "Milk $3.99"); 8 Sale sale = new Sale(display); 9 sale.scan("1"); 10 display.verify(); 11 } 12 }
模拟对象的好处是我们可以告诉它们期望什么调用,然后让他们检查,看是否收到了那些调用。 模拟对象是一种强大的工具,并且有大量模拟对象框架可用。
5 其他章节的链接地址
5.1 如何在Linux下建立包含lua vm的unit test framwork