研发效能之单元测试理论与实践

单元测试的理论概念

Definition

一个单元测试就是一段代码,这段代码会调用另一段代码,然后检验某种假设的正确性。如果假设是成立的,单元测试就成功了。如果假设不成立,则算失败。

从Unit Test调用开始到结束,系统发生的所有行为总称为一个工作单元,小到一个方法,大到很多个类。


对于被测试的对象,统一被称为SUT (System Under Test),也可以称为CUT(Code Under Test)。


对于单元测试中的假设,是对执行结果的一次推断,执行结果可能是以下形式:

  • 被调用的方法的返回值
  • 方法被调用后引起的系统状态或行为变化
  • 方法被调用后引起对下游的调用

那么对于以上结果,我们分别可以进行以下推断:

  • 假设返回值等于期望值
  • 假设系统状态或行为变化为期望结果
  • 假设哪些下游系统被调用


Code Coverage

代码覆盖率是衡量单元测试的一个指标,形容代码覆盖程度。

最常用的代码覆盖率的度量方式有以下:

  • Statement Coverage

​ 又称为行覆盖率 、 段覆盖率 、 代码块覆盖率。

​ 这是最常用也是最常见的一种覆盖方式,就是度量被测代码中每个可执行语句是否被执行到了。

​ 可执行语句也就意味着不包含头文件、代码注释、空行等。


  • Decision Coverage & Condition Coverage

​ 判定覆盖和条件覆盖这两种很接近,所以放到一起。

​ 判定覆盖度量程序中每一个判定分支是否被执行到。

​ 条件覆盖度量包含子表达式覆盖情况。


  • Path Converage

​ 度量函数每一个分支是否被执行到。



demo:

int foo(int a, int b)
{
    int nReturn = 0;
    if (a < 10)
    {// 分支一
        nReturn += 1;
    }
    if (b < 10)
    {// 分支二
        nReturn += 10;
    }
    return nReturn;
}
TestCase a = 5, b = 5   nReturn = 11
语句覆盖率100%
 
TestCase1 a = 5,   b = 5     nReturn = 11
TestCase2 a = 15, b = 15   nReturn = 0
判定覆盖率100%

TestCase1 a = 5,   b = 15   nReturn = 1
TestCase2 a = 15, b = 5     nReturn = 10
条件覆盖率100%

TestCase1 a = 5,    b = 5     nReturn = 11
TestCase2 a = 15,  b = 5     nReturn = 10
TestCase3 a = 5,    b = 15   nReturn = 1
TestCase4 a = 15,  b = 15   nReturn = 0
路径覆盖率100%

可以看到路径覆盖率最靠谱,行覆盖率度量不了分支情况,而判定覆盖率和条件覆盖率效果没有路径覆盖率好。

最后对于覆盖率这回事,有以下建议:

  • 不要盲目追求覆盖率高,而是要提高case全面性
  • 不要为了提高覆盖率写没有意义的case
  • 覆盖率的卡点应该分应用,标准不应该一样


Jacoco

我们可以通过Jacoco来度量覆盖率,接入非常简单。

在maven中加入plugin如下:

            
            
                org.jacoco
                jacoco-maven-plugin
                0.8.5
                
                    
                        default-prepare-agent
                        
                            prepare-agent
                        
                    
                    
                        default-report
                        
                            report
                        
                    
                    
                        default-check
                        
                            check
                        
                        
                            
                                
                                    BUNDLE
                                    
                                        
                                            COMPLEXITY
                                            COVEREDRATIO
                                            0.80
                                        
                                    
                                
                            
                        
                    
                
            
public class CalculatorTest {

    @Test
    public void test3() {
        Calculator calculator = new Calculator();
        int expectedResult = 1;
        int actualResult = calculator.foo(5, 15);
        Assertions.assertEquals(expectedResult, actualResult, "test1测试结果不符合期待,应该返回1");
    }

    @Test
    public void test4() {
        Calculator calculator = new Calculator();
        int expectedResult = 10;
        int actualResult = calculator.foo(15, 5);
        Assertions.assertEquals(expectedResult, actualResult, "test1测试结果不符合期待,应该返回10");
    }

}

我们用上面的案例,运行jacoco试试 , 在Terminal 输入 mvn clean verify ,然后查看报告。

image.png
image.png


Practical-test-paramid

Martin Fowler 在practical-test-pyramid中提出测试金字塔的概念 。

image.png

对于研发交付流程,对研发质量测试投入精力的优先级顺序应该是:

  1. Unit Tests
  2. Service Tests
  3. User Interface Tests

Unit Test 发现问题最早 ,投入的成本最小 , 执行速度最快。所以从效能的角度来看,单元测试无疑是一个比较关键的角色。


单元测试的必要性

上面提到了测试金字塔,所以单元测试的必要性之一就是提高效能。

