Day 06: 测试驱动开发 (Test Driven Development)

「然而,没有测试套件,他们就丧失确保『程序修改後是否仍能照预期般工作』的能力,他们没办法保证『对系统某部分的修改不会搞烂系统其他部分的程序』。所以他们的程序缺陷率开始上升」

「他们开始害怕修改程序,他们的产品程序开始腐坏,最後变成没有任何测试、混乱和 Bug 丛生的产品程序」

取自: Clean Code (p.140)

关於整洁的测试这个议题,本身就能写成一本书,本章节仅做简单概念介绍

CH9: 单元测试 (Unit Test)

  • 测试程序保存和加强了产品的:

    • 可扩充弹性 (Extensibility)
    • 可维护性 (Maintainability)
    • 可再利用性 (Reusability)

    原因很简单,有了测试程序,你就不会害怕修改程序。无论你的程序架构分割的多好,每一次的修改都可能潜藏错误。没有了测试,你将害怕改变会导致其他尚未察觉的错误!

  • 先写测试程序反而能加快产品开发的速度
    作者的朋友 Jason Gorman 以一个将罗马数字与整数互相转换的小程序为例,共切分 6 个小阶段开发程序,并故意间隔采用 TDD 开发策略 (即,开发功能前先写好测试程序):
    https://ithelp.ithome.com.tw/upload/images/20210921/20138643cecrm7Xog8.png
    取自: Clean Architecture (p.9)

  • 我们可以发现:

    • 有采用 TDD 开发策略的日子比非 TDD 日(不写测试,直接上) 的开发效率平均快上 10%
    • 即使是开发最慢的 TDD 日也比开发最快的非 TDD 日还省时
  • 我们或许可以得到两个启发:

    测试程序对一个软件专案的影响程度就跟产品程序一样重要

    想要走的快,唯一的方式就是要走的好

    取自: Clean Code (p.150) & Clean Architecture (p.10)

TDD 的三大法则

  • 每个知道 TDD 的人都会在写产品程序之前,先撰写好单元测试。然而这条准则仅仅是冰山一角而已,来看看以下 TDD 的三大法则
    • Rule 1: 在撰写一个单元测试 (测试失败的单元测试) 前,不可撰写任何产品程序
    • Rule 2: 只撰写刚好无法通过的单元测试,不能编译也算无法通过
    • Rule 3: 只撰写刚好能通过当前测试失败的产品程序

让测试程序整洁

  • 测试会覆盖所有的产品程序,数量足以和产品程序匹敌,将产生管理问题
    测试程序会随着产品程序的演进而修改,而当测试程序越陷入一团混乱时,所花的时间可能比开发新产品还要多。当开发者将错误归咎於测试套件时,他们会将整个测试套件都舍弃掉

    「然而,没有测试套件,他们就丧失确保『程序修改後是否仍能照预期般工作』的能力,他们没办法保证『对系统某部分的修改不会搞烂系统其他部分的程序』。所以他们的程序缺陷率开始上升」

    「他们开始害怕修改程序,他们的产品程序开始腐坏,最後变成没有任何测试、混乱和 Bug 丛生的产品程序」

  • 测试程序跟产品程序一样重要
    「容许测试程序可以是混乱的」,是失败的源头。测试程序也需要花时间思考、设计、和维护

  • 良好的测试例子: Build-Operate-Check

    // Test 1
    public void testGetPageHierarchyAsXml() throws Exception {
       makePages("PageOne", "PageOne.ChildOne", "PageTwo");
    
       submitRequest("root", "type:pages");
    
       assertResponseIsXML();
       assertResponseContains(
         "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
       );
    }
    
    // Test 2
    public void testSymbolicLinksAreNotInXmlPageHierarchy() throws Exception {
       WikiPage page = makePage("PageOne");
       makePages("PageOne.ChildOne", "PageTwo");
    
       addLinkTo(page, "PageTwo", "SymPage");
       submitRequest("root", "type:pages");
    
       assertResponseIsXML();
       assertResponseContains(
          "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
       );
       assertResponseDoesNotContain("SymPage");
    }
    
    // Test 3
    public void testGetDataAsXml() throws Exception {
       makePageWithContent("TestPageOne", "test page");
    
       submitRequest("TestPageOne", "type:data");
    
       assertResponseIsXML();
       assertResponseContains("test page", "<Test>");
    }
    

    上述的每个测试都被拆解成三个部分
    1. 建立测资
    2. 操作测资
    3. 结果是否如预期

    任何人都可以在不被细节误导和干扰的情况下,马上了解测试程序

