Java单元测试实战

news/2024/7/23 18:27:57 标签: java, 单元测试, mockito, 代码覆盖率, springboot

简介

在开发中,发现很多人并不理解什么是单元测试,容易和集成测试混淆,所以专门写一章来讲解,再拓展一下如果获得代码测试覆盖率。我们通常可以将测试分为两大类,一种是集成测试,一种是单元测试

  • 集成测试:对功能的整体测试,要完整依赖功能的所有代码、组件。比如获得城市详情的功能,不论是从界面点击测试、Postman接口测试、启动服务后代码运行接口,实际上都属于集成测试,运行需要依赖服务启动、数据库操作,完整的运行所有代码
  • 单元测试:对功能单元的测试,这个单元通常是类的方法,一个功能由一个或多个方法调用完成

单元测试

Maven依赖

以下是示例项目springboot-restful的Maven依赖

<project>
    <modelVersion>4.0.0</modelVersion>
    <groupId>springboot</groupId>
    <artifactId>springboot-restful</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>springboot-restful</name>
    
    <!-- Spring Boot 启动父依赖 -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.3.RELEASE</version>
    </parent>

    <properties>
        <mybatis-spring-boot>1.2.0</mybatis-spring-boot>
        <mysql-connector>8.0.19</mysql-connector>
    </properties>

    <dependencies>

        <!-- Spring Boot Web 依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring Boot Mybatis 依赖 -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>${mybatis-spring-boot}</version>
        </dependency>

        <!-- MySQL 连接驱动依赖 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>${mysql-connector}</version>
        </dependency>
    </dependencies>
</project>

以下是单元测试Maven依赖

<project>
        <!-- Spring Boot Test 依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- Junit -->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>

        <!-- Mockito -->
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <version>3.12.4</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

相关依赖的作用

  • spring-boot-starter-test:MockMvc类需要,用于模拟Http调用测试Controller接口

  • junit:@Before、@Test注解需要,@Before用于在每次测试前执行代码,@Test用于运行单元测试用例,及断言验证

  • mockito-core:@InjectMocks注解、@Mock注解、Mockito类使用,用于Mock调用及断言验证

示例业务

CityDao用于访问数据库操作

java">public interface CityDao {

    List<City> findAllCity();

    City findById(@Param("id") Long id);

    Long saveCity(City city);

    Long updateCity(City city);

    Long deleteCity(Long id);
}

CityService用于定义业务逻辑接口

java">public interface CityService {

    /**
     * 获取城市信息列表
     */
    List<City> findAllCity();

    /**
     * 根据城市 ID,查询城市信息
     */
    City findCityById(Long id);

    /**
     * 新增城市信息
     */
    Long saveCity(City city);

    /**
     * 更新城市信息
     */
    Long updateCity(City city);

    /**
     * 根据城市 ID,删除城市信息
     */
    Long deleteCity(Long id);
}

CItyServiceImpl用于实现业务逻辑接口

java">@Service
public class CityServiceImpl implements CityService {

    @Autowired
    private CityDao cityDao;

    @Override
    public List<City> findAllCity(){
        return cityDao.findAllCity();
    }

    @Override
    public City findCityById(Long id) {
        City city = cityDao.findById(id);
        if(city == null) {
            City defaultCity = new City();
            defaultCity.setId(0L);
            defaultCity.setProvinceId(0L);
            defaultCity.setCityName("默认城市");
            defaultCity.setDescription("默认城市");
            return defaultCity;
        }else {
            if(city.getId() < 0) {
                City defaultCity = new City();
                defaultCity.setId(-1L);
                defaultCity.setProvinceId(-1L);
                defaultCity.setCityName("省份");
                defaultCity.setDescription("省份");
                return defaultCity;
            }
        }
        return city;
    }

    @Override
    public Long saveCity(City city) {
        return cityDao.saveCity(city);
    }

    @Override
    public Long updateCity(City city) {
        return cityDao.updateCity(city);
    }

    @Override
    public Long deleteCity(Long id) {
        return cityDao.deleteCity(id);
    }
}

CityRestController用于提供对外接口

java">@RestController
public class CityRestController {

    @Autowired
    private CityService cityService;

    @RequestMapping(value = "/api/city/{id}", method = RequestMethod.GET)
    public City findOneCity(@PathVariable("id") Long id) {
        return cityService.findCityById(id);
    }

    @RequestMapping(value = "/api/city", method = RequestMethod.GET)
    public List<City> findAllCity() {
        return cityService.findAllCity();
    }

    @RequestMapping(value = "/api/city", method = RequestMethod.POST)
    public void createCity(@RequestBody City city) {
        cityService.saveCity(city);
    }

    @RequestMapping(value = "/api/city", method = RequestMethod.PUT)
    public void modifyCity(@RequestBody City city) {
        cityService.updateCity(city);
    }

