该文章来自https://medium.com/capital-one-tech/improve-java-code-with-unit-tests-and-jacoco-b342643736ed 翻译而成(自行解释)

作为一家技术公司,那么公司技术的快速发展是很有必要的。但同时,我们不能为了稍微快一点地交付代码质量而牺牲代码质量。编写测试是保证代码质量,同时保持快速发布计划的主要工具之一。和任何其他技能一样,测试写作必须通过实践和经验来检验。

在本文中,我们将使用一个示例程序来探讨代码覆盖率,以及在循环复杂计算当中如何确保代码正确测试。我们将学习如何使用 JaCoCo 快速获取有关代码覆盖率。最后,我们还将了解代码覆盖率的局限性,即使代码覆盖率达到 100%仍然有bug。

让我们从一个简单的应用程序开始,构建SpringBoot Web项目来来评估计算数学表达式。

项目环境

首先构建一个SPW项目,其中pom为

<?  version="1.0" encoding="UTF-8"?>
<project  ns="http://maven.apache.org/POM/4.0.0"
          ns:xsi="http://www.w3.org/2001/ Schema-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.spring work.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.spring work.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.spring work.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  
     * @return
     */
    double process(String  )throws CalculatorException;
}

具体的业务逻辑如下,分支计算比较多,为了测试代码代码覆盖率故意为之

import java.util.ArrayDeque;
import java.util.Deque;

public class CalculatorImpl implements Calculator {

    @Override
    public double process(String  ) throws CalculatorException {
        String[] tokens =  .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  : " +  , e);
        }
        double result = numbers.pop();
        if (numbers.size() > 0) {
            throw new CalculatorException("Invalid  : " +  );
        }
        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.spring work.web.bind.annotation.RequestMapping;
import org.spring work.web.bind.annotation.RequestParam;
import org.spring work.web.bind.annotation.RestController;

@RestController
public class CalcController {
    private final Calculator calculator;

    public CalcController(Calculator calculator) {
        this.calculator = calculator;
    }

    @RequestMapping("/")
    public String result(@RequestParam(" ")String  ) {
        try {
            return Double.toString(calculator.process( ));
        } catch (CalculatorException e) {
            return e.getMessage();
        }
    }
}

最后编写启动类,完成功能开发

import org.spring work.boot.SpringApplication;
import org.spring work.boot.autoconfigure.SpringBootApplication;
import org.spring work.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();
    }
}

测试环境&JaCoCo依赖

接下来我们编写一个测试类

import org.junit.Test;
import org.junit.runner.RunWith;
import org.spring work.boot.test.context.SpringBootTest;
import org.spring work.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  ) throws CalculatorException {
                if ( .equals("1 + 1")) {
                    return 2;
                }
                if ( .equals("+")) {
                    throw new CalculatorException("Invalid  : +");
                }
                throw new CalculatorException("Unexpected input: "+  );
            }
        });
        assertEquals("2.0", c.result("1 + 1"));
        assertEquals("Invalid  : +", 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< []> data() {
        return Arrays.asList(new  [][]{
                {"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  : +")},
//                {"1 1", 0, new CalculatorException("Invalid  : 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 的时间,将更多时间用于解决有趣的问题。

收藏 打印