TDD及单元测试最佳实践

栏目: 编程工具 · 发布时间: 5年前

内容简介:TDD(测试驱动开发)既是一种软件开发技术,也是一种设计方法论。其基本思想是通过测试来推动整个开发的进行,但测试驱动开发并不只是单纯的测试工作,而是把需求分析、设计、质量控制量化的过程。为什么要采用TDD呢?TDD有如下几点优势:下面将用我们重构中的一个简单的案例来展示TDD的过程。我们需要一个工具类来实现一个方法根据商品的tag判断一个商品是否是批发商品:

TDD:What?Why?How?

TDD(测试驱动开发)既是一种软件开发技术,也是一种设计方法论。其基本思想是通过测试来推动整个开发的进行,但测试驱动开发并不只是单纯的测试工作,而是把需求分析、设计、质量控制量化的过程。

为什么要采用TDD呢?TDD有如下几点优势:

  1. 在开发的过程中,把大的功能块拆分成小的功能块进行测试,降低复杂性,帮助我们小步快跑前进。
  2. 遵循“keep it simple, stupid”(KISS)和“You aren't gonna need it”(YAGNI)原则,只写通过测试的必要代码,所以代码通常精简清晰(clean and clear)。
  3. 由于写测试用例实际上在模仿使用者,所以可以提升代码结构和接口设计的合理性。
  4. 尽早的暴露问题并解决,减小后续测试成本,长远的看还可以最大限度规避线上故障。
  5. 测试代码即文档,测试代码中的用例、入参、预期结果是对代码最好的解释。

TDD的基本生命周期如下图:

TDD及单元测试最佳实践
  1. 当一个需求来的时候,我们首先要做的就是增加一个测试或者重写当前的相关测试。这个过程中,我们需要非常清楚的了解需求本质,反映在测试用例上,就是测试的输入是什么,得到的输出是什么。而测试数据也需要尽量包括真实数据和边界数据。
  2. 运行测试,预期中,这个测试会失败,因为相关功能还没有被我们加在代码中。
  3. 编写相关功能的代码,从而让测试通过。
  4. 重新运行测试,这时候不仅要看第一步中的测试有没有通过,还需要看以前通过的测试有没有fail。如果测试失败,那么需要重写编写代码或者更新相关测试。
  5. 重构代码,为了让新增的测试通过,不免会堆积代码,所以要时候保持重构,去除代码中的“bad smell”。

下面将用我们重构中的一个简单的案例来展示TDD的过程。我们需要一个 工具 类来实现一个方法根据商品的tag判断一个商品是否是批发商品:

  1. 明确需求和测试用例

    批发商品的tag为Long型的10000L,传入的商品tags为一个String,以逗号分隔的各个商品tag,比如 "10000, 12345"

    我们的测试用例为如下几个:

    入参 结果
    "" false
    "12345" false
    "10000" true
    "12345,10000,20000" true
    "&^837,20000,10000" true
  2. 实现方法

    我们的测试为:

    @DataProvider(name="isWholesaleProductDp")
    public Object[][] isWholesaleProductDp() {
        return new Object[][] {
            {"", false},
            {"12345", false},
            {"10000", true},
            {"12345,10000,20000", true},
            {"&^837,20000,10000", true},
        };
    }
    
    @Test(dataProvider = "isWholesaleProductDp")
    public void testIsWholesaleProduct(String productTags, boolean expected) {
        Assert.assertEquals(expected, ProductExtendsUtil.isWholesaleProduct(productTags));
    }
    复制代码

(1)第一个cycle 首先实现方法如下:

public boolean isWholesaleProduct(String productTags) {
    return true;
}
复制代码

很显然前两个用例会失败。

(2)第二个cycle

我们需要编写让前两个用例成功的代码:

public boolean isWholesaleProduct(String productTags) {
    if (StringUtils.isBlank(productTags)) {
        return false;
    }
    Set<Long> tagIdSet = Arrays.stream(productTags.split(Constant.COMMA)).filter(s -> StringUtils.isNotBlank(s)).map(Long :: valueOf).collect(
            Collectors.toSet());
    return CollectionUtils.isNotEmpty(tagIds) && tagIds.contains(WHOLESALE_TAG_ID);
}
复制代码

