首页 > 代码库 > Usage and Idioms——Theories

Usage and Idioms——Theories

为什么要引用理论机制(Theory)

当今软件开发中,测试驱动开发(TDD — Test-driven development)越发流行。为什么 TDD 会如此流行呢?因为它确实拥有很多优点,它允许开发人员通过简单的例子来指定和表明他们代码的行为意图。

TDD 的优点:

  1. 使得开发人员对即将编写的软件任务具有更清晰的认识,使得他们在思考如何编写代码之前先仔细思考如何设计软件。
  2. 对测试开发人员所实现的代码提供了快速和自动化的支持。
  3. 提供了一系列可以重用的回归测试用例(regression test case),这些测试用例可以用来检测未来添加的新代码是否改变了以前系统定义的行为(测试代码兼容性)。

然而,TDD 也同样具有一定的局限性。对于开发人员来说,只用一些具体有限的简单例子来表达程序的行为往往远远不够。有很多代码行为可以很容易而且精确的用语言来描述,却很难用一些简单的例子来表达清楚,因为他们需要大量的甚至无限的具体例子才可以达到被描述清楚的目的,而且有时有限的例子根本不能覆盖所有的代码行为。

以下列出的代码行为反映了 TDD 的局限性:

  1. 将十进制整数转换成罗马数字,然后再将其转换回十进制数,并保持原有的数值。(需要大量的测试用例,有限的测试数据可能测不出所实现的代码的错误)。
  2. 对一个对象进行操作,希望结果仍然等于原来的对象。(需要考虑各种各样类型的对象)
  3. 在任何一个货币的 collection 中添加一个对象 dollar,需要产生出另外一个新的与以前不同的 collection 。(需要考虑所有的 collection 类型的对象)。

理论(Theory)的出现就是为了解决 TDD 这个问题。 TDD 为组织规划开发流程提供了一个方法,先用一些具体的例子(测试用例 test case)来描述系统代码的行为,然后再将这些行为用代码语句进行概括性的总的陈述(代码实现 implementation)。而 Theory 就是对传统的 TDD 进行一个延伸和扩展,它使得开发人员从开始的定义测试用例的阶段就可以通过参数集(理论上是无限个参数)对代码行为进行概括性的总的陈述,我们叫这些陈述为理论。理论就是对那些需要无穷个测试用例才能正确描述的代码行为的概括性陈述。结合理论(Theory)和测试一起,可以轻松的描述代码的行为并发现 BUG 。开发人员都知道他们代码所想要实现的概括性的总的目的,理论使得他们只需要在一个地方就可以快速的指定这些目的,而不要将这些目的翻译成大量的独立的测试用例。

理论机制的优点

  • 优点 1:理论(Theory)使得开发完全抽象的接口(Interface)更加容易。
  • 优点 2:理论仍然可以重用以前的测试用例,因为以前的许多传统的具体的测试用例仍然可以被轻松的改写成理论(Theory)测试实例。
  • 优点 3:理论(Theory)可以测试出一些原本测试用例没测出来的 bugs 。
  • 优点 4:理论允许配合自动化测试工具进行使用,自动化工具通过大量的数据点来测试一个理论,从而可以放大增强理论的效果。利用自动化工具来分析代码,找出可以证明理论错误的值。

下面通过一个简单的例子来逐步介绍理论的优点。

比如设计一个专门用来货币计算的计算器,首先需要给代码行为编写测试用例(这里以英镑 Pound 的乘法为例),如清单 9 所示:

清单 9 英镑 Pound 乘法的一个测试用例
@Test 
public void multiplyPoundsByInteger() { 
    assertEquals( 10, new Pound(5).times(2).getAmount() ); 
}

这时很自然的就会想到一个测试用例可能不够,需要再多一个,如清单 10 所示:

清单 10 英镑 Pound 乘法的两个测试用例
@Test 
public void multiplyPoundsByInteger () { 
    assertEquals( 10, new Pound(5).times(2).getAmount() ); 
    assertEquals( 15, new Pound(5).times(3).getAmount() ); 
}

但是此时您可能又会发现这两个测试用例还是很有限,您所希望的是测试所有的整数,而不只是 2,3 和 5,这些只是您所想要的测试的数据的子集,两个测试用例并不能完全与您所想要测试的代码的行为相等价,您需要更多的测试用例,此时就会发现需要很多的额外工作来编写这些测试用例,更可怕的是,您会发现您需要测试用例的并不只是简单的几个,可能是成千上万个甚至无穷个测试用例才能满足等价您的代码行为的目的。

很自然的,您会想到用清单 11 所示的代码来表达您的测试思想。

清单 11 使用变量辅助编写测试用例
//利用变量来代替具体数据表达测试思想
public void multiplyAnyAmountByInteger(int amount, int multiplier) { 
    assertEquals( amount * multiplier, 
        new Pound( amount ).times( multiplier ).getAmount() ); 
}

利用清单 11 的 multiplyAnyAmountByInteger 方法,可以轻松将测试用例改写成如清单 12 所示:

