TDD(测试驱动开发)既是一种软件开发技术,也是一种设计方法论。其基本思想是通过测试来推动整个开发的进行,但测试驱动开发并不只是单纯的测试工作,而是把需求分析、设计、质量控制量化的过程。
为什么要采用TDD呢?TDD有如下几点优势:
TDD的基本生命周期如下图:
下面将用我们重构中的一个简单的案例来展示TDD的过程。我们需要一个工具类来实现一个方法根据商品的tag判断一个商品是否是批发商品:
明确需求和测试用例
批发商品的tag为Long型的10000L,传入的商品tags为一个String,以逗号分隔的各个商品tag,比如 "10000, 12345"
。
我们的测试用例为如下几个:
入参 | 结果 |
---|---|
"" | false |
"12345" | false |
"10000" | true |
"12345,10000,20000" | true |
"&^837,20000,10000" | true |
实现方法
我们的测试为:
@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的基本过程,但在实际操作过程,对于一些简单的方法实现,可以跳过一些步骤直接实现。
作为开发者(Developer),需要单独完成的就是单元测试驱动开发。因为ATTD(Acceptance Test Driven Development,验收驱动测试开发)通常需要QA同学介入。下面会针对Java单元测试的框架及技术展开。
单元测试需要遵循如下几大核心原则:
在Java生态系统中,JUnit和TestNG是最受欢迎的两个单元测试框架。JUnit最早由TDD的先驱Ken Beck和Erich Gamma开发,后来由JUnit团队开发维护,截止到本文写作时间已发布JUnit 5。TestNG作为后起之秀,在JUnit的功能之外提供了一些独特的功能。下面将结合一些代码案例对两个框架的基本功能进行对比,其中JUnit将集中关注JUnit5中的功能。
一个完整的测试平台有以下几个部分组成:
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中,有如下几种数据源注解:
java @ParameterizedTest @ValueSource(strings = { "Hello", "World" }) void testStringNotNull(String word) { assertNotNull(word); }
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还是在参数化测试、依赖测试、并行测试上更加简洁、强大。
Mock是单元测试中重要的一环,在许多场景中需要mock一些外部依赖,比如:
根据之前所提到的单元测试的原则,我们可以专注于测试被测试主体的功能,而不是测试它的依赖。
根据Martin Fowler的这篇文章,Mock有以下几个基本概念:
Mock主要分为三个阶段:
1. Record阶段:录制期望。也可以理解为数据准备阶段。创建依赖的Class或Interface或Method,模拟返回的数据、耗时及调用的次数等。
2. Replay阶段:通过调用被测代码,执行测试。期间会Invoke到第一阶段Record的Mock对象或方法。
3. Verify阶段:验证。可以验证调用返回是否正确,及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; } 复制代码
在JMockit中,测试分为三个步骤:
new Expectations(){{}}
区块中定义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
确保所有调用都被验证。
JUnit 5、TestNG这些单测框架都有自己的断言,提供了基础的API,基本能满足全部断言需求。但其缺点是不对各类数据做逻辑封装,比如判断一个String是否以"abc"开头,需要我们自己去实现。除了自带的断言,第三方断言工具中比较流行的是AssertJ和HamCrest。HamCrest并不是一个只针对单元测试的库,只是其中丰富的匹配器特别适合和断言配合使用。而AssertJ同样提供了丰富的API,不仅涵盖了基础类型、异常、日期、soft断言,还对DB、Stream、Optional等提供了支持。其流式断言的风格不仅使代码更加精简优雅,还增强了代码的可读性。对于AssertJ API的例子可以参考 此处 。
单元测试中我们主要关注:
我们可以在pom中加入一些maven插件来帮助我们产生测试覆盖率报告。常用的测试覆盖率报告插件有:
以cobertura举例,运行mvn cobertura:cobertura后,report会产生在${project}/target/site/cobertura/index.html。
ATDD全称Acceptance Test Driven Development,验收驱动测试开发。主要是由QA编写测试用例。根据验收方法和类型的不同,ATDD又包含了BDD(Behavior Driven Development)、EDD(Example Driven Development),FDD(Feature Driven Development)、CDCD(Consumer Driven Contract Development)等各种的实践方法。