此时再运行单元测试,所有测试用例都通过。

3. 重构

考虑到以后我们不仅要判断这个商品是否是批发品,还需要判断其是否是其他类型的商品,于是重构将主要的判断逻辑拆出来单独成为一个函数:

public boolean containsTag(String productTags, Long tagId) {
    if (StringUtils.isBlank(productTags)) {
        return false;
    }
    Set<Long> tagIdSet = Arrays.stream(productTags.split(Constant.COMMA)).filter(s -> StringUtils.isNotBlank(s)).map(Long :: valueOf).collect(
            Collectors.toSet());
    return CollectionUtils.isNotEmpty(tagIds) && tagIds.contains(WHOLESALE_TAG_ID);
}
复制代码

以上就是TDD的基本过程,但在实际操作过程,对于一些简单的方法实现,可以跳过一些步骤直接实现。

UTDD(单元测试驱动开发)

作为开发者(Developer),需要单独完成的就是单元测试驱动开发。因为ATTD(Acceptance Test Driven Development,验收驱动测试开发)通常需要QA同学介入。下面会针对 Java 单元测试的框架及技术展开。

1. 单元测试核心原则

单元测试需要遵循如下几大核心原则:

  • 自动化:单元测试应该是全自动执行的,并且非交互式的。利用断言Assert进行结果验证。
  • 独立性:保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。 单测不负责检查跨类或者跨系统的交互逻辑,那是集成测试的领域。
  • 可重复:单元测试是可以重复执行的,不能受到外界环境的影响。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。
  • 全面性:除了正确的输入得到预期的结果,还需要强制错误信息输入得到预期的结果,为了系统的鲁棒性,应加入边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序等。
  • 细粒度:保证测试粒度足够小,有助于精确定位问题。单测粒度至多是类级别,一般是方法级别。单测不负责检查跨类或者跨系统的交互逻辑,那是集成测试的领域。

2. 测试框架

在Java生态系统中,JUnit和TestNG是最受欢迎的两个单元测试框架。JUnit最早由TDD的先驱Ken Beck和Erich Gamma开发,后来由JUnit团队开发维护,截止到本文写作时间已发布JUnit 5。TestNG作为后起之秀,在JUnit的功能之外提供了一些独特的功能。下面将结合一些代码案例对两个框架的基本功能进行对比,其中JUnit将集中关注JUnit5中的功能。

总体架构

TDD及单元测试最佳实践 一个完整的测试平台有以下几个部分组成:

  1. 面向开发者的API,比如各种测试注解。
  2. 特定于某一测试框架的测试引擎。其中JUnit 5将调用Vintage Engine来兼容JUnit 3和JUnit 4的测试,Juniper Engine则用来执行JUnit 5的测试。
  3. 通用测试引擎,是对第2层中各种框架引擎的抽象。
  4. 面向IDE的启动器,IntelliJ IDEA、Eclipse等IDE通过启动器来运行测试。

Test设置

JUnit可以在方法和类两个级别完成初始化和后续操作,其中@BeforeEach和@AfterEach为方法级别的注解,@BeforeAll和@AfterAll为类级别的注解。TestNG同样提供了@BeforeMethod和@AfterMethod作为方法级别的注解,@BeforeClass和@AfterClass作为类级别的注解。TestNG还多了@BeforeSuite、@AfterSuite、@BeforeGroup、@AfterGroup,提供套件以及组级别的设置能力。

停用测试

JUnit提供了@Ignore注解,而TestNG则是在@Test后加入了enable=false的参数:@Test(enable = false)。

套件/分组测试

所谓套件/分组测试,就是把多个测试组合成一个模块,然后统一运行。

在JUnit中利用了@RunWith、@SelectPackages、@SelectClasses注解来组合测试用例,比如:

@RunWith(JUnitPlatform.class)
@SelectClasses({Class1UnitTest.class, Class2UnitTest.class})
public class SelectClassesSuiteUnitTest {
}
复制代码

