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及单元测试最佳实践》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

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

生命3.0

生命3.0

[美] 迈克斯·泰格马克 / 汪婕舒 / 浙江教育出版社 / 2018-6 / 99.90元

《生命3.0》一书中,作者迈克斯·泰格马克对人类的终极未来进行了全方位的畅想,从我们能活到的近未来穿行至1万年乃至10 亿年及其以后,从可见的智能潜入不可见的意识,重新定义了“生命”“智能”“目标”“意识”,并澄清了常见的对人工智能的误解,将帮你构建起应对人工智能时代动态的全新思维框架,抓住人类与人工智能共生演化的焦点。 迈克斯·泰格马克不仅以全景视角探讨了近未来人工智能对法律、战争、就业和......一起来看看 《生命3.0》 这本书的介绍吧!

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具

HEX CMYK 转换工具
HEX CMYK 转换工具

HEX CMYK 互转工具

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具