良好的编程习惯-从单元测试开始

原文博客:Doi技术团队
链接地址:https://blog.doiduoyi.com
初心:记录优秀的Doi技术团队学习经历

系列目录

良好的编程习惯-从单元测试开始_第1张图片

引言

这篇文章文中的实用例子只是一个抛砖引玉的作用。

适合新手学习,或者时间充裕可以深入研究以这篇为目录进行查漏补缺。

了解单元测试

单元测试属于小型测试,针对单个函数的测试,关注其内部逻辑输出的结果是否正确。如果将一个单元测试看成是一个单位,只需保证每一个单元测试都通过,则可以大大提高项目质量。单元测试可以保证能够代码覆盖率达到100%的测试。

但是我们往往在开发中都不愿意好好写单元测试,理由有很多,绝大多数如下:

  1. 需求赶,没有足够的时间写单元测试
  2. 功能需求太简单,没有必要写单元测试
  3. 当需求变动的时候,又要修改单元测试,增加了开发时间
  4. 应该交给测试人员来完成

以上的问题,其实对于每一个项目普遍存在。在以前,我也是这样的心态,不愿意写单元测试。但当我尝试了几次单例的带来的甜头后,越发喜欢和习惯写单元测试。我觉得当你认识到单元测试的意义,以及熟悉使用单元测试,你自然会打消以上的疑虑并且爱上它。

单元测试的意义

  • 它可以保证你写的代码是你想要的结果。这个点很重要,因为在编程中,经常会敲错代码导致结果并不是自己脑子里想的。如果不经过单元测试测试下运行结果,那么代码质量是肯定保证不了的。
  • 单元测试是最少单位,一个高可用的系统需要靠一个一个最小的稳定的单位组成。所以保证一个最小单位的准确率是必须的。
  • 单元测试应该是快速的,因此它不应该使用任何Web服务器。
  • 每个单元测试应该独立于其他测试。
  • 当出现问题的时候,单元测试可以很快帮助你排查问题。因为单元测试保证你写的代码是你想要的结果,当出现异常效果,只需要从对应的单元测试是排查,就可以很快定位问题。

如何实现单元测试

