大话单测
代码写完了,是不是就万事大吉,只差上线了?我不知道大家有没有写单测的习惯,有的公司要求写,有的公司根本就不要求写。
写单元测试确实是一个体力活,一点都不比写代码轻松,所以很多程序员比较排斥单元测试,更不用说集成测试了。
如果不写单元测试,那代码的质量如何保证呢,咱不是有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断言验证
方法名如何进行命名呢?这两种方式我在项目中都用了,但我更喜欢第二种方式。
- givenXXX_whenXXX_thenXXX, 从方法命名上一目了然
- 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就行,如果要执行整个项目的单元测试该如何做呢?
配置完成之后,直接点击run。
执行单元测试必须要依赖于IDE吗,当然不是了,通过命令的方式也可以。
单测覆盖率
单元测试写完了,如何衡量单测写的好不好呢?单测覆盖率,我们一般使用分支覆盖率来衡量
什么是分支覆盖率呢?你理解成对if else的覆盖情况,如果只覆盖到了if,那覆盖率就是50%
单测覆盖率如何执行呢?
- 在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>
- 执行命令
mvn clean org.jacoco:jacoco-maven-plugin:prepare-agent test surefire:test surefire-report:report -Dsurefire.reportsDirectory=./target/site --settings=D:/IdeaProjects/settings.xml
- 结果
每个包的分支覆盖率,指令覆盖率都赫然显示出来,点击包就能看到某个类的覆盖率以及代码执行情况
总结
单元测试需要花很长时间写,导致很多程序员不想写也不愿意写,但单元测试的收益是非常大的,在我们下次修改代码的时候,通过执行单元测试对软件进行第一道把关。除此之外,我们做重构也会比较放心,真的是 write once ,run any time。