清单 12 改写的英镑 Pound 乘法的测试用例
@Test 
public void multiplyPoundsByInteger () { 
    multiplyAnyAmountByInteger(5, 2); 
    multiplyAnyAmountByInteger(5, 3); 
}

如清单 12 所示,以后若想增加测试用例,只要不停调用 multiplyAnyAmountByInteger 方法并赋予参数值即可。

方法 multiplyAnyAmountByInteger 就是一个理论的简单例子,理论就是一个带有参数的方法,其行为就是对任何参数都是正常的返回,不会抛出断言错误和其它异常。理论就是对一组数据进行概括性的陈述,就像一个科学理论一样,如果没有对所有可能出现的情况都进行实验,是不能证明该理论是正确的,但是只要有一种错误情况出现,该理论就不成立。相反地,一个测试就是对一个单独数据的单独陈述,就像是一个科学理论的实验一样。

如何使用理论机制

在 JUnit 4.4 的理论机制中,每个测试方法不再是由注释 @Test 指定的无参测试函数,而是由注释 @Theory 指定的带参数的测试函数,这些参数来自一个数据集(data sets),数据集通过注释 @DataPoint 指定。

JUnit 4.4 会自动将数据集中定义的数据类型和理论测试方法定义的参数类型进行比较,如果类型相同,会将数据集中的数据通过参数一一传入到测试方法中。数据集中的每一个数据都会被传入到每个相同类型的参数中。这时有人会问了,如果参数有多个,而且类型都和数据集中定义的数据相同,怎么办?答案是,JUnit 4.4 会将这些数据集中的数据进行一一配对组合(所有的组合情况都会被考虑到),然后将这些数据组合统统通过参数,一一传入到理论的测试方法中,但是用户可以通过假设机制(assumption)在断言函数(assertion)执行这些参数之前,对这些通过参数传进来的数据集中的数据进行限制和过滤,达到有目的地部分地将自己想要的参数传给断言函数(assertion)来测试。只有满足所有假设的数据才会执行接下来的测试用例,任何一个假设不满足的数据,都会自动跳过该理论测试函数(假设 assumption 不满足的数据会被忽略,不再执行接下来的断言测试),如果所有的假设都满足,测试用例断言函数不通过才代表着该理论测试不通过。

清单 13 理论机制举例
import static org.hamcrest.Matchers.*; //指定接下来要使用的Matcher匹配符
import static org.junit.Assume.*; //指定需要使用假设assume*来辅助理论Theory
import static org.junit.Assert.*; //指定需要使用断言assert*来判断测试是否通过

import org.junit.experimental.theories.DataPoint;	//需要使用注释@DataPoint来指定数据集
import org.junit.experimental.theories.Theories; //接下来@RunWith要指定Theories.class 
import org.junit.experimental.theories.Theory; //注释@Theory指定理论的测试函数
import org.junit.runner.RunWith; //需要使用@RunWith指定接下来运行测试的类

import org.junit.Test;

//注意:必须得使用@RunWith指定Theories.class
@RunWith(Theories.class)
public class TheoryTest {

    //利用注释@DataPoint来指定一组数据集,这些数据集中的数据用来证明或反驳接下来定义的Theory理论,
    //testNames1和testNames2这两个理论Theory测试函数的参数都是String,所以Junit4.4会将这5个
    //@DataPoint定义的String进行两两组合,统统一一传入到testNames1和testNames2中,所以参数名year
    //和name是不起任何作用的,"2007"同样有机会会传给参数name,"Works"也同样有机会传给参数year
    @DataPoint public static String YEAR_2007 = "2007";
    @DataPoint public static String YEAR_2008 = "2008";
    @DataPoint public static String NAME1 = "developer";
    @DataPoint public static String NAME2 = "Works";
    @DataPoint public static String NAME3 = "developerWorks";

    //注意:使用@Theory来指定测试函数,而不是@Test
    @Theory 
    public void testNames1( String year, String name ) {
        assumeThat( year, is("2007") ); //year必须是"2007",否则跳过该测试函数
        System.out.println( year + "-" + name );
        assertThat( year, is("2007") ); //这里的断言语句没有实际意义,这里举此例只是为了不中断测试
    }

    //注意:使用@Theory来指定测试函数,而不是@Test
    @Theory
    public void testNames2( String year, String name ) {
        assumeThat(year, is("2007")); //year必须是"2007",否则跳过该测试函数
        //name必须既不是"2007"也不是"2008",否则跳过该测试函数
        assumeThat(name, allOf( not(is("2007")), not(is("2008"))));
        System.out.println( year + "-" + name );
        assertThat( year, is("2007") ); //这里的断言语句没有实际意义,这里举此例只是为了不中断测试
    }


结果输出:
第一个Theory打印出:
2007-2007
2007-2008
2007-developer
2007-Works
2007-developerWorks
第二个Theory打印出:
2007-developer
2007-Works
2007-developerWorks

Usage and Idioms——Theories