美文网首页Java
良好的编程习惯-从单元测试开始

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

作者: 蓝汝丶琪 | 来源:发表于2020-04-24 12:11 被阅读0次

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

    系列目录

    J0FgAO.png

    引言

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

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

    了解单元测试

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

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

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

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

    单元测试的意义

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

    如何实现单元测试

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

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

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

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

    实战

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

    如何保证单元测试细粒度

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

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

    内嵌数据库

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

    内嵌数据库一般使用

    MysqlH2
    MongoDBfongo
    Redisembedded-redis

    例子

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

    添加依赖

            <dependency>
                <groupId>com.h2database</groupId>
                <artifactId>h2</artifactId>
                <scope>runtime</scope>
            </dependency>
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-boot-starter</artifactId>
            </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, 'test1@baomidou.com'),
    (2, 'Jack', 20, 'test2@baomidou.com'),
    (3, 'Tom', 28, 'test3@baomidou.com'),
    (4, 'Sandy', 21, 'test4@baomidou.com'),
    (5, 'Billie', 24, 'test5@baomidou.com');
    

    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.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <version>2.0.111-beta</version>
    </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.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
            </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\":\"xxx@aa.com\"}"))
                    .andDo(MockMvcResultHandlers.print());
        }
    
        private User buildUser() {
            return new User().setId(1).setName("测试人员1").setAge(15).setEmail("xxx@aa.com");
        }
    

    @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":"xxx@aa.com"}
        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("xxx@aa.com");
        }
    }
    

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

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

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

    参考文章:

    相关文章

      网友评论

        本文标题:良好的编程习惯-从单元测试开始

        本文链接:https://www.haomeiwen.com/subject/ouoxwhtx.html