而在TestNG中,则用一个XML文件来定义要组合的测试:

<suite name="suite">
    <test name="test suite">
        <classes>
            <class name="com.alibaba.icbu.product.Class1Test" />
            <class name="com.alibaba.icbu.product.Class2Test" />
        </classes>
    </test>
</suite>
复制代码

除此之外,TestNG还可以组合方法,在@Test注解中定义group:

@Test(groups = "regression")
public void regressionTestNegtiveSum() {
    int sum = numbers.stream().reduce(0, Integer::sum);
    Assert.assertTrue(sum < 0);
}
复制代码

然后再XML中定义如下:

<test name="test groups">
    <groups>
        <run>
            <include name="regression" />
        </run>
    </groups>
    <classes>
        <class
          name="com.alibaba.icbu.product.Class1Test" />
    </classes>
</test>
复制代码

异常测试

对于如下抛出异常的方法:

public class Calculator {
    public double divide(double a, double b) {
        if (b == 0) {
            throw new DivideByZeroException("Divider cannot be equal to zero!");
        }
        return a/b;
    }
}
复制代码

在JUnit 5中,可以用assertThrows来断言:

@Test
public void testDivideByZero() {
    Calculator calculator = new Calculator();
    assertThrows(DivideByZeroException.class, () -> calculator.divide(10, 0));
}
复制代码

在TestNG中,则可以在注解中加入期望的异常:

@Test(expectedExceptions = ArithmeticException.class) 
public void testDivideByZero() { 
    int i = 1 / 0;
}
复制代码

参数化测试

参数化的好处是重用测试方法来测试多组数据,我们可以申明数据源,测试方法就能读取各个数据进行测试。

在JUnit 5中,有如下几种数据源注解:

  • @ValueSource,可以定义Short、Byte、Int、Long、Float,、Double、Char和String数组作为数据源: java @ParameterizedTest @ValueSource(strings = { "Hello", "World" }) void testStringNotNull(String word) { assertNotNull(word); }
  • @EnumSource,把Enum作为参数: java @ParameterizedTest @EnumSource(value = ProductType.class, names = {"SOURCING", "MARKET"}) void testContainProductType(ProductType type) { assertTrue(EnumSet.of(ProductType.SOURCING, ProductType.MARKET).contains(type)); }
  • @MethodSource,调用函数产生参数:

    static Stream<String> wordDataProvider() {
      return Stream.of("foo", "bar");
    }
    
    @ParameterizedTest
    @MethodSource("wordDataProvider")
    void testInputStream(String argument) {
      assertNotNull(argument);
    }
    复制代码
  • @CsvSource,CSV值作为参数:

    @ParameterizedTest
    @CsvSource({ "1, Car", "2, House", "3, Train" })
    void testContent(int id, String word) {
      assertNotNull(id);
      assertNotNull(word);
    }
    复制代码
  • @CsvFileSource将会读取classpath下的CSV文件作为参数。

    而在TestNG中,主要有如下两种参数化注解:

  • @Parameter,读取XML文件中的数据作为参数:

    <suite name="My test suite">
    <test name="numbersXML">
        <parameter name="value" value="1"/>
        <parameter name="isEven" value="false"/>
        <classes>
            <class name="com.alibaba.icbu.product.ParametrizedTests"/>
        </classes>
    </test>
    </suite>
    复制代码

    在Java代码中:

    @Test
    @Parameters({"value", "isEven"})
    public void testIsEven(int value, boolean isEven) {
      Assert.assertEquals(isEven, value % 2 == 0);
    }
    复制代码
  • @DataProvider,可以提供更复杂的类作为参数,通常定义一个返回Object[][]的函数作为数据提供者:

    @DataProvider(name = "numbers")
    public static Object[][] evenNumbers() {
      return new Object[][]{{1, false}, {2, true}, {4, true}};
    }
    
    @Test(dataProvider = "numbers")
    public void testIsEven(Integer number, boolean expected) {
      Assert.assertEquals(expected, number % 2 == 0);
    }
    复制代码

依赖测试

