构建高质量软件:持续集成与持续交付系统实践
上QQ阅读APP看书,第一时间看更新

1.5 测试驱动开发

测试驱动开发(Test Driven Development,TDD)是一种敏捷的软件开发方法论,提倡在开发者开发足够多的代码之前优先编写单元测试方法,然后重构开发者编写的源代码。一些刚入职场,或者对单元测试应用很少的开发者可能会有这样的疑问:源代码都没有,单元测试要怎么写?测试什么?请注意上述文字中的“开发足够多的代码之前”,这就意味着会有少量的源代码开发工作优先于单元测试代码的开发,比如开发一些功能模块的骨架、方法的定义、方法模块之间的依赖关系等基本代码,否则就会真的什么也做不了。

关于测试驱动开发的概念,如果大家还想从理论上进一步深究,则请参考收录在《计算机科学》刊物中的一篇论文“Using test-driven development to improve software development practices”,该论文对TDD进行了非常系统化、理论化的总结和描述,该论文地址为https://pdfs.semanticscholar.org/c7a8/205b4d8a8d3eee7b6d4f631c65d73a24cdb5.pdf

1.5.1 红–绿–重构

在TDD中有一个非常重要的红–绿–重构三段式方法,可用于指导我们在实际开发中践行TDD,本节就来详细介绍该三段式方法所代表的含义。

“红”指的是单元测试运行失败的状态,即在软件中开发新特性、新功能,或者当现有的软件出现缺陷对其进行重现时,我们首先需要开发新的单元测试代码。由于此刻软件的新功能并没有具体的源代码实现,因此单元测试的执行结果必然是失败的,单元测试的运行状态也必然是红色状态,如图1-3所示。

024-01

图1-3 单元测试运行失败的红色阶段

当单元测试运行失败时,开发人员应该修改源代码,使单元测试方法能够顺利通过运行。也就是说,单元测试执行失败,将促使开发人员修复源代码,使其正常运行,以达到让所有单元测试都能成功运行的目的,这一阶段即为绿色阶段,如图1-4所示。

024-02

图1-4 单元测试运行成功的绿色阶段

开发人员通过对源代码的开发,使所有的单元测试方法都能成功执行之后,整个开发过程并没有完全完成。也许某些新增的源代码还有一些可以进行优化和结构调整的地方,需要进一步拆解和抽象,因此接下来还有一个非常重要的阶段,这就是重构,并且这三个阶段需要反复执行多次(如图1-5所示),才能最终确保开发者完成正确的程序开发。

025-01

图1-5 TDD红–绿–重构三阶段关系

对图1-5各阶段的说明如下。

1)红色阶段:代表软件无法满足某种功能,无论是新的功能需求还是已有的功能存在缺陷,都代表当前的软件无法满足某种功能。这种情况下,所有针对无法满足特定功能的单元测试肯定是不能正常运行的。

2)绿色阶段:单元测试无法成功执行,开发人员需要对源代码进行相应的修改,无论是开发全新的代码还是解决已有代码的缺陷问题,当变更的源代码使单元测试能够正确执行时,就是所谓的绿色阶段。但是仅仅使得现有的单元测试能够顺利执行还远远不够,因为即使单元测试全部执行成功,也并不能代表所编写的单元测试方法覆盖了所有测试条件,下一轮的红色阶段或许还会将单元测试方法进一步拆分成粒度更小的单元测试方法,或者新增更多其他的单元测试方法。

3)重构阶段:当所有的单元测试方法都能顺利通过执行时,也并不意味着开发者所开发的代码就是最终态了,代码可能还需要进行结构的调整、逻辑的优化、容错处理,以及各种依赖关系的抽象和重构等。在完成诸如此类的所有动作之后,还需要通过已有的单元测试和新增的单元测试验证所做的操作是否正确。

1.5.2 TDD工作流程

如果你对TDD的红–绿–重构三阶段的理解还存在困难,觉得这些概念还是有些抽象,不用担心,本节会将其进一步分解为若干个步骤,再结合开发人员日常熟悉的工作来进一步详细说明。TDD的工作流程示意图如图1-6所示。

026-01

图1-6 TDD工作流程步骤

图1-6所示的TDD工作流程进一步细分了红–绿–重构三个主要阶段的工作流程步骤,其中,每个阶段都需要执行单元测试,这也是我们反复强调的单元测试是TDD的基础,也是持续集成和交付的基础,因为它为软件质量的保障提供了最重要的第一道关口。

1)编写单元测试,用于验证当前软件是否满足新的功能需求。

2)运行所有的单元测试,检查是否存在失败的单元测试代码。

3)开发基本的功能代码,使单元测试能够成功执行。

4)运行单元测试,如果失败则跳回步骤3。

5)重构代码,并且再次运行单元测试代码,以确保重构代码的正确性,如果失败则跳回步骤3。

