该文章来自https://medium.com/capital-one-tech/improve-java-code-with-unit-tests-and-jacoco-b342643736ed 翻译而成(自行解释)
作为一家技术公司,那么公司技术的快速发展是很有必要的。但同时,我们不能为了稍微快一点地交付代码质量而牺牲代码质量。编写测试是保证代码质量,同时保持快速发布计划的主要工具之一。和任何其他技能一样,测试写作必须通过实践和经验来检验。
在本文中,我们将使用一个示例程序来探讨代码覆盖率,以及在循环复杂计算当中如何确保代码正确测试。我们将学习如何使用 JaCoCo 快速获取有关代码覆盖率。最后,我们还将了解代码覆盖率的局限性,即使代码覆盖率达到 100%仍然有bug。
让我们从一个简单的应用程序开始,构建SpringBoot Web项目来来评估计算数学表达式。
首先构建一个SPW项目,其中pom为
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.ts</groupId>
<artifactId>mylab</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.6.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
接下来编写一个接口
public interface Calculator {
/**
* 根据字符串,来进行计算结果 比如 “1+1“ 那么返回2.0
* @param expression
* @return
*/
double process(String expression)throws CalculatorException;
}
具体的业务逻辑如下,分支计算比较多,为了测试代码代码覆盖率故意为之
import java.util.ArrayDeque;
import java.util.Deque;
public class CalculatorImpl implements Calculator {
@Override
public double process(String expression) throws CalculatorException {
String[] tokens = expression.split(" ");
Deque<String> operators = new ArrayDeque<>();
Deque<Double> numbers = new ArrayDeque<>();
try {
for (String token : tokens) {
switch (token) {
case "+":
case "-":
case "/":
case "*":
while (shouldEvaluate(token, operators.peekFirst())) {
String op = operators.pop();
double second = numbers.pop();
double first = numbers.pop();
double result;
switch (op) {
case "+":
result = first + second;
break;
case "-":
result = first - second;
break;
case "*":
result = first * second;
break;
case "/":
result = first / second;
break;
default:
throw new CalculatorException("Unexpected operator " + op);
}
numbers.push(result);
}
operators.push(token);
break;
case "(":
operators.push(token);
break;
case ")":
for (String op = operators.peekFirst(); !op.equals("("); op = operators.peekFirst()) {
operators.pop();
double second = numbers.pop();
double first = numbers.pop();
double result;
switch (op) {
case "+":
result = first + second;
break;
case "-":
result = first - second;
break;
case "*":
result = first * second;
break;
case "/":
result = first / second;
break;
default:
throw new CalculatorException("Unexpected operator " + op);
}
numbers.push(result);
}
operators.pop();
break;
default:
double d = Double.parseDouble(token);
numbers.push(d);
break;
}
}
for (String op = operators.peekFirst(); op != null; op = operators.peekFirst()) {
operators.pop();
double second = numbers.pop();
double first = numbers.pop();
double result = 0;
switch (op) {
case "+":
result = first + second;
break;
case "-":
result = first - second;
break;
case "*":
result = first * second;
break;
case "/":
result = first * second;
break;
default:
throw new CalculatorException("Unexpected operator " + op);
}
numbers.push(result);
}
} catch (Exception e) {
throw new CalculatorException("Invalid expression: " + expression, e);
}
double result = numbers.pop();
if (numbers.size() > 0) {
throw new CalculatorException("Invalid expression: " + expression);
}
return result;
}
private boolean shouldEvaluate(String newOp, String topOp) {
if (topOp == null || topOp.equals("(")) {
return false;
}
// with 4 standard operators, the only time you don't evaluate is
// when the new operator is a * or / and the top operator is a + or -
// topOp newOp shouldEvaluate
// ----- ----- --------------
// +, - +, - true
// *, / +, - true
// +, - *, / false
// *, / *, / true
if ((topOp.equals("+") || topOp.equals("=")) && (newOp.equals("*") || newOp.equals("/"))) {
return false;
}
return true;
}
}
编写Controller类
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CalcController {
private final Calculator calculator;
public CalcController(Calculator calculator) {
this.calculator = calculator;
}
@RequestMapping("/")
public String result(@RequestParam("expression")String expression) {
try {
return Double.toString(calculator.process(expression));
} catch (CalculatorException e) {
return e.getMessage();
}
}
}
最后编写启动类,完成功能开发
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@Bean
public Calculator calculator() {
return new CalculatorImpl();
}
}
接下来我们编写一个测试类
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest
public class DemoApplicationTests {
@Test
public void contextLoads() {
}
}
不过这段测试代码运行完毕后,什么都没有测试到。我们需要增加JaCoCo依赖包,来完成单元测试的覆盖。
pom文件的build节点增加一个插件
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.2</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>prepare-package</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
还需要增加reporting节点的内容,如下
<reporting>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<reportSets>
<reportSet>
<reports>
<!-- select non-aggregate reports -->
<report>report</report>
</reports>
</reportSet>
</reportSets>
</plugin>
</plugins>
</reporting>
好了到此为止,我们的环境Ok了,接下来运行mvn test jacoco:report
,最终在target目录生成如下内容
在浏览器中打开index.html,可以看到下面这个图像
有很多红色的线段。在继续之前,让我们回顾一下表中的列,以便了解我们正在寻找什么,以及我们需要改进什么。
第一列,元素列:元素列提供当前应用程序中的包。您可以使用此列向下钻取代码,以准确查看涵盖的内容和未涵盖的内容。我们将在一点一点中介绍这一点,但首先我们将查看其他列。
Missed Instructions :这提供了测试中涵盖的 Java 字节码指令数量的图形和百分比度量。红色表示未覆盖,绿色表示覆盖。
Missed Branches:这给出了测试中涵盖的 [分支] 数量的图形和百分比度量。分支是代码中的决策点,您需要(至少)为决策的每个可能方式提供(至少)测试,以便获得完全覆盖。
Missed & Cxty: 在这里,我们找到您的源代码的循环复杂性分数。在包级别,这是包中所有类中所有方法的分数之和。在类级别,它是类中所有方法的分数总和,在方法级别,它是方法的分数。
Missed & Lines: 这是代码行数和有多少行没有完整的覆盖。
Missed & Methods:这是表示多少方法没有覆盖到。
Missed & Classes:这代表多少类没有覆盖到。
我们点击第一列的包名,一直追溯到启动类的实现,可以发现他的覆盖率是58%。
再深入点击进去,可以看到更加具体的覆盖情况
还可以继续点击方法名称,可以看到里面代码行的覆盖情况
红色的表示没有覆盖到的,绿色表示已经覆盖了。
我们没有写如何的测试代码,但是却有58%的覆盖率,这个是怎么回事呢?原来测试类的注解SpringBootTest
会启动一个Spring Application上下文,而这将会加载拥有@Bean
注解的方法,并且构造出对象注入到容器中。这说明了一个重要点;您可以触发代码覆盖率,而无需任何测试,但不应该如此。也就是这些测试覆盖率不是真实的覆盖率,需要注意。
那么怎么验证代码实例化呢?
接下来我们完善下测试代码,看看验证实例化是怎么回事:
@RunWith(SpringRunner.class)
@SpringBootTest
public class DemoApplicationTests {
@Autowired
ApplicationContext ac;
@Test
public void contextLoads() {
Calculator calculator = ac.getBean(Calculator.class);
assertTrue(calculator instanceof CalculatorImpl);
CalcController calcController = ac.getBean(CalcController.class);
assertNotNull(calcController);
}
}
测试代码如上,还是运行 mvn test jacoco:report
完成之后,代码的覆盖率并没有发生变化,但本质已经不一样了,因为我们现在能确信我们Calculator和CalcController是真实有效的了。
测试Controller方法
目前的CalcController的覆盖率是37%,如下图
我们再测试类中测试一个控制器
@Test
public void result() {
CalcController c = new CalcController(new Calculator() {
@Override
public double process(String expression) throws CalculatorException {
if (expression.equals("1 + 1")) {
return 2;
}
if (expression.equals("+")) {
throw new CalculatorException("Invalid expression: +");
}
throw new CalculatorException("Unexpected input: "+ expression);
}
});
assertEquals("2.0", c.result("1 + 1"));
assertEquals("Invalid expression: +", c.result("+"));
}
再次运行mvn test jacoco:report
,得到结果,此时CalcController的覆盖率是100%了
我们的CalculatorImpl的覆盖率太低了,从上图看出。为了增加覆盖率,我们模拟一下测试内容
新增测试类,如下,其中注释的地方有问题,不在测试,只是说明一个问题,需要覆盖所有代码,包括异常
@RunWith(Parameterized.class)
public class CalculatorTest {
@Parameterized.Parameters(name = "{index}: CalculatorTest({0})={1}, throws {2}")
public static Collection<Object[]> data() {
return Arrays.asList(new Object[][]{
{"1 + 1", 2, null},
{"1 + 1 + 1", 3, null},
// {"1 – 1", 0, null},
{"1 * 1", 1, null},
{"1 / 1", 1, null},
{"( 1 + 1 )", 2, null},
// {" + ", 0, new CalculatorException("Invalid expression: +")},
// {"1 1", 0, new CalculatorException("Invalid expression: 1 1")}
});
}
private final String input;
private final double expected;
private final Exception exception;
public CalculatorTest(String input, double expected, Exception exception) {
this.input = input;
this.expected = expected;
this.exception = exception;
}
@Test
public void testProcess() {
Calculator c = new CalculatorImpl();
try {
double result = c.process(input);
if (exception != null) {
fail("should have thrown an exception: " + exception);
}
// shouldn't compare doubles without a delta, because FP math isn't accurate
assertEquals(expected, result, 0.000001);
} catch (Exception e) {
if (exception == null) {
fail("should not have thrown an exception, but threw " + e);
}
if (!exception.getClass().equals(e.getClass()) || !exception.getMessage().equals(e.getMessage())) {
fail("expected exception " + exception + "; got exception " + e);
}
}
}
}
之后运行mvn test jacoco:report
可以看到跟到的代码测试被覆盖到了。
逐步增加测试范围,知道最终代码覆盖率全部为绿色通过为止。
测试是许多开发人员避免做的事情。但是,通过一些简单的工具和对该过程的一些了解,测试可以帮助您减少跟踪 Bug 的时间,将更多时间用于解决有趣的问题。