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

单元测试的理论概念

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如下:

            <!-- Runs JUnit tests under code coverage and creates a coverage report (target/site/jacoco/index.html). -->
            <plugin>
                <groupId>org.jacoco</groupId>
                <artifactId>jacoco-maven-plugin</artifactId>
                <version>0.8.5</version>
                <executions>
                    <execution>
                        <id>default-prepare-agent</id>
                        <goals>
                            <goal>prepare-agent</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>default-report</id>
                        <goals>
                            <goal>report</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>default-check</id>
                        <goals>
                            <goal>check</goal>
                        </goals>
                        <configuration>
                            <rules>
                                <rule>
                                    <element>BUNDLE</element>
                                    <limits>
                                        <limit>
                                            <counter>COMPLEXITY</counter>
                                            <value>COVEREDRATIO</value>
                                            <minimum>0.80</minimum>
                                        </limit>
                                    </limits>
                                </rule>
                            </rules>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
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<ProductEntity> 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<LoanDecisionRequest> 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");
          }
      
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,719评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,337评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 142,887评论 0 324
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,488评论 1 266
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,313评论 4 357
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,284评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,672评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,346评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,644评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,700评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,457评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,316评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,706评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,261评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,648评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,859评论 2 335

推荐阅读更多精彩内容