zoukankan      html  css  js  c++  java
  • 一、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())));
    • 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(){
            //...
        }
    
    执行顺序为:
    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{
        //..
    }
  • 相关阅读:
    30 algorithm questions study
    Binary Tree: Write a function to return count of nodes in binary tree which has only one child.
    分布式排序
    android开发中,工程前面有感叹号的解决办法
    android导入工程出现红色感叹号
    INSTALL_FAILED_MISSING_SHARED_LIBRARY错误解决方法
    安卓手机铃声怎么设置
    如何开启Mysql远程访问
    android 链接mysql数据库
    android 连接mysql数据库问题
  • 原文地址:https://www.cnblogs.com/jiyuqi/p/13841529.html
Copyright © 2011-2022 走看看