掘金 后端 ( ) • 2024-04-29 15:15

大话单测

代码写完了,是不是就万事大吉,只差上线了?我不知道大家有没有写单测的习惯,有的公司要求写,有的公司根本就不要求写。

写单元测试确实是一个体力活,一点都不比写代码轻松,所以很多程序员比较排斥单元测试,更不用说集成测试了。

如果不写单元测试,那代码的质量如何保证呢,咱不是有QA吗,靠QA呗,出了事儿算QA的,但研发一定是幸免于难的,必定这个事儿是你干的,至少有你一半的责任。

单元测试是质量的第一道把关,至关重要.

那什么样的单元测试是好的呢,干一个事儿肯定得有衡量的方法,我给你一一介绍。

环境搭建

单元测试还需要搭建环境吗?我们的脚手架不都已经搭建好了吗?如何搭建Spring Boot脚手架 - 掘金 (juejin.cn)但我想给你介绍一下另外一种好玩的方式。

在目前分布式的体系架构下,我们是不是要依赖很多中间件,比如Redis、DB、Kafka等,如果做单元测试的话,依赖的中间件怎么办呢?

使用dev环境的中间件不就可以了吗?但会遇到一个问题是,dev环境大家共用,万一不小心破坏了你的数据,不就game over了吗?总不能不让别人用吧。

除了环境的问题,还需要考虑每个case之间的数据是隔离的,这样才能保证case的准确性。

什么叫准确性呢,这个case执行1次和执行100次的结果是一样的,不会因着其他因素而改变,这也是科学中的可重复性。

在介绍脚手架的文章中,我们提到了内存版的Redis、kafka、Db, 接下来我们来看看怎么使用的,直接上代码

导入pom

<dependency>
  <groupId>it.ozimov</groupId>
  <artifactId>embedded-redis</artifactId>
  <version>0.7.2</version>
  <scope>test</scope>
</dependency>

<dependency>
  <groupId>ch.vorburger.mariaDB4j</groupId>
  <artifactId>mariaDB4j</artifactId>
  <version>2.4.0</version>
  <scope>test</scope>
</dependency>

<dependency>
  <groupId>org.mariadb.jdbc</groupId>
  <artifactId>mariadb-java-client</artifactId>
  <version>2.5.2</version>
  <scope>test</scope>
</dependency>


<dependency>
  <artifactId>junit-platform-launcher</artifactId>
  <groupId>org.junit.platform</groupId>
  <scope>test</scope>
</dependency>

<dependency>
  <artifactId>junit-vintage-engine</artifactId>
  <groupId>org.junit.vintage</groupId>
  <version>5.9.0</verison>
</dependency>


ApplicationTests 测试启动类:该类主要职责是启动redis Server,DbServer.

@SpringBootTest(classes = {LifeCycleManagement.class, KafkaTemplateConfig.class})
@ActiveProfiles({"unit"})
@Slf4j
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
public abstract class ApplicationTests {

    public static RedisServer redisServer;

    @BeforeAll
    public static void beforeAll() throws ManagedProcessException {
        log.info("================mariadb  start================");
        LifeCycleManagement.initDB();
        log.info("================redis server start================");
        redisServer = RedisServer.builder().setting("maxmemory 200m").port(8697).build();
        redisServer.start();

    }

    @AfterAll
    public static void afterAll() throws ManagedProcessException {
        log.info("================mariadb  close================");
        LifeCycleManagement.closeDB();
        log.info("================redis server stop================");
        redisServer.stop();
    }

}

LifeCycleManagement 该类职责是初始化DB库,启动kafka Server

@TestConfiguration
@Slf4j
public class LifeCycleManagement {

    private static final int NUMBER_OF_BROKERS = 1;
    public static DB db;
    @Value("${spring.cloud.sentinel.enabled}")
    private static boolean sentinel;

    public static void initDB() throws ManagedProcessException {
        DBConfigurationBuilder configBuilder = DBConfigurationBuilder.newBuilder();
        configBuilder.setPort(3308); // OR, default: setPort(0); => autom. detect free port
        configBuilder.setDataDir("./data"); // just an example
        configBuilder.addArg(" --user=root");
        db = DB.newEmbeddedDB(configBuilder.build());
        db.start();
        db.createDB("test");
        db.source("script/source_table_str.sql", "test");

    }

    public static void closeDB() throws ManagedProcessException {
        if (db != null) {
            db.stop();
        }
    }

    public static int[] setupPorts() {
        return new int[NUMBER_OF_BROKERS];
    }

    @Bean
    public EmbeddedKafkaBroker initKafka() {
        log.info("================kafka server  start================");
        boolean CONTROLLER_SHUTDOWN = true;
        int NUMBER_OF_PARTITIONS = 1;
        EmbeddedKafkaBroker embeddedKafkaBroker =
            new EmbeddedKafkaBroker(NUMBER_OF_BROKERS, CONTROLLER_SHUTDOWN, NUMBER_OF_PARTITIONS,
                new String[]{})
                .kafkaPorts(setupPorts()).zkPort(0)
                .zkConnectionTimeout(EmbeddedKafkaBroker.DEFAULT_ZK_CONNECTION_TIMEOUT)
                .zkSessionTimeout(EmbeddedKafkaBroker.DEFAULT_ZK_SESSION_TIMEOUT);
        Properties properties = new Properties();
        properties.put("listeners", "PLAINTEXT://127.0.0.1:9091");
        properties.put("port", "9091");
        properties.put("auto.create.topics.enable", true);
        embeddedKafkaBroker.brokerProperties((Map<String, String>) (Map<?, ?>) properties);
        return embeddedKafkaBroker;
    }

}

