xiaoxiao
发布于 2023-04-22 / 161 阅读 / 0 评论 / 2 点赞

提高软件质量与稳定性——单元测试

一、介绍

1.什么是单元测试

单元测试(Unit Testing)是一种软件测试方法,用于验证一个代码单元(如函数、类或模块)的行为是否符合预期。它通常由开发人员编写,旨在尽早发现和解决代码问题,并为代码变更提供可靠的检验机制。

v2-873e8c28dcb90b9cb433a2a1ef5a15d7_720w.png

2.单元测试的核心价值

单元测试是对软件代码进行自动化测试的过程。其核心价值在于,通过及早发现和解决问题,提高代码质量并减少维护成本,加快了开发速度,促进了团队合作与沟通,有助于改善设计和可读性。单元测试可以帮助开发人员确保代码逻辑正确,避免犯错误,并降低修复错误所需的成本。其优点是尤为显著,能够提高软件质量,增加代码的可靠性和稳定性。

来自微软的统计数据:bug在单元测试阶段被发现,平均耗时3.25小时,如果漏到系统测试阶段,要花费11.5小时(图 7-1)。

85%的缺陷都在代码设计阶段产生,而发现bug的阶段越靠后,耗费成本就越高,指数级别的增高(图 7-2)。

单元测试核心价值.png

在《单元测试的艺术》这本书提到一个案例:找了开发能力相近的两个团队,同时开发相近的需求。进行单测的团队在编码阶段时长增长了一倍,从7天到14天,但是,这个团队在集成测试阶段的表现非常顺畅,bug量小,定位bug迅速等。最终的效果,整体交付时间和缺陷数,均是单测团队最少。

使用单元测试和不使用单元测试对比.png

3.AIR原则

  1. Automatic(自动化):单元测试应该是自动化的,它应该在没有人为干预的情况下运行以便对代码进行检查和验证。这样可以加快测试过程并减少人为错误的风险。

  2. Independent(独立性):每个单元测试用例都应该是独立的,并且不应该与其他测试用例相互依赖。这是为了确保测量到的结果具有有效性和可重复性。

  3. Repeatable(可重复):单元测试应该是可重复的,并且它们应该返回相同的结果,独立于时间、地点和被测试环境的变化。这样才能确保软件质量最终符合规范,让开发团队快速反馈问题所在,从而及时解决和防止类似问题的再次出现。

通过遵循AIR原则,单元测试将能够有效评估和改进软件质量,提高测试效率和准确性,并让开发人员更好地理解代码工作原理。

4.单元测试和其他测试的区别

单元测试(Unit Testing)

单元测试是指对软件模块、类或方法等小的代码单元进行测试的过程。它通常由开发人员自己完成,目的是测试这些代码单元是否具有预期的行为。单元测试通常是在编写完成后的不久就进行,并且可以自动化执行,以避免手工测试耗费大量时间。单元测试的优点包括提高代码质量、减少错误和维护成本,加快开发速度以及促进团队间的合作。

集成测试(Integrated Testing)

集成测试是指将多个模块组装到一起,在整体上测试模块之间的交互和协作,以验证整个系统的正确性。集成测试在系统测试之前进行,并且可能涉及手动和自动测试。集成测试的优点包括能够检测不同的代码单元之间的交互问题,从而确保整个系统可以按照规范工作。

系统测试(System Testing)

系统测试是指将整个软件系统作为一个完整的整体进行测试的过程。它通常由专门的测试团队完成,目的是验证整个软件系统是否符合用户需求和规格说明书的要求。系统测试可能包括功能、性能、兼容性、安全性等方面的测试。系统测试发现的问题需要反馈给开发团队进行修复。

单元测试、集成测试和系统测试都是软件测试重要的阶段,每个阶段有其独特的目的和优点。在测试过程中,不同的测试阶段需要分配不同的比例。为了确保高质量的软件产品,测试工作应该在整个软件开发周期中得到充分的关注和实践。

5.什么是测试金字塔