    @RequestMapping(value = "/api/city/{id}", method = RequestMethod.DELETE)
    public void modifyCity(@PathVariable("id") Long id) {
        cityService.deleteCity(id);
    }
}

Service单元测试

建议将以下类方法静态引入,这样可以简化写法,不需要指明那个类

java">import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.mockito.Mockito.*;

以下为CityService类的单元测试,通过Mockito、junit不需要启动整个应用进行快速测试,将结合代码逐行讲解

public class CityServiceTest {
    
    /**
    * @InjectMocks属于mockito-core包
    * @InjectMocks注解用于标识被测试类,会将@Mock、@Spy注入到被测试类中
    * 如果标识的是一个类,则会创建该类的实例;如果被标识的是一个接口,需要手动new该接口的实现
    **/
    @InjectMocks
    private CityService componentService = new CityServiceImpl();
    
    /**
    * @Mock属于mockito-core包
    * @Mock注解用于标识测试类中被依赖的类,会创建一个该类或接口的代理类,不会调用真实的类代码
    * 我们可以应用这种特性,在测试方法时,让被依赖类根据不同参数返回预期的值
    **/
    @Mock
    private CityDao cityDao;

    /**
    * @Before属于junit包
    * @Before注解用于在@Test标识的测试用例运行前执行代码
    **/
    @Before
    public void setUp() {
        //MockitoAnnotations类属于mockito-core包
        //openMocks(this)作用是根据@InjectMocks、@Mock注解生成测试代理类
        //结合@Before注解就是每次运行测试用例之前都会重置测试代理类
        MockitoAnnotations.openMocks(this);
    }

    /**
     * @Test属于junit包,用于作为测试用例运行
     * 验证根据ID获得城市
     * 正常情况
     *
     * @see org.spring.springboot.service.CityService#findCityById(Long)
     */
    @Test
    public void findCityById_NormalTest() {
        //定义cityDao.findById(10L) Mock返回的数据
        City city = new City();
        city.setId(10L);
        city.setProvinceId(10L);
        city.setCityName("正常城市");
        city.setDescription("正常介绍");

        //设置当执行CityService被测试方法,依赖的cityDao在输入10时
        //返回city(id=10,provinceId=10,cityName=正常城市,description=正常介绍)
        //这也就是常说的Mock
        when(cityDao.findById(10L)).thenReturn(city);

        //真实执行测试用例,如果debug会发现findCityById的代码被真实执行了
        //而cityDao则生成了一个代理类,返回预期的city实体
        City cityResult = componentService.findCityById(10L);

        //Assert类属于junit包,设置断言,所有的预期都应该满足
        //断言 返回实体不为NULL
        Assert.assertNotNull(cityResult);
        //断言 返回实体ID为10
        Assert.assertEquals(cityResult.getId(), (Long) 10L);
        //断言 返回实体名称为正常城市
        Assert.assertEquals(cityResult.getCityName(), "正常城市");
        //全写是Mockito.verify,由于我们静态引入所以可以简写
        //断言 cityDao.findById()方法被调用了1次
        verify(cityDao, times(1)).findById(any());
    }

    /**
     * 这是另一个测试用例,用于验证当无法查到数据,返回默认城市的逻辑是否符合预期
     * 验证根据ID获得城市为null时
     * 返回默认城市
     *
     * @see org.spring.springboot.service.CityService#findCityById(Long)
     */
    @Test
    public void findCityById_DefaultTest() {
        //设置当执行CityService被测试方法,依赖的cityDao在输入100时
        //返回null
        //此时根据代码逻辑,会创建一个cityName=默认城市的类,并返回
        //这也就是常说的Mock
        when(cityDao.findById(100L)).thenReturn(null);

        //真实执行测试用例
        City cityResult = componentService.findCityById(100L);

        //设置断言,所有的预期都应该满足
        //断言 返回实体不为NULL
        Assert.assertNotNull(cityResult);
        //断言 返回实体ID为0
        Assert.assertEquals(cityResult.getId(), (Long) 0L);
        //断言 返回实体名称为默认城市
        Assert.assertEquals(cityResult.getCityName(), "默认城市");
        //断言 cityDao.findById()方法被调用了1次
        verify(cityDao, times(1)).findById(any());
    }