6)重复整个流程,直到所有的测试条件都能顺利通过验证并充分覆盖源代码中的逻辑分支。

1.5.3 TDD实践

了解了TDD的基本理论之后,下面就来讲解如何将其应用在实际开发工作中,也就是我们通常所说的“落地”。在TDD方法论的实践过程中,开发者需要反复不断地思考,以确保程序代码的正确性。本节将开发一个简单的应用程序,并以此为例来实践TDD的落地,示例程序将传入数学表达式并输出计算结果,比如输入字符串“1+2”,计算结果为3.0,输入字符串“2*3”,计算结果为6.0,等等。

简单了解应用程序需要满足的基本功能之后,下面我们就来着手开始相关的开发工作。首先,确定一个最基本的类NumericCalculator和基本的方法eval,具体实现如程序代码1-5所示。

程序代码1-5 NumericCalculator最初的框架代码

//这里省略部分代码。
public class NumericCalculator
{
    public double eval(String expression)
    {
        return 0.0D;
    }
}
//这里省略部分代码。

在开发足够多”满足eval方法的代码之前,我们首先会开发若干个单元测试方法,对eval方法进行测试,最基本的运算表达式当然是“加减乘除”,测试方法如程序代码1-6所示。

程序代码1-6 NumericCalculatorTest单元测试

import org.junit.Before;
import org.junit.Test;

import static org.hamcrest.core.IsEqual.equalTo;
import static org.hamcrest.MatcherAssert.assertThat;

public class NumericCalculatorTest
{
    private NumericCalculator calculator;

    @Before
    public void setup()
    {
        this.calculator = new NumericCalculator();
    }

    @Test
    public void textEvalAddExpression()
    {
        final String expression = "1+2";
        assertThat(calculator.eval(expression), equalTo(3.0D));
    }

    @Test
    public void textEvalSubtractExpression()
    {
        final String expression = "3-1";
        assertThat(calculator.eval(expression), equalTo(2.0D));
    }

    @Test
    public void textEvalMultiplyExpression()
    {
        final String expression = "3*2";
        assertThat(calculator.eval(expression), equalTo(6.0D));
    }

    @Test
    public void textEvalDivideExpression()
    {
        final String expression = "3/2";
        assertThat(calculator.eval(expression), equalTo(1.5D));
    }
}

根据最基本的数学运算方法,我们分别开发了“加减乘除”四个最基本的单元测试,并且对其进行了断言操作。运行上面的单元测试,我们会发现所有的单元测试方法都无法通过测试(如图1-7所示),也就是说出现了失败的测试用例方法,这一阶段就是上文所描述的“红色”阶段。

028-01

图1-7 单元测试执行失败

根据1.5.1节和1.5.2节中关于TDD三大阶段及执行流程步骤的描述,当单元测试运行失败时,我们需要更新相关的源代码,使单元测试方法能够正常运行,因此我们增加了计算逻辑的eval方法,具体实现如程序代码1-7所示。

程序代码1-7 eval方法实现基本的数学运算

//这里省略部分代码。
public double eval(String expression)
{
    final String operation;
    final String[] data;
    if (expression.contains("+"))
    {
        operation = "+";
        data = expression.split("\\+");
    } else if (expression.contains("-"))
    {
        operation = "-";
        data = expression.split("-");
    } else if (expression.contains("*"))
    {
        operation = "*";
        data = expression.split("\\*");
    } else if (expression.contains("/"))
    {
        operation = "/";
        data = expression.split("/");
    } else
        throw new IllegalArgumentException("Unrecognized operator.");

    switch (operation)
    {
        case "+":
            return Double.parseDouble(data[0]) + Double.parseDouble(data[1]);
        case "-":
            return Double.parseDouble(data[0]) - Double.parseDouble(data[1]);
        case "*":
            return Double.parseDouble(data[0]) * Double.parseDouble(data[1]);
        case "/":
            return Double.parseDouble(data[0]) / Double.parseDouble(data[1]);
        default:
            throw new UnsupportedOperationException();
    }
}
//这里省略部分代码。

当我们完成了对eval方法的代码开发之后,再次运行所有的单元测试,会发现每一个测试用例方法都能正确运行(如图1-8所示),这一阶段就是上文所描述的“绿色”阶段。

029-01

图1-8 单元测试执行成功

就像上文所提到的,虽然功能源代码能够保证最基本的单元测试方法都能顺利通过并正常运行,但是目前源代码的设计仍然非常粗糙,比如eval方法职责太重,除了要解析表达式字符串之外,还承载了数学运算。另外,该方法中存在大量的重复性代码。因此我们需要对其进行重构,重构的最基本思想是将参与运算的数据和运算符号抽象出来,并将表达式的解析从eval方法中抽取出来。重构后的eval方法如程序代码1-8所示。