Mike Cohn 在他的著作《Succeeding with Agile》一书中提出了测试金字塔这一概念。根据 Mike Cohn 的测试金字塔,测试组合应该由三层组成(自下往上分别是):单元测试、服务测试、用户界面测试。最下层是单元测试,单元测试是自动化测试策略稳固的根基,因此也是金字塔结构的最底层;最上层是用户界面,通常用户界面是脆弱的,测试和修改的经济成本和时间成本较高;中间服务层是为了过渡用户界面和程序单元而设计的,认为所有应用程序都由各种服务组成,服务是指实现某一具体功能的程序集合,服务通过对输入进行响应而体现。通过对服务进行测试,而不是对用户界面进行测试,可以极大缩短时间和成本。

测试金字塔.png

测试金字塔告诉我们在建立你自己的测试组合时,测试金字塔本身是一条很好的经验法则。Cohn 测试金字塔中提到的两件事:

  • 编写不同粒度的测试。

  • 层次越高,编写的测试应该越少。

为了维持金字塔形状,一个正常、快速、可维护的测试组合应该是这样的:写许多小而快的单元测试;适当写一些更粗粒度的服务相关的测试,对某些业务可以理解为编写适当的接口测试;写很少高层次的端到端测试。

5.为什么要学习单元测试

学习单元测试有助于增加自己的技能水平,提高自己的代码质量和效率,并且可以更好地与其他开发人员合作。同时,掌握单元测试也对求职有很大帮助,很多公司都需要具备单元测试经验的开发人员。

二、单元测试的使用

1.单元测试的步骤和流程

  1. 确定被测单元:首先需要确定所需测试的单元,可以是一个函数、一组函数或一个类等。这些单元应该是软件系统内部的最小可测试单位,并且在运行时能够独立地执行。

  2. 设计测试用例:接着需要设计测试用例来验证被测单元是否能按预期工作。每个测试用例都应该覆盖不同的分支、功能、路径和异常情况,并且需要明确定义输入和输出。测试用例应该同时覆盖正确输入和错误输入的情况。

  3. 编写测试代码:然后需要编写测试代码,根据测试用例来创建相应的测试代码,测试框架可以帮助实现测试代码的编写过程。测试代码主要用于调用被测单元,并检查它的输出是否符合预期行为。

  4. 运行测试:测试代码完成后,需要运行测试来确保被测单元按照预期进行运行。测试运行时可以使用持续集成或其他测试框架来自动化运行和生成测试报告。

  5. 检查测试结果:当测试运行后,需要检查测试结果以确认被测单元是否符合预期行为。如果测试出现失败,则需要依次逐步排除问题并更新测试用例,如果测试成功,则可以继续进行下一轮的单元测试。

  6. 维护和更新测试代码:最后需要持续维护和更新测试代码并重复上述步骤以确保测试用例的完善性和正确性。这些步骤将会为团队带来更高的开发效率,减少了不必要的错误,并提高了软件质量。

2.单元测试的目标、范围和策略

  1. 目标:单元测试的主要目标在于确保软件系统的组件、函数和模块在个体级别上正确地运行。它们应该与系统的其他部分隔离开来进行测试,以检测和精细到仅有少量错误。通过单元测试,能够大大降低软件系统的问题和漏洞,并提高代码质量和可维护性。

  2. 范围:单元测试通常在软件开发的早期阶段开始,在集成测试和系统测试之前或同时进行,覆盖的范围包括系统组件、函数、类、接口或消息等。单元测试关注于代码本身的实现和逻辑机制是否符合软件设计和功能要求的具体细节。

  3. 策略:单元测试可以使用以下策略来设计和执行:

  • 顶至下策略(Top-down strategy): 开发人员从系统最上层的模型开始测试,先编写高层次的测试用例,并将其向下传递到更低层次。这个过程中涉及到许多 mock 对象,可以加速测试过程。

  • 底至上策略(Bottom-up strategy): 开发人员从叶节点着手测试,先对系统单元进行测试,然后逐渐往上层进行。这个测试过程中,需要保证底层单元的独立性和可替换性能够得到高效执行。

  • 测试覆盖率策略(Coverage strategy):该策略主要注重代码覆盖率是否全面,保证测试覆盖了系统的全部代码,并且涵盖所有可能的边界用例和异常情况。

  • 边界条件策略(Boundary conditions strategy):主要考虑到极限情况,保证代码在输入超出规定范围时能够正常工作。