我能想到的必要性有这些:

  • 团队效能提升
    • 通过单测带来测试阶段的左移,及早发现问题,还有对于边界条件、执行结果的验证越齐全,联调的质量就会更高。
    • 通过行、分支覆盖率等实验室卡点,可以把控研发到交付过程中的代码质量,提高长期效能收益。
  • 带来重构的信心和保障
    • 单元测试做的越好,重构发现问题越精准,人们才会有更大的信心重构,这部分的作用不容小觑,很多开发都有优化代码的追求,却被代码现有的质量情况劝退,所以从这一点上来说,单元测试会带来良性的雪球效应,质量越高优化活动便会越容易产生。
  • 改进实现
    • 在单元测试编写过程中,如果感到很吃力,或者执行效果不佳,开发就会意识到代码设计是有问题的,然后进行优化,进一步,我们也可以尝试用TDD的思想驱动编码。
  • 通过BDD思想将期望的行为文档化
    • 基于BDD的思想,我们可以用Given、When、Then来形容一次调用,这样的好处是单测的方法可以表达行为意图,首先可以帮助新人从单测上了解业务,其次在重构时也可以针对性的进行回归。个人认为这里只适合借鉴BDD命名的思路,而不会和产品业务有任何交互。
  • 架构建设
    • 单元测试对于效能质量上的帮助,可以使核心应用的拆分合并变得更友好,利于开展架构发展工作。


优秀的单元测试实践