程序代码1-8 重构后的eval方法

//这里省略部分代码。
public double eval(String expression)
{
    final Expression expr = Expression.of(expression);
    switch (expr.getOperator())
    {
        case ADD:
            return expr.getLeft() + expr.getRight();
        case SUBTRACT:
            return expr.getLeft() - expr.getRight();
        case MULTIPLY:
            return expr.getLeft() * expr.getRight();
        case DIVIDE:
            return expr.getLeft() / expr.getRight();
        default:
            throw new UnsupportedOperationException();
    }
}
//这里省略部分代码。

重构后的eval方法看起来简洁、清晰了很多,屏蔽了表达式expression的解析过程,减少了代码的重复,但是也由此引入了新的代码结构,即新增了对Expression类的依赖,Expression类的实现如程序代码1-9所示。

程序代码1-9 Expression类的实现

package com.wangwenjun.cicd.chapter01;

public class Expression
{
    enum Operator
    {
        ADD("+"),
        SUBTRACT("-"),
        MULTIPLY("*"),
        DIVIDE("/");
        private final String opt;

        Operator(String opt)
        {
            this.opt = opt;
        }

        @Override
        public String toString()
        {
            return opt;
        }
    }

    private final Operator operator;
    private final double left;
    private final double right;

    public static Expression of(Operator operator, double left, double right)
    {
        return new Expression(operator, left, right);
    }

    public static Expression of(String expression)
    {
        if (expression.contains("+"))
        {
            String[] data = expression.split("\\+");
            return of(Operator.ADD, Double.parseDouble(data[0]), Double.parseDouble
                (data[1]));
        } else if (expression.contains("-"))
        {
            String[] data = expression.split("-");
            return of(Operator.SUBTRACT, Double.parseDouble(data[0]), Double.parseDouble
                 (data[1]));
        } else if (expression.contains("*"))
        {
            String[] data = expression.split("\\*");
            return of(Operator.MULTIPLY, Double.parseDouble(data[0]), Double.parseDouble
                 (data[1]));
        } else if (expression.contains("/"))
        {
            String[] data = expression.split("/");
            return of(Operator.DIVIDE, Double.parseDouble(data[0]), Double.
                 parseDouble(data[1]));
        } else
        {
            throw new IllegalArgumentException("Unrecognized operator.");
        }
    }

    public Expression(Operator operator, double left, double right)
    {
        this.operator = operator;
        this.left = left;
        this.right = right;
    }

    public Operator getOperator()
    {
        return operator;
    }

    public double getLeft()
    {
        return left;
    }

    public double getRight()
    {
        return right;
    }
}

至此,重构阶段的任务已全部完成。需要注意的是,不要忘记在代码重构完成之后继续执行所有的单元测试代码,以确保重构的代码是正确的。

实际上,TDD的实践过程就是一个不断思考和迭代的过程,其会推动开发者不断思考怎样做才能使项目程序足够正确和稳健,比如,针对目前的eval方法,我们还可以思考如下的问题。

  • 如果eval方法中传入的表达式expression为空或null怎么办?
  • 如果表达式中不包含任何运算符号怎么办?
  • 如果表达式中包含除了运算符之外的非数字内容怎么办?
  • 如果表达式不完整(比如“1+”)怎么办?
  • 如果进行除法运算时,除数为0怎么办?

答案是增加新的测试代码,继续回到“红色”阶段,程序代码1-10所示的是新增的单元测试代码,其中的代码注释详细描述了每个单元测试的测试意图。

程序代码1-10 新增的单元测试方法

//当表达式字符串为空时,期望抛出IllegalArgumentException异常。
@Test(expected = IllegalArgumentException.class)
public void testExpressionStringBlack()
{
    calculator.eval("");
}
//当表达式字符串为null时,期望抛出IllegalArgumentException异常。
@Test(expected = IllegalArgumentException.class)
public void testExpressionStringNull()
{
    calculator.eval(null);
}
//当表达式包含不支持的运算符时,期望抛出IllegalArgumentException异常。
@Test(expected = IllegalArgumentException.class)
public void testExpressionNoOperator()
{
    calculator.eval("1?2");
}

//当表达式包含非数字数值时,期望抛出IllegalArgumentException异常。
@Test(expected = IllegalArgumentException.class)
public void testExpressionNotNumeric()
{
    calculator.eval("x+y");
}

//当表达式非法时,期望抛出IllegalArgumentException异常。
@Test(expected = IllegalArgumentException.class)
public void testExpressionInvalid()
{
    calculator.eval("1+");
}

//当表达式除数为0时,期望抛出IllegalArgumentException异常。
@Test(expected = IllegalArgumentException.class)
public void testExpressionDivisorIsZero()
{
    calculator.eval("1/0");
}

继续执行所有的单元测试方法,我们会看到运行结果中出现了运行失败的单元测试用例方法,如图1-9所示。

