内容简介:TDD(测试驱动开发)既是一种软件开发技术,也是一种设计方法论。其基本思想是通过测试来推动整个开发的进行,但测试驱动开发并不只是单纯的测试工作,而是把需求分析、设计、质量控制量化的过程。为什么要采用TDD呢?TDD有如下几点优势:下面将用我们重构中的一个简单的案例来展示TDD的过程。我们需要一个工具类来实现一个方法根据商品的tag判断一个商品是否是批发商品:
TDD:What?Why?How?
TDD(测试驱动开发)既是一种软件开发技术,也是一种设计方法论。其基本思想是通过测试来推动整个开发的进行,但测试驱动开发并不只是单纯的测试工作,而是把需求分析、设计、质量控制量化的过程。
为什么要采用TDD呢?TDD有如下几点优势:
- 在开发的过程中,把大的功能块拆分成小的功能块进行测试,降低复杂性,帮助我们小步快跑前进。
- 遵循“keep it simple, stupid”(KISS)和“You aren't gonna need it”(YAGNI)原则,只写通过测试的必要代码,所以代码通常精简清晰(clean and clear)。
- 由于写测试用例实际上在模仿使用者,所以可以提升代码结构和接口设计的合理性。
- 尽早的暴露问题并解决,减小后续测试成本,长远的看还可以最大限度规避线上故障。
- 测试代码即文档,测试代码中的用例、入参、预期结果是对代码最好的解释。
TDD的基本生命周期如下图:
- 当一个需求来的时候,我们首先要做的就是增加一个测试或者重写当前的相关测试。这个过程中,我们需要非常清楚的了解需求本质,反映在测试用例上,就是测试的输入是什么,得到的输出是什么。而测试数据也需要尽量包括真实数据和边界数据。
- 运行测试,预期中,这个测试会失败,因为相关功能还没有被我们加在代码中。
- 编写相关功能的代码,从而让测试通过。
- 重新运行测试,这时候不仅要看第一步中的测试有没有通过,还需要看以前通过的测试有没有fail。如果测试失败,那么需要重写编写代码或者更新相关测试。
- 重构代码,为了让新增的测试通过,不免会堆积代码,所以要时候保持重构,去除代码中的“bad smell”。
下面将用我们重构中的一个简单的案例来展示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的基本过程,但在实际操作过程,对于一些简单的方法实现,可以跳过一些步骤直接实现。
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中的功能。
总体架构
一个完整的测试平台有以下几个部分组成:
- 面向开发者的API,比如各种测试注解。
- 特定于某一测试框架的测试引擎。其中JUnit 5将调用Vintage Engine来兼容JUnit 3和JUnit 4的测试,Juniper Engine则用来执行JUnit 5的测试。
- 通用测试引擎,是对第2层中各种框架引擎的抽象。
- 面向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,功能对比如下:
从上图可以看到,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及单元测试最佳实践》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- Vue 应用单元测试的策略与实践 02 - 单元测试基础
- Vue 应用单元测试的策略与实践 04 - Vuex 单元测试
- 有赞单元测试实践
- Vue 应用单元测试的策略与实践 03 - Vue 组件单元测试
- 前端单元测试实践(koa篇)
- 微服务架构下单元测试落地实践(下)
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。