BaseTest 该类主要初始化mockMvc

public abstract class BaseTest extends ApplicationTests {

    public static MockMvc mockMvc;
    @Autowired
    WebApplicationContext webApplicationContext;

    @AfterEach
    void afterEach() {

    }

    @BeforeEach
    public void beforeEach() throws ManagedProcessException {
        MiddleWareLifeCycleManagement.db.source("script/clean.sql", "test");
        // 在这初始mock所有的过滤器都不会加载
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
            .addFilter(webApplicationContext.getBean(ContentCachingTestFilter.class)).build();
    }

    protected ResultActions doAction(String content, String uri) throws Exception {
        return mockMvc
            .perform(MockMvcRequestBuilders.post(uri).contentType(MediaType.APPLICATION_JSON)
                .header("requestTime", System.nanoTime()).content(content));
    }


    protected ResultActions doAction(MultiValueMap<String, String> map, String uri)
        throws Exception {
        return mockMvc.perform(MockMvcRequestBuilders.post(uri).params(map).header("bizId",
            UUID.randomUUID().toString().replace("-", "")));

    }
}

在你的工程中导入以下几个类,基本上就可以work了

单元测试如何写

直接看案例

    @Test
    void should_return_expired_when_status_isCorrect() throws Exception {
        ReceiveAwardRequest request = givenStatusExpired(); //given
        assertResponseCodeEquals(request,ResultCode.EXPIRED); //when and then
    }
	

单元测试基本上遵循这样的结构体

given : 封装请求参数

when:执行请求

then:assert断言验证

方法名如何进行命名呢?这两种方式我在项目中都用了,但我更喜欢第二种方式。

  1. givenXXX_whenXXX_thenXXX, 从方法命名上一目了然
  2. should_XXX_when_XXX

说完了结构,接下来我给大家介绍一下不同类型的单元测试该如何写

1. 断言http接口返回值

spring的MockMvc可以帮我发起http请求 get请求

 mockMvc
    .perform(MockMvcRequestBuilders.get(uri).queryParams(map));

post请求


mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/receive")
                .contentType(MediaType.APPLICATION_JSON)
                .header("requestTime", System.nanoTime())
                .content(content))   .andExpect(MockMvcResultMatchers.jsonPath("$.code").value(resultCode.getCode()));

如果你想mock第三方服务的返回值,可以这么做

Mockito.doReturn(false).when(xxx).xxxFunction(Mockito.any());

有时候我们需要mock spring中的bean,该bean仍然需要spring去管理,而不是mock,可以这样做

@SpyBean 
protected xxxService xxservice

或者

Mockito.spy(bean)

**2. 断言数据库中的某个值 **

在有些情况下,我们验证该case的是否成功的条件是验证数据库中的某个值是不是改变了,比如验证一下奖券状态修改从a->b,判断该case是否成功就需要看数据库的值是否是b

  Awaitility.await().pollDelay(100, TimeUnit.MILLISECONDS)
        .pollInterval(Duration.ofMillis(500))
        .until(() -> eventService.lambdaQuery()
            .eq(MsgEvent::getRequestId, 112212)
            .eq(MsgEvent::getState, MsgEventState.FAILED.getCode()).exists());

3. 断言某个方法是否被执行到

Mockito.verify(xxxx, Mockito.never()).xxxFunction(Mockito.any());

4. 直接mock某个对象

Mockito.mock(Clazz class)

除此之外还可以断言某个日志关键字是否被打印等等

如何执行单元测试

执行一个单元测试,我们都知道,直接在单个case上右键点击run就行,如果要执行整个项目的单元测试该如何做呢?

image.png 配置完成之后,直接点击run。

执行单元测试必须要依赖于IDE吗,当然不是了,通过命令的方式也可以。

单测覆盖率

单元测试写完了,如何衡量单测写的好不好呢?单测覆盖率,我们一般使用分支覆盖率来衡量

什么是分支覆盖率呢?你理解成对if else的覆盖情况,如果只覆盖到了if,那覆盖率就是50%

单测覆盖率如何执行呢?

  1. 在pom.xml中添加jacoco插件
<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.6</version>
  <executions>
    <execution>
      <goals>
        <goal>prepare-agent</goal>
      </goals>
    </execution>
    <execution>
      <id>report</id>
      <phase>test</phase>
      <goals>
        <goal>report</goal>
      </goals>
    </execution>
  </executions>
</plugin>

  1. 执行命令
mvn clean org.jacoco:jacoco-maven-plugin:prepare-agent test surefire:test surefire-report:report -Dsurefire.reportsDirectory=./target/site --settings=D:/IdeaProjects/settings.xml
  1. 结果

image.png

每个包的分支覆盖率,指令覆盖率都赫然显示出来,点击包就能看到某个类的覆盖率以及代码执行情况

总结

单元测试需要花很长时间写,导致很多程序员不想写也不愿意写,但单元测试的收益是非常大的,在我们下次修改代码的时候,通过执行单元测试对软件进行第一道把关。除此之外,我们做重构也会比较放心,真的是 write once ,run any time。