033-01

图1-9 部分单元测试方法执行失败

此刻再次进入“绿色阶段”,我们需要进一步修改代码,使单元测试方法能够正常运行。由于源代码越来越多,需要考虑的细节也越来越多,因此这次修改代码所要涉及的地方会更多一些。为了方便起见,笔者将所有的代码更改都汇合在一起进行展示,如程序代码1-11所示(为了节约篇幅,未变动的源代码会省略,具体请参考随书代码)。

程序代码1-11 修改后的计算器程序

//修改NumericCalculator类的eval方法,增加了对输入表达式expression是否为空的判断。
public double eval(String expression)
{
    if (null == expression || expression.isEmpty())
        throw new IllegalArgumentException("the expression can't be null or black.");
    final Expression expr = Expression.of(expression);
//这里省略部分代码。

//修改枚举Operator类,增加了类型映射方法。
//这里省略部分代码。
private static Map<String, Operator> typeMapping = new HashMap<>();
static
{
    typeMapping.put(ADD.opt, ADD);
    typeMapping.put(SUBTRACT.opt, SUBTRACT);
    typeMapping.put(MULTIPLY.opt, MULTIPLY);
    typeMapping.put(DIVIDE.opt, DIVIDE);
}
public static Operator getOperator(String opt)
{
    return typeMapping.get(opt);
}

//这里省略部分代码。
//重写Expression的of方法,使用正则表达式对字符串进行split操作,使代码更加简洁。
private final static String regexp = "^(\\d+)([\\+|\\-|\\*|\\/])(\\d+)$";
private final static Pattern pattern = Pattern.compile(regexp);
public static Expression of(String expression)
{
    final Matcher matcher = pattern.matcher(expression);
    if (!matcher.matches())
        throw new IllegalArgumentException("Illegal expression.");
    final Expression exp = of(Operator.getOperator(matcher.group(2)),
            Double.parseDouble(matcher.group(1)),
            Double.parseDouble(matcher.group(3)));
    if (exp.getOperator() == Operator.DIVIDE && exp.getRight() == 0)
        throw new IllegalArgumentException("The divisor cannot be zero. ");
    return exp;
//这里省略部分代码。

至此,源代码已全部修改完毕。现在所有的单元测试考验都可以正常通过了(限于篇幅,此处省略单元测试的执行过程,大家可以自行测试运行),接下来无须再进行进一步的重构工作,可以提交当前数值计算器的初级版本了。虽然该版本看起来还是比较脆弱,不支持多个数值的计算,不支持“加减乘除”优先级,不支持大括号、小括号、花括号,不支持高阶的数学运算,但这些对我们来说都是新的需求,只有当需要的时候才会进行进一步的完善和开发,我们可以将其纳入任务列表(Sprint Backlog)中,通过项目的不断迭代,实现更复杂、更强大的表达式计算操作。

那么,单元测试到底有没有覆盖到所有的测试条件和可能性呢?除了开发人员进行人工分析之外,更严谨的做法是再借助测试覆盖率工具(如图1-10所示),进一步确认是否有必要补充新的单元测试方法。

034-01

图1-10 单元测试覆盖率报告

从图1-10所示的覆盖率报告来看,除了枚举类Operator的toString()方法没有进行测试之外,单元测试覆盖率几乎达到100%,这也从侧面印证了应用TDD这一敏捷方法论可以很好地完成基本数学表达式的解析和运算功能。

扩展阅读:如果想要实现更复杂的数学表达式计算,可以借助数据结构Stack来实现,“1+2”这样的表达式是我们比较习惯的“中缀表达式”,可以将其转换为“右缀表达式”(比如“12+”,代表1和2相加),分别将数值和运算符压入两个栈中,然后用弹栈的方式进行计算,即可实现更复杂的数学表达式计算(比如1+2+3×4–2+100/5等)。随着对()、[]和{}符号,以及其他数学运算(比如乘方、三角函数等)的引入,程序会变得越来越复杂,这里推荐一个非常好用的第三方类库exp4j,通过下面的方式将其引入项目工程中即可。

<dependency>
    <groupId>net.objecthunter</groupId>
    <artifactId>exp4j</artifactId>
    <version>0.4.8</version>
</dependency>

我们可以写一个简单的单元测试方法来验证exp4j的功能,代码如下。

@Test
public void testExp4j()
{
    net.objecthunter.exp4j.Expression expression =
        new ExpressionBuilder("(1+2)*10-5/3+40").build();
    double result = expression.evaluate();
    assertThat(result, equalTo(68.33333333333333D));
}

exp4j不仅支持很多种类的数学计算,而且支持变量名定义、异步运算等,如果对该类库感兴趣,则可以通过如下官网地址获取更多帮助。

exp4j库的官网地址为https://www.objecthunter.net/exp4j/