在讨论如果实现单元测试的之前,我们要先想想,什么是好的单元测试呢?

  • 完整性:覆盖率高,意思就是对各种情况都要考虑到
  • 健壮性:具有健壮性的单元测试,完全不需要被修改或者只有极少的修改。因为单元测试只是关注输出结果是否符合期望,如果只是修改了实现逻辑,那么单元测试是不需要改动的。
  • 粒度细:其实这里跟代码的设计和实现有关。考虑到单测实现的简洁,把各个功能分成每个函数,保证粒度足够细。(评判代码或者设计好不好的⼀个准则是看它容不容易测试

那么接下来我们要讨论下需要测试什么?

上文已经提到,单元测试测试是最小粒度的代码,通常是一个方法或函数。通常是通过⼀系列不同的⾏为。⾏为就是对不同的输⼊场景有不同的输出,每⼀个⾏为都需要独⽴的单测。

实战

接下来,我们来讨论一下如何写单元测试

如何保证单元测试细粒度

在实战前,我们要考虑如何保证单元测试的细粒度呢?
在绝大多数业务中,单个方法/函数也是有调用其他方法/函数的,那么当我们测试的方法调用链很深的时候,这相当于测试用例的粒度变大了,返回的结果情况也会因为调用链的深度而变复杂。
又或者测试的方法/函数有调用远程数据源或者远程接口,这种情况往往测试依赖性很高。如果数据库没有准备好,或者远程接口不允许测试,那么单元测试就没办法进行下去。这样是打击了写单元测试的热情。

以上情况,其实我们通常会用内嵌数据库或者Mock来解决。下面就来介绍一下他们的用途

内嵌数据库

在开发应用的过程中使用内嵌的内存数据库是非常方便的,很明显,内存数据库不提供数据的持久化存储;当应用启动时你需要填充你的数据库,当应用结束时数据将会丢弃

内嵌数据库一般使用

Mysql:H2
MongoDB:fongo
Redis:embedded-redis

例子

此处结合Mybatis-plus的初始化工程看看如何使用H2内嵌数据库

添加依赖

		<dependency>
			<groupId>com.h2databasegroupId>
			<artifactId>h2artifactId>
			<scope>runtimescope>
		dependency>
		<dependency>
			<groupId>com.baomidougroupId>
			<artifactId>mybatis-plus-boot-starterartifactId>
		dependency>

配置

# DataSource Config
spring:
  datasource:
    driver-class-name: org.h2.Driver
    schema: classpath:db/schema-h2.sql
    data: classpath:db/data-h2.sql
    url: jdbc:h2:mem:test
    username: root
    password: test
    initialization-mode: always

# Logger Config
logging:
  level:
    com.baomidou.mybatisplus.samples.quickstart: debug

schema-h2.sql

DROP TABLE IF EXISTS user;

CREATE TABLE user
(
	id BIGINT(20) NOT NULL COMMENT '主键ID',
	name VARCHAR(30) NULL DEFAULT NULL COMMENT '姓名',
	age INT(11) NULL DEFAULT NULL COMMENT '年龄',
	email VARCHAR(50) NULL DEFAULT NULL COMMENT '邮箱',
	PRIMARY KEY (id)
);

data-h2.sql

DELETE FROM user;

INSERT INTO user (id, name, age, email) VALUES
(1, 'Jone', 18, '[email protected]'),
(2, 'Jack', 20, '[email protected]'),
(3, 'Tom', 28, '[email protected]'),
(4, 'Sandy', 21, '[email protected]'),
(5, 'Billie', 24, '[email protected]');

Spring boot启动类添加@MapperScan注解

@SpringBootApplication
@MapperScan("com.example.h2.mapper")
public class H2Application {

	public static void main(String[] args) {
		SpringApplication.run(H2Application.class, args);
	}

}

编码

Entity 实体类

@Data
public class User {
    private Long id;
    private String name;
    private Integer age;
    private String email;
}

Mapper类

public interface UserMapper extends BaseMapper<User> {

}

启动

@SpringBootTest
public class SampleTest {

    @Autowired
    private UserMapper userMapper;

    @Test
    public void testSelect() {
        System.out.println(("----- selectAll method test ------"));
        List<User> userList = userMapper.selectList(null);
        Assert.assertEquals(5, userList.size());
        userList.forEach(System.out::println);
    }

}

控制台输出

User(id=1, name=Jone, age=18, email=test1@baomidou.com)
User(id=2, name=Jack, age=20, email=test2@baomidou.com)
User(id=3, name=Tom, age=28, email=test3@baomidou.com)
User(id=4, name=Sandy, age=21, email=test4@baomidou.com)
User(id=5, name=Billie, age=24, email=test5@baomidou.com)

使用Mock测试

上文用到了内嵌数据库可以完成单例测试,但是使用还是很繁琐,需要初始化数据库,另外如果是测试的函数中需要调用其他服务的接口,这时候就不是内嵌数据库可以解决的。因此我们可以使用另外一种方法,Mock测试。Mock是对于一些不容易构造/获取的对象,创建一个Mock对象来模拟对象的行为。Mock对象是虚构的,是可以构造任意你想要的数据。

在本章中,主要使用Mockito,一个强大的用于 Java 开发的模拟测试框架,而且使用简单。官方中文文档

Maven依赖

<dependency>
    <groupId>org.mockitogroupId>
    <artifactId>mockito-coreartifactId>
    <version>2.0.111-betaversion>
dependency>

编码

  1. 利用Mock对象记录行为

一旦mock对象被创建了,mock对象会记住所有的交互。你可以验证是否存在该操作

public void testMockito() {
        //构建Mock对象
        List mock = Mockito.mock(List.class);
        //使用mock对象
        mock.add("one");

        //验证 mock对象是否进行过这些操作
        Mockito.verify(mock).add("one");
        //会抛出错误,因为没有进行过这个操作
       // Mockito.verify(mock).remove("one");

    }
  1. Mock最核心的功能,做测试桩(Stub)

测试桩Stub是什么呢?
写码的时候你会遇到一些外部依赖,比如在本机上写代码,可能会调用谷歌的API,来完成远程调用。而我在做测试的时候并不想真的发出这个请求,(贵,得不到想要的结果),因此我选择通过某种方式(Mockito)来进行模拟。Stub指的就是这种模拟,把服务端的依赖用本机来进行模拟
作者:CC stone
链接:https://www.zhihu.com/question/21017494/answer/604154516

    @Test
    public void testMockitoStub() {
        // 你可以mock具体的类型,不仅只是接口
        LinkedList mockedList = Mockito.mock(LinkedList.class);

        //测试桩,这个算是埋点。当我们调用mockedList.get(0)的时候,会返回first
        Mockito.when(mockedList.get(0)).thenReturn("first");

        // 输出“first”
        System.out.println(mockedList.get(0));
    }

测试例子

不定期更新测试例子
主要写一下平时工作中会用到的测试方式,随着水平提高,应该会对单元测试有更深的理解

注意: 测试用例统一使用的JUnit5

Junit5 的使用

JUnit 5 是一个项目名称(和版本),其 3 个主要模块关注不同的方面:JUnit JupiterJUnit PlatformJUnit Vintage。在单元测试中,我们通常只是使用到JUnit Jupiter

Junit Jupiter 模块

Junit Jupiter模块用于编写单元测试,包含两个部分 JUnit Jupiter APIJUnit Jupiter Test Engine

  • JUnit Jupiter API:使用 JUnit Jupiter API 创建单元测试来测试您的应用程序代码。使用该 API 的基本特性 — 注解、断言等
  • JUnit Jupiter Test Engine:发现和执行 JUnit Jupiter 单元测试,可将 JUnit Jupiter Test Engine 看作单元测试与用于启动它们的工具(比如 IDE)之间的桥梁
JUnit Platform模块

这个模块主要用于发现测试API和执行测试API。JUnit Platform 负责使用 IDE 和构建工具(比如 Gradle 和 Maven)发起测试发现流程。以前我们常用``@RunWith(SpringRunner.class)```在JUnit4,在JUnit5我们使用@RunWith(JUnitPlatform.class)`(对于一些支持JUnit5得IDE,不需要此注解了)

JUnit Vintage 模块

该模块主要是为了兼容JUnit4。这个模块包含junit-vintage-enginejunit-jupiter-migration-support 组件

JUnit Platform 而言,JUnit Vintage 只是另一个测试框架,包含自己的 TestEngineJUnit API

Junit5 注解

Junit5的注解与Junit4还是有不少区别的

注解 描述
@Test 表示测试方法,该注解没有任何属性,因为JUnit Jupiter测试扩展有专门的注解操作
@BeforeEach 表示被注解的方法应在当前类的每个@Test,类似于JUnit 4的@Before
@AfterEach 表示被注解的方法应该在当前类的所有@Test,类似于JUnit 4的@After
@BeforeAll 表示被注解的方法应该在当前类的所有@Test,类似于JUnit 4的@BeforeClass
@AfterAll 表示被注解的方法应该在当前类的所有@Test,类似于JUnit 4的@AfterClass
@RunWith 对于支持Junit5的IDE,不需要此注解。对于未支持的需要@RunWith(JUnitPlatform.class)使用
@DisplayName 声明测试类或者测试方法的自定义显示名称
@Disabled 声明JUnit不允许此@Test方法
Junit5 断言

如果断言失败,用例即结束。

JUnit Jupiter提供了许多JUnit4已有的断言方法,并增加了一些适合与Java 8 lambda一起使用的断言方法。

org.junit.jupiter.api.Assertions类提供

更多例子,可以查看官方文档

    @Test
    void standardAssertions() {
        Assertions.assertEquals(2, 2);
        Assertions.assertEquals(4, 4, "The optional assertion message is now the last parameter.");
        Assertions.assertTrue(2 == 2, () -> "Assertion messages can be lazily evaluated -- "
                + "to avoid constructing complex messages unnecessarily.");
    }

    @Test
    void groupedAssertions() {
        // In a grouped assertion all assertions are executed, and any
        // failures will be reported together.
        Assertions.assertAll("person",
            () -> assertEquals("John", person.getFirstName()),
            () -> assertEquals("Doe", person.getLastName())
        );
    }
Junit5 假设

如果假设失败,相关测试用例被忽略,但与假设同级别的收尾工作还要继续执行。

JUnit Jupiter附带了JUnit4提供的一些assumption方法的子集,并增加了一些适合与Java 8 lambda一起使用的方法。

org.junit.jupiter.Asumptions类提供

更多例子,可以查看官方文档

    @Test
    void testOnlyOnCiServer() {
        Asumptions.assumeTrue("CI".equals(System.getenv("ENV")));
        // remainder of test
    }

    @Test
    void testOnlyOnDeveloperWorkstation() {
        Asumptions.assumeTrue("DEV".equals(System.getenv("ENV")),
            () -> "Aborting test: not on developer workstation");
        // remainder of test
    }
Junit5 参数化测试

有时候我们需要传值测试,在Junit5中,支持我们传入参数进行测试。

数据库的初始化请看上文的内嵌数据库

@JdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
public class ParameterTest {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @ParameterizedTest
    @ValueSource(longs = {1L,2L,3L,4L,5L})
    public void parameterTest(long id) {
        String sql = "select * from user where id=?";
        RowMapper<User> rowMapper=new BeanPropertyRowMapper<User>(User.class);
        List<User> users = jdbcTemplate.query(sql, rowMapper,id);
        System.out.println(users);
    }
}

参数化测试需要用到@ParameterizedTest@ValueSource

Spring boot 单元测试

spring-boot-starter-test 测试包

spirng boot 对于测试,提供了一个spring-boot-starter-test

        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-testartifactId>
        dependency>

在这个包中,包含了以下的库

  • Junit5:单元测试Java,目标是为JVM上的开发人员端测试创建最新的基础。官方文档
  • Spring测试和Spring boot 测试:Spring Boot应用程序的实用程序和集成测试支持
  • AssertJ:一个流程的断言库
  • Hamcrest:匹配器对象库
  • Mockito:一个Java模拟框架
  • JSONassert:JSON的断言库
  • JsonPath:JSON的XPath
简单的Spring boot 单元测试
@SpringBootTest
public class Test {
    
    @org.junit.jupiter.api.Test
    public void test() {
        System.out.println("Hello World");
    }
}
  1. 使用的注解是的包是org.junit.jupiter.api 这是JUnit5,而不是org.junit
  2. 由于使用JUnit5,所以@RunWith(SpringRunner.class)已经不再需要了

@SpringBootTest 工作原理与项目的启动类中@SpringBootApplication差不多。@SpringBootTest提供了webEnvironment属性,默认是MOCK,不会启动嵌入式服务器,所以不会起端口。

具体参数如下:

  • MOCK(默认):不会启动嵌入式服务器。但是提供模拟网络环境,可以使用@AutoConfigureMockMvc或者@AutoConfigureWebTestClient测试Web应用程序接口。

  • RANDOM_PROT:启动嵌入式服务器,并且随机监听端口

  • DEFINED_PORT:启动嵌入式服务器,定义配置文件的端口或者默认端口8080

  • NONE:不提供任何模拟网络环境

spring boot 分片测试

当项目很庞大,每次启动耗时都很长的时候,就需要考虑只加载需要测试的配置和资源。**spring boot **提供了很多自动配置的注解,这些注解只会加载对应的资源信息,这会大大提高你的单元测试效率。

**spring boot **提供的注解如下,更多内容请查阅官方文档

  • @DataJdbcTest :加载JdbcTemplateAutoConfigurationDataSourceAutoConfiguration等配置。
  • @DataJpaTest: 加载HibernateJpaAutoConfiguration,DataSourceAutoConfiguration
  • @DataLdapTest:加载LdapAutoConfiguration
  • @DataMongoTest:加载Mongodb配置
  • @DataNeo4jTest:加载Neo4j
  • @DataRedisTest:加载redis
  • @JdbcTest:加载DataSource
  • @JooqTest:加载Jooq
  • @JsonTest: 加载Json配置,GSON,Jackson都支持
  • @RestClientTest:加载RestTemplate配置
  • @WebFluxTest:加载WebFlut配置
  • @WebMvcTest:加载SpringMvc配置
数据库测试,@JdbcTest的使用
@JdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
public class H2Test {
    @Autowired
    private DataSource dataSource;

    @Test
    public void h2Test() {
        System.out.println(dataSource);
    }
}

@JdbcTest注解会自动加载以下类相关的配置

org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration 
org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration 
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration 
org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration 
org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration 
org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration 
org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration 
org.springframework.boot.test.autoconfigure.jdbc.TestDatabaseAutoConfiguration

@AutoConfigureTestDatabase有3种模式

  • ANY: 测试数据源代替所有的自动配置数据源和手动定义的数据源
  • AUTO_CONFIGURED:测试数据源仅代替所有自动配置的数据源
  • NONE:不代替系统默认数据源,当你不想启动内嵌数据库的时候,可以选择这个模式
Spring MVC测试 @WebMvcTest
  1. 场景1

Controller类

@RestController
@RequestMapping("/user")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }


    @GetMapping("/info")
    public User userInfo(@RequestParam long id) {
        return userService.findById(id);
    }
}