一个测试一次断言 (Assert)

  • 上述例子虽然采用了 Build-Operate-Cehck 的模式来设计测试程序,但仍有另一派人认为,每个测试函式都只能有唯一的一个 Assert。好处是每个测试都只会产生一个结论,人们可以更快速容易地了解它们。让我们以此概念来改写上述例子:

    // Test 1 (Refactored)
    public void testGetPageHierarchyAsXml() throws Exception {
       givenPages("PageOne", "PageOne.ChildOne", "PageTwo");
    
       whenRequestIsIssued("root", "type:pages");
    
       thenResponseShouldBeXML();
    }
    
    public void testGetPageHierarchyAsXml() throws Exception {
       givenPages("PageOne", "PageOne.ChildOne", "PageTwo");
    
       whenRequestIsIssued("root", "type:pages");
    
       thenResponseShouldContain(
         "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
       );
    }
    

    注意: 这边依据[1] Given-When-Then 的概念来替换了函式的名称,这让程序可读性上升。不幸的是,这样的拆解会导致重复程序码产生

  • 要解决 Give-When-Then 模式产生的重复程序码,可以利用 Template Method [2]设计模式来提取共用程序码。也就是将 Given/When 提取至基底类别,Then 则放在不同衍生类别

  • 断言仅能唯一或许是一种较为极端的作法,但不论如何,谨记:

    「测试里的断言应该尽可能地减少」

一个测试一个概念

  • 不要撰写一个冗长的测试函式,以下例而言,应该被拆解成三个独立的测试
    public void testAddMonths() {
         SerialDate d1 = SerialDate.createInstance(31, 5, 2004);
    
         SerialDate d2 = SerialDate.addMonths(1, d1);
         assertEquals(30, d2.getDayOfMonth());
         assertEquals(6, d2.getMonth());
         assertEquals(2004, d2.getYYYY());
    
         SerialDate d3 = SerialDate.addMonths(2, d1);
         assertEquals(31, d3.getDayOfMonth());
         assertEquals(7, d3.getMonth());
         assertEquals(2004, d3.getYYYY());
    
         SerialDate d4 = SerialDate.addMonths(1, SerialDate.addMonths(1, d1));
         assertEquals(30, d4.getDayOfMonth());
         assertEquals(7, d4.getMonth());
         assertEquals(2004, d4.getYYYY());
     }
    

整洁测试法则: F.I.R.S.T.

  • Fast
    测试要能被快速地运行
  • Independent
    测试程序不应该互相依赖,要能独立地运行,并可按照任何顺序进行测试。当测试互相依赖,会让错误的诊断变得更困难
  • Repeatable
    测试环境应该可以在任何环境中重复执行,避免发生「为什麽测试会失败」的藉口
  • Self-Validating
    测试程序应该输出布林值
  • Timely
    单元测试要恰好在产品程序之前不久撰写。如果你在撰写完产品後再去写测试,你可能会认为某些产品难以被测试,导致你不会去设计 可被测试(Testable) 的产品程序

小结

「测试程序对於一个专案的健康程度,就跟产品程序一样重要。如果你让测试程序腐败,那麽你的产品程序也会跟着腐败。保持你的测试整洁


P.S. 关於 TDD 的探讨在国外也有另一群人批评其为 "Cargo Cult" (邪教),有兴趣的读者们可以自行 Google 查找相关资讯。另外,究竟台湾的职场环境适不适合导入测试驱动开发(或说,该如何说服主管?),笔者挺好奇各位大大们的看法,欢迎交流~

Reference

  1. BDD - 如何写出好的 Gherkin 语法展示你的 Specification By Examples
  2. [Design Pattern] Template 模板模式

<<:  [第06天]理财达人Mx. Ada-下单作业

>>:  [Python 爬虫这样学,一定是大拇指拉!] DAY06 - URL / URN / URI (2)

在 Windows 10 上安装 Visual Studio Code EP3

前言 写程序,设定好 IDE,可以增加自己的效率,今天来纪录一下安装 Visual Studio C...

TypeScript 能手养成之旅 Day 13 特殊型别 - Never

前言 今天来讲解特殊型别中的 never,never 是一种函式回传值的状况,跟 void 很像,稍...

Day 1 - Series Overview

In this series, I want to introduce some concepts ...

#28 JS: Timing Events - Part 2

After introducing about the 2 methods for timing e...

Day2 - Canvas基础概论 I - 成为Canvas Ninja ~ 理解2D渲染的精髓

Let's Start From Scratch 本系列文章的头几篇我决定还是带点基础的东西,但是我...