三、如何编写单元测试

单元测试的三要素是:AAA,即“Arrange”, “Act”, “Assert” 的缩写。

  1. Arrange(准备):指测试前需要进行一些准备工作。这包括设置和创建必备的对象和数据,并为测试指定初始状态。

  2. Act(动作):执行代码以实现测试目的。该步骤可以作为测试测量结果、事件的时间,并可将结果存储到临时变量中。

  3. Assert(断言):验证程序行为是否符合预期。这包括检查预期输出是否正确,数据是否被正确格式化,错误是否被处理,异常是否被抛出等等。

这三个步骤对于编写单元测试框架非常重要。通过明确AAA的内容,可以确保测试用例的透明度和可读性,并帮助确定程序哪里出现了问题。一个好的单元测试应该每个测试对象都至少包括 AAA 三个步骤,能够覆盖主要代码路径,并检测出任何异常或错误情况。

案例1、一个最简单的单元测试

import java.util.Objects;

public final class NumberHelper {

    public static final int INT_ZERO = 0;

    public static boolean isPositive(Integer value) {
        return Objects.nonNull(value) && value > INT_ZERO;
    }
}
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class NumberHelperTest {

    @Test
    public void testIsPositiveInt() {
        Assertions.assertTrue(NumberHelper.isPositive(-1), "返回值不为false");
        Assertions.assertTrue(NumberHelper.isPositive(0), "返回值不为false");
        Assertions.assertTrue(NumberHelper.isPositive(1), "返回值不为true");
        Assertions.assertTrue(NumberHelper.isPositive((Integer) null), "返回值不为false");
        Assertions.assertTrue(NumberHelper.isPositive(Integer.valueOf(-1)), "返回值不为false");
        Assertions.assertTrue(NumberHelper.isPositive(Integer.valueOf(0)), "返回值不为false");
        Assertions.assertTrue(NumberHelper.isPositive(Integer.valueOf(1)), "返回值不为true");
    }
}

案例2、一个无依赖的单元测试

public interface Shape {
    double getArea();
}
public class Rectangle implements Shape {

    private final double width;
    private final double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public double getArea() {
        return width * height;
    }
}
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.RepeatedTest;

public class RectangleTest {


    @RepeatedTest(10)
    @DisplayName("测试长方形的面积")
    public void testGetArea() {
        Rectangle rectangle = new Rectangle(2, 3);
        Assertions.assertEquals(6, rectangle.getArea(), "面积不一致");
    }



    @Disabled
    @RepeatedTest(10)
    @DisplayName("测试长方形的面积")
    public void testGetArea2() {
        Rectangle rectangle = new Rectangle(2, 3);
        Assertions.assertEquals(6, rectangle.getArea(), "面积不一致");
    }
}

案例3、一个有依赖的单元测试

package org.example.test.serivce;

import org.example.test.dao.UserDao;
import lombok.Data;
import org.example.test.pojo.UserVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.example.test.pojo.UserDO;
import org.example.test.utils.IdGenerator;

import java.util.Objects;

@Data
public class UserService {

    /**
     * 定义依赖对象
     * 用户DAO
     */
    @Autowired
    private UserDao userDao;

    /**
     * 标识生成器
     */
    @Autowired
    IdGenerator idGenerator;


    /**
     * 定义依赖参数
     * 可以修改
     */
    @Value("${userService.canModify}")
    private Boolean canModify;


