单元测试的理论概念
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 ,然后查看报告。
Practical-test-paramid
Martin Fowler 在practical-test-pyramid中提出测试金字塔的概念 。
对于研发交付流程,对研发质量测试投入精力的优先级顺序应该是:
- Unit Tests
- Service Tests
- 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"); }
-