前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >通过单元测试和 JaCoCo 提高 Java 代码覆盖率和质量

通过单元测试和 JaCoCo 提高 Java 代码覆盖率和质量

作者头像
breezedancer
发布2019-11-06 17:08:37
2.3K0
发布2019-11-06 17:08:37
举报
文章被收录于专栏:技术与生活技术与生活

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

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

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

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

项目环境

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

代码语言:javascript
复制
<?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>

接下来编写一个接口

代码语言:javascript
复制
public interface Calculator {
    /**
     * 根据字符串,来进行计算结果 比如 “1+1“   那么返回2.0
     * @param expression
     * @return
     */
    double process(String expression)throws CalculatorException;
}

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

代码语言:javascript
复制
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类

代码语言:javascript
复制
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();
        }
    }
}

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

代码语言:javascript
复制
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();
    }
}

测试环境&JaCoCo依赖

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

代码语言:javascript
复制
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节点增加一个插件

代码语言:javascript
复制
<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节点的内容,如下

代码语言:javascript
复制
    <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注解的方法,并且构造出对象注入到容器中。这说明了一个重要点;您可以触发代码覆盖率,而无需任何测试,但不应该如此。也就是这些测试覆盖率不是真实的覆盖率,需要注意。

那么怎么验证代码实例化呢?

接下来我们完善下测试代码,看看验证实例化是怎么回事:

代码语言:javascript
复制
@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%,如下图

我们再测试类中测试一个控制器

代码语言:javascript
复制
@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的覆盖率太低了,从上图看出。为了增加覆盖率,我们模拟一下测试内容

新增测试类,如下,其中注释的地方有问题,不在测试,只是说明一个问题,需要覆盖所有代码,包括异常

代码语言:javascript
复制
@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 的时间,将更多时间用于解决有趣的问题。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-09-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 技术谈 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 项目环境
  • 测试环境&JaCoCo依赖
  • 覆盖率
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档