单元测试基础
写在前面
测试一般听起来与开发过程无关,这是错误的,实际上测试与编码、设计和debug是紧密相关的。
一般我们采用的IDE会检查语法错误和一般的编译错误,但从来不能告诉我们代码实际做了什么,以及代码做的正确与否,这就是测试的必要性。
单元测试,使我们可以描述代码做了什么,以及是否做的正确。
很多公司在招聘时会问关于单元测试的问题,比如怎样写一个桩等等,甚至还会问是否会测试驱动开发,单元测试可以有效提高代码质量,如果用的好,甚至可以改变编码的方式。
很多开发人员都反感单元测试,觉得单元测试没有用,浪费时间,还不如写代码。在工作中,推行单元测试也是困难重重,很多情况下,版本发布之前,集中2周补齐单元测试,达到版本发布的代码覆盖率。单元测试要想真正做的有效,体现出价值,需要整个开发团队意识到单元测试的投入是值得的,更重要的是理解单元测试的价值,进而成为团队的习惯和文化。
现在我试着在这篇文章中将这些问题简单回答一下,附加一些写单元测试的技巧和准则,希望能够帮助到哪些对代码质量有追求的人。
文章中的大部分内容翻译自《Pragmatic Unit Testing in Java 8 with JUnit》ß
为什么要做单元测试
首先“单元”这个概念是不是非常明确的,单元可以简单理解为一段代码,实现了系统中的一些行为,“单元”本身不是一个端到端的行为,而常常是行为的子集。
下面是做单元测试的一些常见原因:
- 刚刚完成了一个特性,检查其按照期望运行
- 对变更进行文档化,其他人可以通过单元测试理解发生了什么
- 确认以后的更改不会影响现有行为(因现有行为已经通过充分的单元测试保证)
- 希望能够理解系统的行为(单元测试包含了一定的场景)
- 希望获知在什么情况下,第三方代码会不按照期望运行(尤其在集成其他团队代码时,最好能有单元测试保证己方代码的正确性)
最重要的是,单元测试提升了我们对代码信心。
第一个例子
在通常的实践中,单元测试目录会与业务代码隔离开,放到单独的test目录中。
建立目录
增加junit库
代码示例
package com.emcc.asset.service.inout; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner; @SpringBootTest @RunWith(SpringRunner.class) class AsyncImportServiceTest { @Autowired private AsyncImportService asyncImportService; @Test public void test() { fail("Not yet implemented"); } }
- fail静态方法来自于org.junit.Asset类
- @Test注解来自于org.junit包,只有增加了@Test注解的方法才被认为是一个有效的单元测试方法
- 测试方法名称test应该修改为更加有含义的名称,能够表明一定的逻辑,常用的格式为test${MetodName}When${Condition},如testAddNodesWhenDbIsDown
单元测试写法的套路
@Test public void answersArithmeticMeanOfTwoNumbers() { // Arrange(设置条件) ScoreCollection collection = new ScoreCollection(); collection.add(() -> 5); collection.add(() -> 7); // Act(执行操作) int actualResult = collection.arithmeticMean(); // Assert(结果判断) assertThat(actualResult, equalTo(6)); }
一般单元测试遵循设置条件;执行操作;验证结果这三个过程。
决定要测试代码中的什么部分
单元测试一般都是针对方法的,方法中包含有大量的逻辑,分支、循环等,我们需要决定要测试哪些。
一般来说,我们需要对被测方法有所理解,并对方法的关键点有针对性的进行测试,如循环、if语句、复杂的条件或者关键的变量取值等。
使用@Before注解初始化环境
如果所有的测试用例都有重复的逻辑,那么将其移动到@Before方法中,每个Junit测试用例运行时均会执行该方法。
Junit会为每个测试重新创建一个实例,从而保证不同的测试用例能够重复执行。
注意:在Junit5中,@Before和@After被@BeforeEach和@AfterEach代替。
深入断言
断言用于帮助我们判断结果是否满足条件,断言存在两种基本形式:
- 经典类型 JUnit一直以来演化的
- Hamcrest
assertTrue
用于判断值是否为真,如:
assetTrue(account.getBalance()->initialBalance);
assetThat
用于判断一个事物与另外一个事物相同,如比较两个字符串...:
assertThat(account.getBalace(),equalTo(100));
assertThat是一个Harmcrest类型的断言,第一个参数是被验证的变量,第二个参数是比较器,比较器提供了更好的可读性。
使用Harmcrest需要引入以下的静态类。
import static org.hamcrest.CoreMatchers.*;
对于assertTrue和assertThat,如下的例子:
package com.emcc.asset.service.inout; import static org.hamcrest.CoreMatchers.*; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import org.junit.jupiter.api.Test; class AsyncImportServiceTest { @Test public void test1() { assertTrue(true); } @Test public void test2() { assertThat(1, is(1)); } }
CoreMatchers中存在有很多很方便的方法,而且可以组合如:
- is
有时候可以使用is()方法装饰判断条件。
assertThat(account.getName(),is(equalTo("my big fat acct")));
- equalTo 可以用来比较两个数组是否相等
assertThat(new string[]{"a","b"}, equalTo(new String[] {"a","b"}));
- not 非
assertThat(account.getName(), not(equalTo("plunderings")));
- nullValue 空值
assertThat(account.getName(), is(not(nullValue())));
- startWith
assertThat(account.getName(), startWith("xyz"));
- endWith
除此之外,上述的方法还可以嵌套使用。
assertThat(account.getName(), is(not(nullValue())));
- allofanyofotheithereveryItemanyinstanceOfisAanythinghasItemsameInstance heInstancecontainsString
比较两个浮点型的数字
浮点型的数字不能使用equalTo这种方法比较,而是应该使用比较两个double值是否在某个容忍限度内。
解释断言
所有的JUnit断言形式,如fail()和assertThat()都支持一个可选的第一参数,用来提供对断言的说明。
@Test public void testWithWorthlessAssertionComment(){ account.deposit(50); assertThat("test account banlance", account.getBanlance(), equalTo(50)); }
另外,同时需要注意为单元测试起一个有意义的名称,以加强可读性。
测试异常
除了测试方法的正常路径之外,也需要测试方法的异常,JUnit支持三种判断异常的方式。
使用注解
JUnit的@Test标记支持通过传入一个参数来指定期待的异常类型。
@Test(expected=InsufficientFundsException.class) public void throwsWhenWithdrawingTooMuch(){ account.withdraw(100); }
当withdraw()抛出throwsWhenWithdrawingTooMuch异常时,测试通过,否则测试失败。
使用Try-Catch语句
使用Try-Catch语句捕获异常并判断异常的正确性,如果没有抛出异常,则手动调用org.junit.Assert.fail()方法置单元测试为失败。
try{ account.withdraw(100); fail(); //如果未抛出异常则失败了 } catch(InsufficientFundsException expected){ assertThat(expected.getMessage(), equalTo("balcance only 0")); //通过这里捕获并判断 }
使用ExceptedException规则
JUnit允许人们自定义规则,从而提供了更高的灵活性,例如JUnit可以提供类似于切面的机制,只是这个切面只关注测试。
import org.junit.rules.* // @Rule public ExceptedException thrown = ExceptedException.none(); //声明一个ExceptedException对象,并设置为@Rule @Test public void exceptionRule(){ thrown.except(InsufficientFundsException.class); //给异常定义规则 thrown.expectMessage("balance only 0"); account.withdraw(100); }
当该测试执行中抛出了预期的异常,则测试通过。
测试异常时的异常
当执行测试用例时,有可能也会发生异常(checked exception),不用处理,直接抛出即可,JUnit会将其标记为error。
@Test public void readsFromTestFile() throws IOException{ String filename="test.txt"; BufferedWriter writer=new BufferedWriter(new FileWriter(filename)); writer.write("test data"); writer.close(); // }
组织单元测试
该部分介绍一些JUnit的特性以及展示如何组织测试的结构。包含如下内容:
- 如何保证测试与AAA原则一致
- 通过行为保持测试可维护,而非通过方法
- 测试用例命名的重要性
- 通过@Before和@After满足通用的初始化和清理需求
- 如何安全的忽略阻塞性性测试
保持测试与AAA一致
- Arrange(准备)
- 在执行被测试代码之前,通过该步骤保证系统处于合适的待测状态上,包括创建对象,与API交互等等
- Act
- 执行被测代码,通常是调用一个单元的方法
- Assert
- 确认被测代码的行为与预期一致,可以通过观察返回值或者返回对象的新状态实现
有时候还需要第四个步骤:
- After
- 清理测试分配的资源
@Test public void answersArithmeticMeanOfTwoNumbers(){ // arrange scoreCollection collection = new ScoreCollection(); collection.add(()->5); collection.add(()->7); //act int actualResult = collection.arithmeticMean(); //assert assertThat(actualResult, equalTo(6)); }
测试行为VS测试方法
写在前面,这也是做单元测试的通常误区,为了追求覆盖率,单纯针对方法进行测试,导致测试代码臃肿,重复。
当编写测试时,应当专注于类的行为,而非单独的测试方法。
例如某个类包含了三个方法:
- deposit() 存款
- withdraw() 取款
- getBalance() 取余额
如果想单独测试deposit(),最后也会调用getBalance()方法,因此再单独测试getBalanc()就无必要了,我们可以专注测试withdraw()方法,因取款之前需要先存款,而且必定要获取余额getBalance(),因此只针对withdraw()测试即可。
总结来说:当编写单元测试时,需要以一种更为整体化的视角进行,应该通过聚合类中不通过行为,而非其中单独的方法进行测试。
测试与生产代码的关系
单元测试代码通常与生产代码在一起,但彼此隔离,生产代码并不知道测试代码的存在,换句话说,除了程序员,没有任何终端用户、客户或者非程序员会知道测试代码的存在。
如上图所示,测试类和生产类是单向依赖关系。但并不是说,测试代码对生产代码没有影响,实际上,测试代码写的越多,越会发现设计对编写单元测试的影响。
将测试和生产代码分离
还有另外两种形式,一种是测试代码与生产代码在同一个包中,或者建立单独的test目录,但代码在test中,一般我们都采用第一种形式。
暴露私有数据VS暴露私有行为
一种声音认为只应当测试公共的方法,而不要测试私有方法,因测试过多的私有方法导致与实现细节过度耦合,当代码变更时导致大量失败,影响编写者的信心。
测试私有私有数据是另外的一种方法,私有数据导致的耦合要弱于测试私有的方法,通常要通过反射才能获取私有数据,但测试私有数据和私有方法都不是最好的方法。
从设计角度来说,如果你对于一个类的私有方法感兴趣,那该方法就不应该是一个私有方法,更应当被提取出来,作为一个类来使用。
专注、且目的单一的测试用例的价值
将测试用例按照目的单一、专注分隔开,而不是夹杂在一起,这样虽然能够节省一点公共代码,但使得测试用例完全不可单独执行。
通过分离测试,我们可以:
- 立即获知出问题的行为,因junit列出了失败测试用例的名字
- 可以避免测试用例间的相互影响,利于定位问题
- 能够保证所有用例均被执行过,因如果一个用例执行失败,后面的均会删除
测试用例作为文档的一部分
测试用例针对被测类提供了可持续、可信的说明,测试用例提供了代码所不具备的,进一步解释代码的机会。
通过给与一致的名称实现测试的文档化
随着单元测试的增多,简单的命名就不足以描述复杂的行为,因此需要对名称进行具体化,如增加发生的条件和产生的结果等。
下面是一些无意义和有意义的示例:
not-so-hot-name
|
cooler, more descriptve name
|
makeSingleWithdrawal
|
withdrawalReducesBalanceByWithdrawnAmount
|
attemptToWithdrawTooMuch
|
withdrawalOfMoreThanAvailableFundsGeneratesError
|
multipleDeposits
|
multipleDepositsIncreaseBalanceBySumOfDeposits
|
下面是一些常见的命名泛型:
- doingSomeOperationGeneratessSomeResult
- someResultOccursUnderSomeCondition
- givenSomeContextWhenDoingSomeBehaviorThenSomeResultOccurs
- whenDoingSomeBehaviorThenSomeResultOccurs
保持测试有意义
如果发现测试用例难以理解,需要设法提高可读性,除了提供更加富有意义的命名外,也可以:
- 改进本地变量的命名
- 引入有意义的常量
- 使用Hamcrest风格的断言
- 将大的测试用例分割为小的,更加专注的用例
- 重构代码,将杂乱部分移动到辅助方法或者@Before方法中
仔细构造测试名字和代码,讲故事,而不是做说明性的注释。
对于@Before和@After进一步说明(通用初始化和清理)
@Before的作用范围是class中的每个方法,也即在每个方法执行前,均会执行@Before函数中的内容,其中多个@Before函数的执行顺序是不可控的。
@After的作用范围与@Before相同,常用于清理现场,如关闭数据库连接等。
注意:在Junit5中,@Before和@After被@BeforeEach和@AfterEach代替。
@BeforeClass和@AfterClass
@BeforeClass和@AfterClass表示该方法在该测试类启动和结束前运行一次且仅一次。如下面的示例:
public class AssertMoreTest{ @BeforeClass public static void initializeSomethingReallyExpensive(){ //... } @AfterClass public static void cleanUpSomethingReallyExpensive(){ //... } @Before public void createAccount(){ //... } @After public void closeConnection(){ //... } @Test public void depositIncreasesBanlance(){ //.. } @Test public void hasPositiveBanlance(){ //... }
执行顺序为:
- @BeforeClass initializeSomethingReallyExpensive
- @Before createAccount
- @Test depositIncreasesBalance
- @After closeConnections
- @Before createAccount
- @Test hasPositiveBanlance
- @After closeConnections
- @AfterClass cleanUpSomethingReallyExpensive
绿的才是好的:保持测试有意义
在单元测试失败时不要增加新功能,要尽快修改完毕,并保证单元测试在整个开发过程中一直是正确的。
”All green all of the time!"
持续测试运行快速
IDE可以使我们只运行自己关心的测试,而无需运行整个测试套,从长远看来,这隐藏着问题:可能有些问题已经出现,但只有全量运行整个测试套才会发现。
因此需要尽力保证测试快速完成,这里可以使用mock等技术,如果实在不能保证,可以考虑一次只运行一个包内的测试用例,或者考虑持续集成。
更好的解决方案是注意那些运行缓慢的用例,绝大多数的用例的执行应该是迅速的,集成测试可能需要更多的关注哪些缓慢的用例。
忽略某些测试
当不需要运行某个用例时,使用@Ingore注解加注释,在完成开发后,记得将注释去掉。
@Test @Ingore("dont't forget me!") public void somethingWenCannotHandleRightNow()P{ //.. }