一、Java8单元测试基础

单元测试基础

写在前面

测试一般听起来与开发过程无关,这是错误的,实际上测试与编码、设计和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())));
  • allof\anyof\both\either\everyItem\any\instanceOf\isA\anything\hasItem\sameInstance\theInstance\containsString

 

比较两个浮点型的数字

浮点型的数字不能使用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(){
        //...
    }
执行顺序为:
  1. @BeforeClass initializeSomethingReallyExpensive
  2. @Before createAccount
  3. @Test depositIncreasesBalance
  4. @After closeConnections
  5. @Before createAccount
  6. @Test hasPositiveBanlance
  7. @After closeConnections
  8. @AfterClass cleanUpSomethingReallyExpensive

绿的才是好的:保持测试有意义

在单元测试失败时不要增加新功能,要尽快修改完毕,并保证单元测试在整个开发过程中一直是正确的。
”All green all of the time!"

持续测试运行快速

IDE可以使我们只运行自己关心的测试,而无需运行整个测试套,从长远看来,这隐藏着问题:可能有些问题已经出现,但只有全量运行整个测试套才会发现。
因此需要尽力保证测试快速完成,这里可以使用mock等技术,如果实在不能保证,可以考虑一次只运行一个包内的测试用例,或者考虑持续集成。
更好的解决方案是注意那些运行缓慢的用例,绝大多数的用例的执行应该是迅速的,集成测试可能需要更多的关注哪些缓慢的用例。

忽略某些测试

当不需要运行某个用例时,使用@Ingore注解加注释,在完成开发后,记得将注释去掉。
@Test
@Ingore("dont't forget me!")
public void somethingWenCannotHandleRightNow()P{
    //..
}
posted @ 2020-10-19 17:30  纪玉奇  阅读(544)  评论(0编辑  收藏  举报