    /**
    * 这是另一个测试用例,用于验证当id小于0时,返回数据为省份的逻辑是否符合预期
    **/
    @Test
    public void findCityById_LessThan0Test() {
        //定义cityDao.findById(-10L) Mock返回的数据
        //这里我们只定义了id=-10,因为我们知道代码逻辑预期只对id等于负数进行判断,没有使用其他值,所以可以简化
        City city = new City();
        city.setId(-10L);
        
        //设置当执行CityService被测试方法,依赖的cityDao在输入100时
        //返回city(id=-1,provinceId=-1,cityName=省份,description=省份)
        //此时根据代码逻辑,会创建一个cityName=省份的类,并返回
        //这也就是常说的Mock
        when(cityDao.findById(-10L)).thenReturn(city);

        //真实执行测试用例
        City cityResult = componentService.findCityById(-10L);

        //设置断言,所有的预期都应该满足
        //断言 返回实体不为NULL
        Assert.assertNotNull(cityResult);
        //断言 返回实体ID为-1
        Assert.assertEquals((long) cityResult.getId(), -1L);
        //断言 返回实体名称为省份
        Assert.assertEquals(cityResult.getCityName(), "省份");
        //断言 cityDao.findById()方法被调用了1次
        verify(cityDao, times(1)).findById(any());
    }
}

Controller单元测试

以下为CityRestController类的单元测试,通过Mockito、junit、MockMvc不需要启动整个应用进行快速测试。可以看到这里多使用了MockMvc,这是用于模拟Http调用Controller层代码,会真实的请求到Controller中的代码,将结合代码逐行讲解。

public class CityRestControllerTest {

    /**
    * @InjectMocks属于mockito-core包
    * @InjectMocks注解用于标识被测试类,会将@Mock、@Spy注入到被测试类中
    * 如果标识的是一个类,则会创建该类的实例;如果被标识的是一个接口,需要手动new该接口的实现
    **/
    @InjectMocks
    private CityRestController cityRestController;

    /**
    * @Mock属于mockito-core包
    * @Mock注解用于标识测试类中被依赖的类,会创建一个该类或接口的代理类,不会调用真实的类代码
    * 我们可以应用这种特性,在测试方法时,让被依赖类根据不同参数返回预期的值
    **/
    @Mock
    private CityService cityService;

    /**
    * MockMvc属于spring-boot-starter-test包
    * MockMvc用于模拟Http请求,它会真实的调用Controller对应方法,并对响应设置断言
    **/
    private MockMvc mvc;
    
    /**
    * @Before属于junit包
    * @Before注解用于在@Test标识的测试用例运行前执行代码
    **/
    @Before
    public void setup() {
        //MockitoAnnotations类属于mockito-core包
        //openMocks(this)作用是根据@InjectMocks、@Mock注解生成测试代理类
        //结合@Before注解就是每次运行测试用例之前都会重置测试代理类
        MockitoAnnotations.openMocks(this);
        //构建cityRestController对象的模拟Http代理类
        mvc = MockMvcBuilders.standaloneSetup(cityRestController).build();
    }

    /**
     * 用于验证通过Controller接口,调用CityService正常的情况
     * 验证根据ID获得城市逻辑正常
     *
     * @see org.spring.springboot.controller.CityRestController#findOneCity(Long)
     */
    @Test
    public void findOneCity_normalTest() throws Exception {
        //定义接口的Mock返回的数据
        City city = new City();
        city.setId(1L);
        city.setProvinceId(1L);
        city.setCityName("泰安");
        city.setDescription("泰山");

        //设置当执行CityRestController被测试方法,依赖的cityService在1时
        //返回city(id=1,provinceId=1,cityName=泰安,description=泰山)
        //这也就是常说的Mock
        when(cityService.findCityById(1L)).thenReturn(city);

        //执行并设置断言
        //GET 请求/api/city/1
        //设置json请求
        MvcResult mvcResult = mvc.perform(get("/api/city/1")
                .contentType(MediaType.APPLICATION_JSON))
                //断言 Http响应码是200
                .andExpect(status().isOk())
                //断言 Http返回body是相同实体
                .andExpect(content().json("{\"id\":1,\"provinceId\":1,\"cityName\":\"泰安\",\"description\":\"泰山\"}"))
                .andReturn();
    }

}

代码覆盖率

我们看很多开源项目时,会看到一个小图标表示代码覆盖率70%,含义是单元测试走过代码行占总有效代行数的百分比,有效代码行是指去除换行等无意义代码。

IDEA代码覆盖率步骤

通过IDEA自带代码覆盖率检测,打开单元测试类,如图所示。
在这里插入图片描述
点击Run…with Coverage,如图所示。
在这里插入图片描述
在IDEA右侧会显示Coverage框,里面会显示整个项目的代码覆盖率,你可以找到你测试的类,查看每个类的测试覆盖率,以我们刚刚的单元测试为例。CityRestController单测代码覆盖率为22%,如图所示。
在这里插入图片描述
CityService单测代码覆盖率为80%,点击可以进一步查看那些地方没有走到,绿色代码单测走过,红色代表未走过,灰色代表无效代码,如图所示。
在这里插入图片描述
可以通过不断补充单元测试,提高代码覆盖率

思考

我们使用发问的形式,在看作者回答前可以自己先思考