单元测试的作用取决于编写的质量,一个优秀的单元测试可以参考以下准则:

  • 一个单元测试只验证一种case,保证验证逻辑的单一原则。

    • 如果是方法,圈复杂度中每一个分支都应该有独立的case。
    • 如果是类,每一个public方法都应该有独立的case。
  • 可以重复执行,结果具有稳定性,每一次执行都会得到相同的结果。

    • 相反就是潮汐单测,时而成功,时而失败。
  • 执行速度快 。

    • 单个case不超过200ms
    • 单个套件不超过10s
    • 单个project不超过10分钟
  • 单元测试之间没有调用。

  • 单元测试之间没有执行顺序要求。

  • 单元测试具有原子性,要么成功要么失败。

  • 单元测试没有网络依赖

    • 如果有外部依赖,mock端口来回放内部。
    • 如果有mysql访问,用d2代替。
  • 单元测试边界条件检查良好、逻辑分支覆盖良好。

  • 命名精准、表达意图强。

    • 推荐以Given、When、Then的方式表达。
  • 结构清晰,可读性强。

    • 每一个unit test看起来应该是封装成三小段代码,Given 一个前提 ,When 真正调用 , Then 验证结果。如下:

    • @Test
      public void should_return_smart_phone_when_query_request_given_a_valid_id() {
          insertIntoDatabase(new Product(100, "Smartphone"));
      
          Product product = dao.findProduct(100);
      
          assertThat(product.getName()).isEqualTo("Smartphone");
      }
      
  • 用actual* 、 expected* 来命名执行结果与期望值。

    对比感受一下

    // Don't
    ProductDTO product1 = requestProduct(1);
    
    ProductDTO product2 = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
    assertThat(product1).isEqualTo(product2);
    
    // Do
    ProductDTO actualProduct = requestProduct(1);
    
    ProductDTO expectedProduct = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
    assertThat(actualProduct).isEqualTo(expectedProduct); // nice and clear.
    
  • 单元测试代码存放结构

    • 保持maven结构即可,将测试代码与被测试代码保持同一个package路径即可。如下

      ── pom.xml
      └── src
          ├── main
          │   ├── java
          │   │   └── com
          │   │       └── javadevelopersguide
          │   │           └── junit
          │   │               └── Calculator.java
          │   ├── resources
          └── test
              ├── java
              │   └── com
              │       └── javadevelopersguide
              │           └── junit
              │               └── CalculatorTest.java
              └── resources
      
  • Fixture 复用

    • 将创建对象行为封装、减少case创建成本 ,同样对比一下

      // Don't
      @Test
      public void categoryQueryParameter() throws Exception {
          List products = List.of(
                  new ProductEntity().setId("1").setName("Envelope").setCategory("Office").setDescription("An Envelope").setStockAmount(1),
                  new ProductEntity().setId("2").setName("Pen").setCategory("Office").setDescription("A Pen").setStockAmount(1),
                  new ProductEntity().setId("3").setName("Notebook").setCategory("Hardware").setDescription("A Notebook").setStockAmount(2)
          );
          for (ProductEntity product : products) {
              template.execute(createSqlInsertStatement(product));
          }
      
          String responseJson = client.perform(get("/products?category=Office"))
                  .andExpect(status().is(200))
                  .andReturn().getResponse().getContentAsString();
      
          assertThat(toDTOs(responseJson))
                  .extracting(ProductDTO::getId)
                  .containsOnly("1", "2");
      }
      
      // Do
      @Test
      public void categoryQueryParameter2() throws Exception {
          insertIntoDatabase(
                  createProductWithCategory("1", "Office"),
                  createProductWithCategory("2", "Office"),
                  createProductWithCategory("3", "Hardware")
          );
      
          String responseJson = requestProductsByCategory("Office");
      
          assertThat(toDTOs(responseJson))
                  .extracting(ProductDTO::getId)
                  .containsOnly("1", "2");
      }
      
  • 异常验证使用注解或者推断

        @Test(expected = InstitutionDecisionException.class)
        public void testXx(){}
    
        @Test
        void exceptionTesting() {
            Exception exception = assertThrows(ArithmeticException.class, () ->
                calculator.divide(1, 0));
            assertEquals("/ by zero", exception.getMessage());
        }
    
  • Suite 套件

    @RunWith(Suite.class)
    @Suite.SuiteClasses({
      LoginServiceTest.class,
      UserServiceTest.class,
    })
    public class SuiteTest {
    }
    
  • DisplayName 注释

        @Test
        @DisplayName("alias")
        public void testXx() {}
    
  • 尽可能把握mock的度,mock有利有弊
  • 使用一些AssertJ之类的断言api

    assertThat(actualProduct)
            .isEqualToIgnoringGivenFields(expectedProduct, "id");
    
    assertThat(actualProductList).containsExactly(
            createProductDTO("1", "Smartphone", 250.00),
            createProductDTO("1", "Smartphone", 250.00)
    );
    
    assertThat(actualProductList)
            .usingElementComparatorIgnoringFields("id")
            .containsExactly(expectedProduct1, expectedProduct2);
    
    assertThat(actualProductList)
            .extracting(Product::getId)
            .containsExactly("1", "2");
    
    assertThat(actualProductList)
            .anySatisfy(product -> assertThat(product.getDateCreated()).isBetween(instant1, instant2));
    
    assertThat(actualProductList)
            .filteredOn(product -> product.getCategory().equals("Smartphone"))
            .allSatisfy(product -> assertThat(product.isLiked()).isTrue());
    
        @Test
        fun `grouped assertions`() {
            assertAll("Person properties",
                { assertEquals("Jane", person.firstName) },
                { assertEquals("Doe", person.lastName) }
            )
        }
    
  • 断言信息描述清晰一些

    // Don't 
    assertTrue(actualProductList.contains(expectedProduct));
    assertTrue(actualProductList.size() == 5);
    assertTrue(actualProduct instanceof Product);
    

    像上述这种case失败以后,一眼是看不出来原因的,报错信息很坑。

    两种办法

    • 使用AssertJ之类的

      // Do
      assertThat(actualProductList).contains(expectedProduct);
      assertThat(actualProductList).hasSize(5);
      assertThat(actualProduct).isInstanceOf(Product.class);
      
    • 加错误提示

      // Do 
      assertTrue(actualProductList.contains(expectedProduct) , "xxxxxxx");
      assertTrue(actualProductList.size() == 5 , "xxxxxx");
      assertTrue(actualProduct instanceof Product , "xxxxxxx");
      
  • Spring应用对外部依赖mock处理

        @MockBean
        InstitutionDecisionFacade institutionDecisionFacade;
        
        private void mock(){
        
            LoanDecisionResponse response = new LoanDecisionResponse();
            LoanDecisionInfoDTO loanDecisionInfoDTO = new LoanDecisionInfoDTO();
            loanDecisionInfoDTO.setHasAvailableInstitution(true);
            loanDecisionInfoDTO.setInstitutionCode(InstitutionTypeEnum.ALIBABA.name());
            loanDecisionInfoDTO.setLoanFundPlanNo("mock loanFund");
            response.setDecisionNo("decisionNo mock");
            response.setDecisionInfo(loanDecisionInfoDTO);
            response.setSuccess(true);
            when(institutionDecisionFacade.loanDecision(any())).thenReturn(response);
        }
    
  • 对参数的captor验证

        @Captor
        private ArgumentCaptor loanInstitutionDecisionRequestArgumentCaptor;
    
     
        private void thenCheck(){
                     verify(institutionDecisionFacade).loanDecision(loanInstitutionDecisionRequestArgumentCaptor.capture());
            LoanDecisionRequest loanDecisionRequest = loanInstitutionDecisionRequestArgumentCaptor.getValue();
    
            assertEquals(loanDecisionRequest.getProduct(), ProductTypeEnum.SAMPLE_PRODUCT.name());
            assertEquals(loanDecisionRequest.getTenant(), InstitutionTypeEnum.ALIBABA.name());
            assertEquals(loanDecisionRequest.getAmount(), BigDecimal.valueOf(2000L));
            assertEquals(loanDecisionRequest.getCurrency(), "CNY");
            assertEquals(loanDecisionRequest.getLoanType(), "LOAN");
            assertEquals(loanDecisionRequest.getUser().getUserId(), "1000");
            assertEquals(loanDecisionRequest.getUser().getUserType(), CustomerTypeEnum.ALI.name());
            assertEquals(loanDecisionRequest.getUser().getNativeUser(), null);
            assertEquals(loanDecisionRequest.getCustomerProfile().getCifNo(), Long.valueOf(3000));
        }
    
  • Before After setup处理

    • 将初始化和mock行为可以放到before流程,释放放到after流程。

          @Before
          public void init() {
              TmfTestUtil.register(InstitutionDecisionDomainService.class.getPackage().getName(),
                  "com.alibaba.fin.tfp.solution.functions.institution.decision");
          }
      

你可能感兴趣的:(研发效能之单元测试理论与实践)