TDD及单元测试最佳实践

TDD:What?Why?How?

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

为何要采用TDD呢?TDD有以下几点优点:java

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

TDD的基本生命周期以下图:
git

  1. 当一个需求来的时候,咱们首先要作的就是增长一个测试或者重写当前的相关测试。这个过程当中,咱们须要很是清楚的了解需求本质,反映在测试用例上,就是测试的输入是什么,获得的输出是什么。而测试数据也须要尽可能包括真实数据和边界数据。
  2. 运行测试,预期中,这个测试会失败,由于相关功能尚未被咱们加在代码中。
  3. 编写相关功能的代码,从而让测试经过。
  4. 从新运行测试,这时候不只要看第一步中的测试有没有经过,还须要看之前经过的测试有没有fail。若是测试失败,那么须要重写编写代码或者更新相关测试。
  5. 重构代码,为了让新增的测试经过,难免会堆积代码,因此要时候保持重构,去除代码中的“bad smell”。

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

  1. 明确需求和测试用例
    批发商品的tag为Long型的10000L,传入的商品tags为一个String,以逗号分隔的各个商品tag,好比"10000, 12345"web

    咱们的测试用例为以下几个:数据库

    入参 结果
    "" 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)); } 复制代码@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; } 复制代码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); } 复制代码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); } 复制代码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 BeckErich Gamma开发,后来由JUnit团队开发维护,截止到本文写做时间已发布JUnit 5。TestNG做为后起之秀,在JUnit的功能以外提供了一些独特的功能。下面将结合一些代码案例对两个框架的基本功能进行对比,其中JUnit将集中关注JUnit5中的功能。

整体架构

一个完整的测试平台有如下几个部分组成:

  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 { } 复制代码@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> 复制代码<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); } 复制代码@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> 复制代码<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; } } 复制代码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)); } 复制代码@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; } 复制代码@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); } 复制代码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); } 复制代码@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); } 复制代码@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); } 复制代码@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,功能对好比下:

从上图能够看到,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; } 复制代码@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 }}; // 断言 } 复制代码@Test public void testWireframe() { new Expectations() {{ // 定义mock指望的行为 }}; // 执行测试代码 new Verifications() {{ // 验证mocks }}; // 断言 } 复制代码

    (2) Mock对象
    对于须要Mock的对象,将其加上@Mocked注解,做为测试方法的参数传入便可。

    @Test public void testDoSomething(@Mocked TestDependency testDependency) throws Exception { } 复制代码@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(); } 复制代码@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"; } }; } 复制代码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 五、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)等各类的实践方法。