依赖测试是指测试的方法是有依赖的,在执行的测试之前需要执行的另一测试。如果依赖的测试出现错误,所有的子测试都被忽略,且不会被标记为失败。JUnit目前不支持依赖,而在TestNG中,在@Test中加入dependsOnMethods = {"xxx"}即可。

并行测试

JUnit并行测试需要自己定制一个Runner,而在TestNG中,可以通过XML设置并行度:

<suite name="Concurrency Suite" parallel="methods" thread-count="2" >
  <test name="Concurrency Test" group-by-instances="true">
    <classes>
      <class name="com.alibaba.icbu.product.ConcurrencyTest" />
    </classes>
  </test>
</suite>
复制代码

综上来看,JUnit 5在功能上已经和TestNG十分接近,但TestNG还是在参数化测试、依赖测试、并行测试上更加简洁、强大。

3. Mock

Mock是单元测试中重要的一环,在许多场景中需要mock一些外部依赖,比如:

  • 依赖的外部服务的调用,比如一些webservice。
  • DAO层的调用,访问 MySQL 、Tair等底层存储。

根据之前所提到的单元测试的原则,我们可以专注于测试被测试主体的功能,而不是测试它的依赖。

基本概念

根据Martin Fowler的这篇文章,Mock有以下几个基本概念:

  • Dummy:不包含实现的对象,在测试中需要被传入,却没有真正的被使用,通常只是来填充参数列表。
  • Fake:有具体实现,但通常做了一些捷径使之不能用于生产环境,比如内存数据库。
  • Stubs:对于测试中的调用和请求,返回准备好的数据。
  • Spies:类似于Stubs,但会记录被调用的成员,用于验证数据。
  • Mocks:根据一系列对象将收到的调用已经预设好结果。

Mock原理

Mock主要分为三个阶段:

1. Record阶段:录制期望。也可以理解为数据准备阶段。创建依赖的Class或Interface或Method,模拟返回的数据、耗时及调用的次数等。

2. Replay阶段:通过调用被测代码,执行测试。期间会Invoke到第一阶段Record的Mock对象或方法。

3. Verify阶段:验证。可以验证调用返回是否正确,及Mock的方法调用次数,顺序等。

Mock框架

目前主流的Java Mock框架有JMockit、Mockito、EasyMock和PowerMock,功能对比如下:

TDD及单元测试最佳实践

从上图可以看到,JMockit的功能最为全面和强大,就笔者的实际使用体验来说,Mockito的API更加轻量易用。下面将以JMockit为例介绍一些基本的Mock。

(1) 测试设置

JMockit需要将Runner设置为JMockit。对于被Mock的对象,加上@Injectable(只创建一个Mock实例)和@Mocked(对于每个实例都创建一个Mock)注解即可。对于测试实例,加上@Tested注解。

@RunWith(JMockit.class)
public class JMockitExampleTest {

    @Tested
    JMockitExample jMockitExample;

    @Injectable
    TestDependency testDependency;
}
复制代码