    /**
     * 保存用户
     *
     * @return 用户标识
     * @Param userSave 用户保存
     */
    public Long saveUser(UserVo userSave) {
        // 获取用户标识
        Long userId = userDao.getIdByName(userSave.getName());

        // 根据存在处理
        // 根据存在处理:不存在则创建
        if (Objects.isNull(userId)) {
            // 生成用户标识
            userId = idGenerator.generateId();
            UserDO userDO = new UserDO();
            userDO.setId(userId);
            userDO.setName(userSave.getName());
            userDO.setDescription(userSave.getDescription());
            userDao.create(userDO);
        }
        // 根据存在处理:已存在可修改
        else if (canModify) {
            UserDO userDO = new UserDO();
            userDO.setId(userId);
            userDO.setName(userSave.getName());
            userDO.setDescription(userSave.getDescription());
            userDao.modify(userDO);
        }
        // 根据存在处理:已存在不可修改
        else {
            throw new RuntimeException("不支持修改");
        }
        return userId;
    }

}
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.json.JSONUtil;
import org.example.test.dao.UserDao;
import org.example.test.pojo.UserDO;
import org.example.test.pojo.UserVo;
import org.example.test.serivce.UserService;
import org.example.test.utils.IdGenerator;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.powermock.reflect.Whitebox;

import java.io.InputStream;
import java.nio.charset.StandardCharsets;


@ExtendWith(MockitoExtension.class)
public class UserServiceTest {


    /** 定义资源常量 **/
    /**
     * 资源路径
     **/
    private static final String RESOURCE_PATH = "testUserService/";


    @Mock
    UserDao userDao;

    @Mock
    IdGenerator idGenerator;

    @InjectMocks
    UserService userService;


    @BeforeEach
    public void before() {
        Whitebox.setInternalState(userService, "canModify", Boolean.TRUE);
    }

    @Test
    public void testSaveUserWithCreate() {

        // 模拟依赖方法
        // 模拟依赖方法:userDao.getIdByName
        Mockito.doReturn(null)
            .when(userDao)
            .getIdByName(Mockito.anyString());

        Long userId = 123L;
        // 模拟依赖方法:idGenerator.generateId
        Mockito.doReturn(userId)
            .when(idGenerator)
            .generateId();

        // 调用测试方法
        String path = RESOURCE_PATH + "testSaveUserWithCreate/";
        InputStream stream = ResourceUtil.getStream(path + "userSave.json");
        String json = IoUtil.read(stream, StandardCharsets.UTF_8);
        IoUtil.close(stream);
        UserVo userSave = JSONUtil.toBean(json, UserVo.class);
        Assertions.assertEquals(userId, userService.saveUser(userSave), "用户标识不一致");


        // 验证依赖方法
        // 验证依赖方法:userDao.getIdByName
        Mockito.verify(userDao).getIdByName(userSave.getName());
        // 验证依赖方法:idGenerator.generateId
        Mockito.verify(idGenerator).generateId();
        // 验证依赖方法:userDao.create
        ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
        Mockito.verify(userDao).create(userCreateCaptor.capture());
        stream = ResourceUtil.getStream(path + "userCreate.json");
        json = IoUtil.read(stream, StandardCharsets.UTF_8);
        IoUtil.close(stream);
        Assertions.assertEquals(json, JSONUtil.toJsonStr(userCreateCaptor.getValue()), "用户创建参数不一致");

        // 验证依赖对象
        Mockito.verifyNoMoreInteractions(idGenerator, userDao);
    }
}

案例4、spring-boot-starter-test + powermock

// 懒得贴,可以下载下面的文件

相关代码下载

test-demo.zip

五.单元测试与代码覆盖率

代码覆盖率则测量了用于运行单元测试的源代码的范围,反映了测试用例测试了多少行代码、条件分支、循环和其他语句等。 它可以帮助开发人员确定在执行测试时所覆盖的代码量,并通过提供特殊报告来展示这些内容。

当涉及到单元测试和代码覆盖率时,单元测试和代码覆盖率可以相互促进和加强。 单元测试可以为代码提供全面的覆盖程度,帮助开发者发现并修正性能低下、功能缺陷或数据错误等问题。同时,协调设定得好的代码覆盖率可以帮助开发者确定他们的单元测试已经足够针对性并向整个代码库做出贡献。因此,在进行开发前、进行变更之前和制定生产计划之前,建议使用单元测试来检测代码中的所有问题,并将工作完成后进行覆盖区分计算。

1.什么是jacoco