通过Mock编写单元测试的优点是什么?

  • 可以不依赖于环境重复运行单元测试
  • 每一次改动发布,都可以运行之前的所有单测,保障改动对之前功能兼容
  • 重构代码的重要保障,《重构改善既有代码的设计》中的核心其实就是面向测试编程,优化代码的实现,但保持结果一致的预期,可以避免优化引入大量BUG

自己造Mock数据又返回自己验证是否是有效测试,如果调用方法不返回这种数据,甚至抛出异常呢?
我们单测的目的是目标方法逻辑代码正确性,通过控制变量的形式,假设被Mock方法始终正常,这种情况下,被测试逻辑代码符合预期,则说明逻辑代码是正确的,而Mock方法的正确性则由另一个单测保障。

这个例子会调用数据库,那数据库操作方法的正确性该如何保障呢?
如果使用Mybatis-plus这种框架,由框架的单测保障。使用Mybatis映射文件,可以通过启动一个H2内存数据库,执行SQL验证正确性。如果是NoSQL,可以考虑通过启动本地容器的方式验证,不过一般情况下优先保障逻辑代码的正确性,数据库出现异常是比较少的情况。

那假设是RPC调用呢,又该如何单元测试
对于消费者,可以对RPC接口Mock来单元测试。RPC接口内的逻辑正确,由服务提供者的单元测试保障。

示例项目代码

以上讲解知识点的示例项目源码也已上传,可找到项目单元测试直接运行。


http://www.niftyadmin.cn/n/5414635.html

相关文章

sql server使用逗号,分隔保存多个id的一些查询保存

方案一&#xff0c;前后不附加逗号&#xff1a; 方案二&#xff0c;前后附加逗号&#xff1a; 其他保存方案&#xff1a; &#xff08;这里是我做一个程序的商家日期规则搞得&#xff0c;后面再补具体操作&#xff09;&#xff1a; 1,2,3 | 1,2,3 | 1,2,3; 1,2,3 &#xff1…

消息队列以及Kafka的使用

什么是消息队列 消息队列&#xff1a;一般我们会简称它为MQ(Message Queue)。其主要目的是通讯。 ps&#xff1a;消息队列是以日志的形式将数据顺序存储到磁盘当中。通常我们说从内存中IO读写数据的速度要快于从硬盘中IO读写的速度是对于随机的写入和读取。但是对于这种顺序存…

安装邮件服务器postfix、mail客户端发送邮件

安装邮件服务器postfix、mail客户端发送邮件 1 安装postfix sudo apt-get update sudo apt-get install postfix -y安装过程中会让你选择一种Postfix配置类型&#xff0c;直接选择默认的第二种配置Internet Site就可以了。 选择ok之后会让你填入域名&#xff0c;一般会自动填…

CorelDRAW2024最新版本号25.0.0.230安装包下载

CorelDRAW2024是一款专业的平面设计软件&#xff0c;以矢量图形编辑与排版为核心功能。它凭借对高级操作系统的支持、多监视器查看和4K显示屏的兼容性&#xff0c;使得无论是初始用户还是图形专家&#xff0c;都能自信快速地交付专业级结果。 CorelDRAW 2024的主要特点包括其直…

云服务器实例重启后,各个微服务的接口(涉及mysql操作的)都用不了了

问题描述&#xff1a; 云服务器被黑客植入挖矿。重启云服务器实例后得到解决&#xff0c;接着把docker&#xff08;zookeeper、redis啥的&#xff09;还有后端jar包啥的都重启了&#xff0c;然后发现后端接口访问不了&#xff0c;只有不涉及数据库操作的接口正常访问&#xff…

Pytorch学习 day07(神经网络基本骨架的搭建)

神经网络基本骨架的搭建 Module&#xff1a;给所有的神经网络提供一个基本的骨架&#xff0c;所有神经网络都需要继承Module&#xff0c;并定义_ _ init _ _方法、 forward() 方法在_ _ init _ _方法中定义&#xff0c;卷积层的具体变换&#xff0c;在forward() 方法中定义&am…

linux网络常用指令和工具

linux网络常用指令和工具 netstat命令&#xff0c;可以查看当前的网络连接状态 常用指令&#xff0c;netstat -nal | grep “ip:port” 可以查看某个ip和端口的状态iftop 命令&#xff0c;可以查看某个ip和端口的带宽 iftop -i eth19 -f ‘dst host 198.18.35.4 and dst port …

掌握Homebrew: macOS 上的软件包管理器

在 macOS 系统上&#xff0c;有许多方式来安装软件&#xff0c;但其中一种最受欢迎且最方便的方法之一是使用 Homebrew。Homebrew 是一个强大的软件包管理器&#xff0c;可以让您在命令行界面轻松地安装、更新和管理各种软件包。本文将介绍一些常用的 Homebrew 命令&#xff0c…