在JMockit中,测试分为三个步骤:

  • Record:在一个 new Expectations(){{}} 区块中定义Mock的行为及数据。
  • Replay:调用测试类中的某个测试方法,这将调用某个Mock对象。
  • Verification:在一个 new Verifications(){{}} 区块中定义各种验证。

    @Test
    public void testWireframe() { 
    new Expectations() {{ 
       // 定义mock期望的行为
    }};
    
    // 执行测试代码
    
    new Verifications() {{ 
       // 验证mocks
    }};
    
    // 断言
    }
    复制代码

    (2) Mock对象

    对于需要Mock的对象,将其加上@Mocked注解,作为测试方法的参数传入即可。

    @Test
    public void testDoSomething(@Mocked TestDependency testDependency) throws Exception {
    }
    复制代码

    (3)Mock方法调用

    对于Mock方法调用,则是在 Expectations 区块中定义 mock.method(args); result = value; ,如果想在多次调用时返回多个值,则可以使用 returns(value1, value2,...) 。包括异常的抛出也可以在此定义。当返回的值需要一些计算逻辑时,我们就可以使用 Delegate 接口来定义result。

    对于传入Mock方法的参数,JMockit提供了 Any 来适配通用参数。每个原始类别、String均有自己的 AnyX 定义, Any 则用来匹配通用对象。

    Any 更高级一些的是 with 方法,比如 withNotNull() 限制了传入的参数不为null, withSubstring("xyz") 限制了传入的String需要含有"xyz"。

    @RunWith(JMockit.class)
    public class JMockitExampleTest {
    
    @Tested
    JMockitExample jMockitExample;
    
    @Test
    public void testDoSomething(@Mocked TestDependency testDependency) throws Exception {
        new Expectations() {{
            testDependency.intReturnMethod(); result = 3;
            testDependency.stringReturnMethod(); returns("str1", "str2"); result = SomeCheckedException();
            testDependency.methodForDelegate();
            result = new Delegate() {
                public int delegate(int i) throws Exception {
                    if (i < 3) {
                        return 5;
                    } else {
                        throw new Exception();
                    }
                }
            }
            testDependency.passStringMethod(anyString);
            testDependency.methodForTimes; times = 2;
        }}
    }
    
    jMockitExample.doSomething();
    }
    复制代码

    (4)Mock静态方法

    在被测试代码中,常常需要调用一个外部类的一个静态方法,这时候需要用到JMockit中的MockUp类。如果不想运行相关初始化逻辑,即可用 $clinit() 模拟掉。

    public class TestUtils {
        public static String staticMethod() {}
    }
    @Test
    public void testDoSomething() {
        new MockUp<TestUtils>() {
            @Mock
            void $clinit() {}
    
            @Mock
            public String staticMethod() {
                return "str";
            }
        };
    }
    复制代码

    (5)Verification

    在Verification区块中,Expectations中提到的Any以及with都可以使用。如果要验证方法调用的顺序,则可以直接创建 VerificationsInOrder 。也可以使用 FullVerifications 确保所有调用都被验证。

4. 断言

JUnit 5、TestNG这些单测框架都有自己的断言,提供了基础的API,基本能满足全部断言需求。但其缺点是不对各类数据做逻辑封装,比如判断一个String是否以"abc"开头,需要我们自己去实现。除了自带的断言,第三方断言工具中比较流行的是AssertJ和HamCrest。HamCrest并不是一个只针对单元测试的库,只是其中丰富的匹配器特别适合和断言配合使用。而AssertJ同样提供了丰富的API,不仅涵盖了基础类型、异常、日期、soft断言,还对DB、Stream、Optional等提供了支持。其流式断言的风格不仅使代码更加精简优雅,还增强了代码的可读性。对于AssertJ API的例子可以参考 此处

5. 测试覆盖率

单元测试中我们主要关注:

  • 语句覆盖率
  • 分支覆盖率

我们可以在pom中加入一些maven插件来帮助我们产生测试覆盖率报告。常用的测试覆盖率报告插件有:

  • JaCoCo
  • clover
  • cobertura

以cobertura举例,运行mvn cobertura:cobertura后,report会产生在${project}/target/site/cobertura/index.html。

ATDD

ATDD全称Acceptance Test Driven Development,验收驱动测试开发。主要是由QA编写测试用例。根据验收方法和类型的不同,ATDD又包含了BDD(Behavior Driven Development)、EDD(Example Driven Development),FDD(Feature Driven Development)、CDCD(Consumer Driven Contract Development)等各种的实践方法。


以上所述就是小编给大家介绍的《TDD及单元测试最佳实践》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

矩阵计算

矩阵计算

Gene H.Golub、Charles F.Van Loan / 袁亚湘 / 人民邮电出版社 / 2011-3-1 / 89.00元

本书是国际上数值计算方面的权威著作,有“圣经”之称。被美国加州大学、斯坦福大学、华盛顿大学、芝加哥大学、中国科学院研究生院等很多世界知名学府用作相关课程的教材或主要参考书。 本书系统地介绍了矩阵计算的基本理论和方法。书中的许多算法都有现成的软件包实现,每节后还附有习题,并有注释和大量参考文献,非常有助于自学。一起来看看 《矩阵计算》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试