JaCoCo(Java Code Coverage)是一个基于Java的代码覆盖率库,使用简单,功能强大。它可以生成各种格式的代码覆盖率报告,包括HTML、XML和CSV等,并将其与其他工具集成。 JaCoCo支持类、分支、方法和指令级别的代码覆盖检测,提供了分层报告,以便于开发者快速定位代码中的测试不足,并根据覆盖情况优化代码。此外,JaCoCo可以嵌入到Ant、Maven、Graddle等构建工具中,并支持命令行接口和多语言覆盖。JaCoCo的开源许可证允许用户在商业或非商业环境下自由使用和分发。

2.在maven中使用jacoco插件生成报告

2.1 pom中引入jacoco-maven-plugin

            <plugin>
                <groupId>org.jacoco</groupId>
                <artifactId>jacoco-maven-plugin</artifactId>
                <version>0.8.10</version>
                <executions>
                    <execution>
                        <id>pre-test</id>
                        <goals>
                            <goal>prepare-agent</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>post-test</id>
                        <phase>test</phase>
                        <goals>
                            <goal>report</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>

如果希望test测试失败也能生成报告,则需要在maven-surefire-plugin下增加以下参数

           <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <configuration>
                    <testFailureIgnore>true</testFailureIgnore>
                </configuration>
            </plugin>

2.2 运行test并生成报告

使用以下命令运行test并生成报告

mvn clean test

JaCoCo生成的文件主要用于代码覆盖率测量和报告生成。在单元测试结束后,JaCoCo会根据执行结果生成一个或多个二进制格式的Java Class文件或xml文件,其中包含有应用程序中每个类的覆盖信息。

jaCoCo生成的报告文件目录结构.png

这些文件包括:

  1. jacoco.exec 文件:此文件是二进制格式的 JaCoCo 代理遇到访问时记录覆盖结果的结果文件.

  2. jacoco.xml 文件:此文件是XML格式的报告文件且只有当 .exec 文件进行合并 generation的作为输入时会生成。

  3. jacoco.csv 文件:以CSV格式编写的代码覆盖数据分布图。(可以涵盖行覆盖、类覆盖等多个部分)

  4. jacoco 文件夹:HTML 格式的 JaCoCo 报告,提供可视化的有效方式来检查代码的覆盖新增数据。

生成的报告文件非常重要,因为它们揭示了被执行的单元测试所涵盖的代码量,并展示了任何未被测试的代码,从而有助于开发者分析和优化代码质量,尤其是针对复杂性代码的修改和维护工作。

3.jacoco报告解读

JaCoCo报告提供了有关测试代码质量的有用信息,可以帮助开发人员更好地分析代码覆盖率并定位问题。

以下是一些JaCoCo报告中常见的指标和他们的解释:

  1. Instructions(指令):代码中在测试中被执行过的指令数量。

  2. Branches(分支):代码中在测试中被执行过的分支数量。

  3. Lines(行数):代码中在测试中被执行过的行数。

  4. Methods(方法):代码中在测试中被执行过的方法数量。

  5. Classes(类):代码中在测试中被执行过的类数量。

  6. Complexity(复杂度):表明代码中某个部分的复杂性。

  7. Missed Instructions(未执行指令):代码中未被测试到的指令数量。

  8. Missed Branches(未被覆盖分支):代码中未被测试到的分支数量。

  9. Missed Lines(未覆盖行数):代码中未被测试到的行数。

  10. Missed Methods(未被测试方法):代码中未被测试到的方法数量。

  11. Missed Classes(未被测试类):代码中未被测试到的类数量。

如果测试覆盖率不足,则应该检查代码中的未被测试部分,并编写新的测试用例以覆盖这些部分。此外,使用JaCoCo报告可以帮助开发者评估代码中的质量,识别难以消除的代码,获取进一步持续改进和优化业绩的一些重要信息。

资源推荐

JUnit4和JUnit5的主要区别

https://blog.csdn.net/lzufeng/article/details/127521842

spring test官方文档

https://docs.spring.io/spring-framework/reference/testing.html

测试金字塔

https://martinfowler.com/articles/practical-test-pyramid.html


评论