测试类

@WebMvcTest(UserController.class)
public class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;
    @MockBean
    private UserService userService;


    @Test
    public void testMvc() throws Exception {
        Mockito.when(userService.findById(Mockito.eq(1L))).thenReturn(buildUser());
        /**
         *  mockMvc.perform 开始执行一个请求
         *  MockMvcRequestBuilders.get(xxx) 构建一个get方法的请求
         *  accept(MediaType.APPLICATION_JSON_UTF8_VALUE) header头信息,Accept:"application/json;charset=UTF-8"
         *  param 请求参数
         *  andExpect  添加执行完成后的断言
         *  andDo 返回结果处理器,可以添加一个对结果处理的Handler,例如MockMvcResultHandlers.print()
         */
        mockMvc.perform(MockMvcRequestBuilders.get("/user/info")
                .accept(MediaType.APPLICATION_JSON_UTF8_VALUE)
                .param("id","1"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().json("{\"id\":1,\"name\":\"测试人员1\",\"age\":15,\"email\":\"[email protected]\"}"))
                .andDo(MockMvcResultHandlers.print());
    }

    private User buildUser() {
        return new User().setId(1).setName("测试人员1").setAge(15).setEmail("[email protected]");
    }

@WebMvcTest(UserController.class)Spring boot提供的单个片测试注解,value=UserController.class 相当于StandaloneMockMvcBuilder测试方式(相对应的还有DefaultMockMvcBuilder集成Web环境测试),独立构建UserController的Web环境。

@MockBean用在构建UserService对象。因为UserService需要查询数据库,有点麻烦。所以我就想用Mock方式了,主要想测试Controller层,就没必要关心其他层了。

  1. 场景2

当你想访问真正的Service层逻辑的时候,而不是用Mock构建Service层对象时。我们可以用MockMvcBuilders来构建我们想要的MockMvc

@SpringBootTest
@ActiveProfiles("test")
class AccountControllerTest {

    private MockMvc mockMvc;
    @Autowired
    protected WebApplicationContext wac;


    @BeforeEach
    @DisplayName("初始化MockMvc")
    public void init() {
        UserController bean = wac.getBean(UserController.class);
        mockMvc = MockMvcBuilders.standaloneSetup(bean).build();
    }
  
    
    @Test
    public void testMvc() throws Exception {
        //测试代码同上
    }
    
}

WebApplicationContext是实现ApplicationContext接口的子类。 它允许从相对于Web根目录的路径中加载配置文件完成初始化工作。从WebApplicationContext中可以获取ServletContext引用,整个Web应用上下文对象将作为属性放置在ServletContext中,以便Web应用环境可以访问Spring上下文。

MockMvcBuilders.standaloneSetup(bean) 构建单个ControllerMockMvc,可以提高项目启动速度。

控制台输出结果

request 信息

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /user/info
       Parameters = {id=[1]}
          Headers = [Accept:"application/json;charset=UTF-8"]
             Body = <no character encoding set>
    Session Attrs = {}

response信息

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json;charset=UTF-8"]
     Content type = application/json;charset=UTF-8
             Body = {"id":1,"name":"测试人员1","age":15,"email":"[email protected]"}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

Json 测试 @JsonTest
@JsonTest
public class MyJsonTest {

    
    /*
    * 相对应的,你也可以使用@JsonbTester,@GsonTester,@BasicJsonTester
    */
    @Autowired
    private JacksonTester<User> jacksonTester;

    @BeforeEach
    public void init() {
        ObjectMapper objectMapper = new ObjectMapper();
        JacksonTester.initFields(jacksonTester,objectMapper);
    }
    
    @Test
    public void t() throws IOException {
        JsonContent<User> jsonContent = jacksonTester.write(buildUser());
        //断言Json串是一样
        Assertions.assertThat(jsonContent).isEqualToJson("{\"id\":1,\"name\":\"测试人员1\",\"age\":15}");
        //断言name属性的值是测试人员1
        Assertions.assertThat(jsonContent).hasJsonPathStringValue("name","测试人员1");
        //断言email属性是空的
        Assertions.assertThat(jsonContent).hasEmptyJsonPathValue("email");
    }

    private User buildUser() {
        return new User().setId(1).setName("测试人员1").setAge(15).setEmail("[email protected]");
    }
}

@JsonTest 注解会自动配置Jackson的ObjectMapper,所有@JsonComponentbean和Jackson Modules

如果使用Jackson,你想自定义ObjectMapper,可以在@BeforeEach的方法中使用JacksonTester.initFields方法。

同样地Json测试也有提供断言方法。可以用org.assertj.core.api.Assertions类来断言JsonContent对象

参考文章:

  • Junit 5文章
  • Junit 5官方文档
  • Spring boot 官方文档

你可能感兴趣的:(Java,web)