共计 12422 个字符,预计需要花费 32 分钟才能阅读完成。
前言
本文整顿自前不久在组内组织的一次单元测试分享。
背景次要是后续咱们的继续集成流程中会减少单测覆盖率这个一个卡点,大家之后须要缓缓将手头上的服务的单测补充起来。而后就发现组里的人对单测这个事件的了解有很大的偏差,并且有些人不晓得怎么去写单测。所以就有了这么一次分享。
本文纲要如下:
- 为什么要进行单元测试
- 怎么做单元测试(术与道)
-
- 单测之道
- 单测之术
-
Spock – 一站式的单元测试利器
- TestableMock – 一款特立独行的轻量 Mock 工具
- 一些思维误区
- 结语
为什么要写单元测试
首先咱们要搞清楚写单元测试的目标是什么?
单测的目标:尽早 在尽量小 的范畴内 裸露谬误
作为开发人员,咱们都应该晓得,越早发现的缺点,其修复老本是越低的。
另外留神 尽量小的范畴 这个形容,这个意味着一个单测办法的关注点应该尽量最小粒度的,即现实状况下,一个单测办法应该对应性能类的一个办法的一个逻辑分支(logic branch)。
另外,咱们再谈谈单元测试的益处,包含但不限于:
- 晋升软件品质
优质的单元测试能够保障开发品质和程序的鲁棒性。越早发现的缺点,其修复的老本越低。
- 促成代码优化
单元测试的编写者和维护者都是开发工程师,在这个过程当中开发人员会一直去扫视本人的代码,从而(潜意识)去优化本人的代码。
- 晋升研发效率
编写单元测试,外表上是占用了我的项目研发工夫,然而在后续的联调、集成、回归测试阶段,单测覆盖率高的代码缺点少、问题已修复,有助于晋升整体的研发效率。
- 减少重构自信
代码的重构个别会波及较为底层的改变,比方批改底层的数据结构等,下层服务常常会受到影响;在有单元测试的保障下,咱们对重构进去的代码会多一份底气。
像咱们这次的背景就是强调要晋升整个研发团队的研发效力,单测覆盖率作为必不可少的一环,也就加到了咱们的继续集成流程中。
单测之道
根本准则和根本要求
这部分的讲述次要是为了强调写单测时的一些留神点,纠正一些同学对单测的谬误写法和认知。
首先就是 AIR 准则。在包含阿里开发手册等很多文章和规约中对于单元测试的要求外面都提到了这个准则。
单元测试在线上运行时,感觉像空气(AIR)一样并不存在,但在测试品质的保障上,却是十分要害的。
A:Automatic(自动化)
单元测试应该是全自动执行的,并且非交互式的。测试框架通常是定期执行的,执行过程必须齐全自动化才有意义。输入后果须要人工查看的测试不是一个好的单元测试。
单测要能报错,单元测试中不准应用 System.out 来进行人肉验证,必须应用 assert 来验证。
有些同学不喜爱用 Assert,而喜爱在 test case 中写个 System.out.println,人肉察看一下后果,确定后果是否正确。这种写法基本不是单测,起因是即便过后被测试代码是正确的,后续这些代码还有可能被批改,而一旦这些代码被改错了。println 基本不会报错,测试失常通过只会带来虚伪的自信心,这种所谓的 ” 单测 ” 连裸露谬误的作用都起不到,基本就不应该存在。
I:Independent(独立性)
放弃单元测试的独立性。为了保障单元测试稳固牢靠且便于保护,单元测试用例之间决不能相互调用,也不能依赖执行的先后秩序。
反例:method2 须要依赖 method1 的执行,将执行后果做为 method2 的输出。。
R:Repeatable(可反复)
单元测试是能够反复执行的,不能受到外界环境的影响。单元测试通常会被放到继续集成中,每次有代码 check in 时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致继续集成机制的不可用。
除了这下面的 AIR 准则,对于单测还有几点需要:
要有强度
单测的后果校验靠的是最初的 assert 断言,断言须要反映对应性能分支的明确需要能力有强度。
举个简略的例子,假如有以下的待测办法:
public class FileInfoService {public FileInfoDTO singleQuery(Long id) {// 实现}
}
以下的断言强度就显得较弱,只对文件信息是否为 Null 做了判断。
(我应用的是 spock 测试框架,前面会具体说)
@Issue("单笔查问 - 对应 id 存在时")
def "test SingleQuery when_id_exists"() {
given: "一个存在的文件 id"
Long existId = 1
when:
FileInfoDTO fileInfoDTO = fileInfoService.singleQuery(existId)
then: "判断文件信息是否查问正确"
fileInfoDTO != null
}
正确的断言应该到判断具体字段是否正确的强度:
// 假如测试库中曾经插入了 id=1 的文件信息
@Issue("单笔查问 - 对应 id 存在时")
def "test SingleQuery_id_exists"() {
given: "一个存在的文件 id"
Long existId = 1
when:
FileInfoDTO fileInfoDTO = fileInfoService.singleQuery(existId)
then: "判断文件信息是否查问正确"
fileInfoDTO != null
Objects.equals(fileInfoDTO.fileName, "***.png")
}
覆盖率保障
强度是指单元测试中对后果的验证要全面,覆盖度则是指测试用例自身的设计要笼罩被测试程序 (SUT, Sysem Under Test) 尽可能多的逻辑。只有覆盖度和强度都比拟高能力较好的实现单测的目标。
依照测试实践,SUT 的覆盖度分为办法覆盖度,行覆盖度,分支覆盖度和组合分支覆盖度几类。不同的系统对单测覆盖度的要求不尽相同,但这是有底线的。一般来说,程序配套的 单测至多要达到 >80% 的办法笼罩以及 >60% 的行笼罩,能力起到 ” 看门狗 ” 的作用,也才是有保护价值的单测。
粒度要小
和集成测试不同,单元测试的粒度肯定要小,只有粒度小能力在出错时尽快定位到出错的地点。单测的粒度最大是类,个别是办法。单测不负责查看跨类或者跨零碎的交互逻辑,那都是集成测试的范畴。
艰深的说,程序员写单测的目标是 ” 擦好本人的屁股 ”,把本人的代码从实现中隔离进去,在集成测试前先保障本人的代码没有逻辑问题。至于集成测试乃至其它测试中裸露进去的接口了解不统一或者性能问题,那都不在单元测试的范畴内。
速度要快
作为 ” 看门狗 ”,最好是在每次代码有批改时都运行单元测试,这样能力尽快的发现问题。这就要求单元测试的运行肯定要快。个别要求 单个测试的运行工夫不超过 3 秒 , 而整个我的项目的单测工夫管制在 3 分钟之内,这样能力在继续集成中尽快裸露问题。
下面只是一般性要求,具体情况当然须要具体分析看待
单测不仅仅是给继续集成跑的,跑测试有时更多的是程序员自身,单测速度和程序员跑单测的志愿成反比 , 如果单测只有 5 秒,程序员会常常跑单测,去享受一下全绿灯的满足感,可如果单测要跑 5 分钟,能在提交前跑一下单测就不错了。
比方下面这种全绿灯的“快感”。
单测之术
后面讲了这么多的实践,可能各位看官们都看厌了,上面咱们以 Spring boot 开发为例,就讲一些具体的技术计划。
测试框架 – Spock
首先是单测的技术框架选型。我这里选用的是 spock,而不是大家罕用的 Junit。
我这边就拿 Junit5 和 Spock 比照两个例子。其余的能够看附录中的扩大材料。
可读性和维护性方面
spock 的单测构造是基于一种 given-when-then
的句式构造,这种概念来源于 BDD。简而言之,它对立了测试的创立,进步了测试的可读性,并使编写起来更容易,尤其是对于经验不足的人。
比方这是一段用 spock 写的单测代码:
class SimpleCalculatorSpec extends Specification {def "should add two numbers"() {
given: "create a calculater instance"
Calculator calculator = new Calculator()
when: "get calculating result via the calculater"
int result = calculator.add(1, 2)
then: "assert the result is right"
result == 3
}
}
用 junit 写是什么样的:
class SimpleCalculatorTest {
@Test
void shouldAddTwoNumbers() {
//given
Calculator calculator = new Calculator();
//when
int result = calculator.add(1, 2);
//then
assertEquals(3, result);
}
}
在 junit 中,你只能用正文表白你的用意。
写的快
其实对于大多数人而言(包含我),写单测都是一件绝对苦楚的事件,因为单测的代码量相对不会比你对应性能类的代码量要少。写单测曾经如此苦楚了,为什么不能让这件事件稍稍变得难受一点?
比方异样断言,咱们为了断言的强度,咱们有时不止要判断是否抛出对应异样还要判断异样的属性。这是 junit5 的写法:
@Test
void shouldThrowBusinessExceptionOnCommunicationProblem() {
//when
Executable e = () -> client.sendPing(TEST_REQUEST_ID)
//then
CommunicationException thrown = assertThrows(CommunicationException.class, e);
assertEquals("Communication problem when sending request with id:" + TEST_REQUEST_ID, thrown.getMessage());
assertEquals(TEST_REQUEST_ID, thrown.getRequestId());
}
这是 spock 的写法:
def "should capture exception"() {
when:
client.sendPing(TEST_REQUEST_ID)
then:
def e = thrown(CommunicationException)
e.message == "Communication problem when sending request with id: $TEST_REQUEST_ID"
e.requestId == TEST_REQUEST_ID
}
还有就是咱们最罕用的 mock,Junit5 中内置了 mockito 这个 mock 工具。
@Test
public void should_not_call_remote_service_if_found_in_cache() {
//given
given(cacheMock.getCachedOperator(CACHED_MOBILE_NUMBER)).willReturn(Optional.of(PLUS));
//when
service.checkOperator(CACHED_MOBILE_NUMBER);
//then
then(webserviceMock).should(never()).checkOperator(CACHED_MOBILE_NUMBER);
// verify(webserviceMock, never()).checkOperator(CACHED_MOBILE_NUMBER); //alternative syntax
}
spock 框架内置了一个 mock 子系统,提供 mock 相干的性能:
def "should not hit remote service if found in cache"() {
given:
cacheMock.getCachedOperator(CACHED_MOBILE_NUMBER) >> Optional.of(PLUS)
when:
service.checkOperator(CACHED_MOBILE_NUMBER)
then:
0 * webserviceMock.checkOperator(CACHED_MOBILE_NUMBER)
}
能够看到,尽管 mockito 的语法十分可读(given/thenReturn/thenAnswer/…),然而你不会感觉,我只是想 mock 一下这个办法的调用,为什么要写如此多的代码?
对于 spock 和 junit5 的进一步比照,大家能够浏览这篇文章 spock-vs-junit-5,上述局部的比照代码也节选于此;
另外对于 spock 的应用,具体能够参照我写的一篇文章:Spock in Java 缓缓爱上写单元测试;当然官网文档是更好的抉择:
mock 工具 – TestableMock
到这里可能会有人说了,spock 不是内置 mock 工具吗,为啥还要独自再说 mock 工具的事件。次要 spock mock 和 mockito 一样,并不全面。
比方我想 mock 静态方法,spock 就做不到了。在 mock 全能方面,第一个想到的应该是 powermock,然而呢,powermock 用在 spock 下面有兼容性的问题,我 google 了很多材料然而都没有解决。并且引入的依赖项都十分复杂,所以我就放弃了 powermock。转向了 TestableMock 这款阿里开源的 mock 工具。
官网并没有阐明这个框架能够和 spock 联合应用,然而依照其 mock 的原理和理论测试下来,是能够在 spock 中应用的。
我的场景是我的待测办法中引入了 javax.imageio.ImageIO
这个类,我并不想理论调用的它的 read(InputStream input)
办法,因为我没有实在的图片输出流。所以我要把它 mock 掉。
step1 引入依赖
<dependency>
<groupId>com.alibaba.testable</groupId>
<artifactId>testable-all</artifactId>
<version>${testable.version}</version>
<scope>test</scope>
</dependency>
step 2 申明 mock 容器
mock 容器算是 TestableMock 外面的概念。在对应测试类中再申明一个动态类
static class Mock {
// 搁置 Mock 办法的中央
@MockMethod(targetClass = ImageIO.class)
private static BufferedImage read(InputStream input) throws IOException {
// 借用 MOCK_CONTEXT 来判断不同的测试 case
switch ((String) MOCK_CONTEXT.get("case")) {
case "normal":
return new BufferedImage(100, 200, 1)
case "error":
throw new IOException()
default:
return null
}
}
}
MOCK_CONTEXT
次要是为了不同的输入场景。
step 3 编写对应测试 case 的代码
def "test createFileInfo pic normal"() {
when:
//... logic
// 确定 mock 的 case
MOCK_CONTEXT.put("case", "normal");
FileInfo fileInfo = fileService.createFileInfo(********)
then:
fileInfo.width == 100
fileInfo.height == 200
}
数据层 (DAO) 层测试
DAO 层测试,是我在分享的时候惟一倡议同学们启动 spring 容器测试的状况。
咱们团体外部应用的是 MyBatis Plus,在理论场景中,DAO 对数据库的操作依赖于 mybatis 的 sql mapper 文件或者基于 MyBatis Plus 的动静 sql,在单测中验证所有 sql 逻辑的正确性十分重要,在 DAO 层有足够的覆盖度和强度后,Service 层的单测能力仅仅关注本身的业务逻辑。
为了验证,咱们须要一个能理论运行的数据库。为了进步速度和缩小依赖,能够应用内存数据库。上面的计划中就应用 H2 作为单测数据库。
step1 引入依赖
<!-- h2database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.197</version>
<scope>test</scope>
</dependency>
<!-- spock 相干 -->
<!-- https://mvnrepository.com/artifact/org.spockframework/spock-core -->
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.1-groovy-2.4</version>
<scope>test</scope>
</dependency>
<!-- 在 spock 中集成 spring 容器测试 -->
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-spring</artifactId>
<version>1.1-groovy-2.4</version>
<scope>test</scope>
</dependency>
<!-- enables mocking of classes without default constructor -->
<dependency>
<groupId>org.objenesis</groupId>
<artifactId>objenesis</artifactId>
<version>2.6</version>
<scope>test</scope>
</dependency>
step2 筹备初始化 sql
在测试资源目录 src/test/resource
下新建 db/{your_module}.sql
,其中的内容是须要初始化的建表语句,也能够包含局部初始记录的 dml 语句,至于怎么导,很多数据库工具能够实现。如果表构造产生了更改,须要人工从新导出。
step3 筹备测试类
@Title("Dao 测试")
@ContextConfiguration(classes = [DaoTestConfiguration.class])
class FileInfoDaoTest extends Specification {
@Autowired
private FileInfoService fileInfoService
}
@ContextConfiguration(classes = [DaoTestConfiguration.class])
这个注解很要害,他是 spring-test 模块的注解,通过这个注解能够配置 spring 容器在启动时的上下文。如果你不指定,那一般来说就默认是你 Spring Boot 的 Application 类决定的上下文。
再来看看 DaoTestConfiguration.class
外面做了什么配置:
@Configuration
@ComponentScan(basePackages = {"com.***.***.dao"})
@MapperScan({"com.****.***.mapper"})
@SpringBootApplication(exclude = {FeignAutoConfiguration.class, ApolloAutoConfiguration.class,***.class,***.class,
Swagger2AutoConfiguration.class, FeignRibbonClientAutoConfiguration.class,
ManagementContextAutoConfiguration.class, JmxAutoConfiguration.class,
BeansEndpointAutoConfiguration.class, TaskExecutionAutoConfiguration.class,
TaskExecutionAutoConfiguration.class, AuditAutoConfiguration.class,
LoadBalancerAutoConfiguration.class, RefreshEndpointAutoConfiguration.class, HystrixAutoConfiguration.class,
RibbonAutoConfiguration.class, ConsulDiscoveryClientConfiguration.class})
public class DaoTestConfiguration {
@Bean
public DataSource dataSource() {EmbeddedDatabaseBuilder builder = new EmbeddedDatabaseBuilder();
return builder.setType(EmbeddedDatabaseType.H2).addScript("classpath:db/***.sql").build();}
}
这个类外面关键点次要有几个:
@SpringBootApplication(exclude = {})
通过这个注解,把一些主动拆卸的类给排除掉了。比方我的我的项目集成了 Apollo,swagger 等组件,还有咱们的微服务框架是在 spring cloud 根底上自研的,我的项目外面也做了很多主动拆卸类的解决,这些在单元测试的环节都能够说是无用的,减少单测的启动工夫不说,甚至会让你的单测启都起不起来。
还记得咱们下面的准则吗,单测要尽可能得对外部环境没有依赖。所以,去掉这些主动拆卸类是很必要的。当然,这一块须要具体我的项目具体分析。因为不同我的项目引入的组件不同,另外还要小心别把 spring boot 本身启动须要的主动拆卸类给排了,也会导致起不来。
@ComponentScan(basePackages = {"com.***.***.dao"})
因为咱们只测 Dao 层,所以只须要扫描 Dao 层的类即可。
- 配置 h2 数据源,指定初始化 sql 脚本
剩下的工作就是编写具体的测试代码即可。
def "UpdateFileSize"() {
given:
FileInfo fileInfo = fileInfoDao.getById(1)
def size = fileInfo.fileSize + 1
when:
fileInfoDao.updateFileSize(fileInfo.id, size)
then: "size 更新胜利"
fileInfoDao.getById(1).fileSize == size
}
其余类的测试
对于除数据库以外的依赖,包含消息中间件、缓存等中间件以及内部的微服务、第三方 API,在单测中全副采纳 Mock 进行解耦。
我想了想,还是在这演示下一个基于多个服务依赖的性能类应该怎么测试。
假如有这个一个类,他的性能实现依赖于 3 个内部服务(你能够设想成一个 feign client、dubbo provider、thrid api 服务)
/**
* 依据黑名单和白名单确认用户是否能拜访
*/
@Service
public class AccessService {
@Autowired
private UserService userService;
@Autowired
private BlackListProvider blackListProvider;
@Autowired
private WhiteListProvider whiteListProvider;
/**
* 返回指定用户是否可能拜访
* @param userId 用户 ID
*
* @return
*/
public boolean canAccess(Long userId) {List<Long> whiteListProviderList = whiteListProvider.provideUserIdWhiteList();
if (whiteListProviderList.contains(userId)) {return true;} else {String name = userService.findNameById(userId);
return !blackListProvider.provideUserNameBlackList().contains(name);
}
}
}
编写对应测试类如下:
@Subject(AccessService)
class AccessServiceTest extends Specification {def "test canAccess"() {
given: "筹备数据和 mock"
// mock 内部服务
UserService userService = Mock()
WhiteListProvider whiteListProvider = Mock()
BlackListProvider blackListProvider = Mock()
// 初始化
AccessService accessService = new AccessService()
// 赋值
accessService.userService = userService;
accessService.whiteListProvider = whiteListProvider;
accessService.blackListProvider = blackListProvider;
// 有人可能会奇怪,这三个服务不是都是公有变量吗,能够这么赋值吗?// 对,groovy 中就是能够这么不便!and: "打桩"
// 打桩是 mock 中的另一个名词,意思就是指定对应输出时返回对应的输入
// user 服务传入 1L/2L/3L/4L,返回 tom/jerry/anna/lucky
userService.findNameById(1L) >> "tom"
userService.findNameById(2L) >> "jerry"
userService.findNameById(3L) >> "anna"
userService.findNameById(4L) >> "lucky"
// 白名单蕴含 jerry,anna 和 lucky
whiteListProvider.provideUserIdWhiteList() >> [2L, 3L, 4L]
// 黑名单蕴含 "tom","jerry","peter"
blackListProvider.provideUserNameBlackList() >> ["tom", "jerry", "peter"]
expect:
result == accessService.canAccess(userId)
where:
userId | result
// 在黑名单里,不在白名单里,不能拜访
1L | false
// 在黑名单里,也在白名单里,能够拜访
2L | true
// 不在黑名单里,在白名单里,能够拜访
3L | true
// 不在黑名单里,也不在白名单里,能够拜访
4L | true
}
}
这里不开展讲 spock 的语法。
一些思维误区
补单测
补单测是很有责任心的体现,但还是要说 单测应该随着代码同时产生,而不应该是补进去的。
当一段代码 (一个类或者一个办法) 刚被写进去的时候,开发对整个上下文十分分明,要测试什么逻辑也很明确 ( 单测是白盒测试),这时候写单测速度最快,也最容易设计出高强度的单元测试。如果等一次产出 N 个类,上千行代码再去写单测,很多过后的上下文都曾经忘记了,而且惰性会使人面对大量工作时产生畏难情绪,这时写的单测品质就比拟差了。至于为几个月甚至几年前的代码写单测,基本上除了大规模重构,是没人违心去写的。
在测试前置这方面最激进的尝试是 TDD (Test Driven Development),其次是 TFD (Test First Development),它们都要求单测在代码前实现。只管这两个实际目前不是很风行,但还是举荐有趣味的同学去尝试一下 TDD,通过 TDD 陶冶的代码会天然的感觉单元测试是程序的一部分,对于这点了解也会更深。
我的项目紧,没工夫写单测
这也是没有写单测习惯的开发常常会说的话。
再紧的我的项目都要有设计、编码、测试和公布这些环节,如果说我的项目紧不写单测,看起来编码阶段省了一些工夫,但如果存在问题,必然会在测试和线上花掉成倍甚至更多的老本来修复。
也就是说,可能从开发的角度,你的工作是因为不写单测按时实现了,然而从整体我的项目 / 性能的交付工夫来看,却不是这样。所以如果你的团队是个懂得单测重要性的团队,就应该在评估开发工夫的把单测的工夫思考进去。
错误率是恒定的,须要的调试量也是固定的,用测试甚至线上环境调试并不能升高调试的量,只会升高调试效率。
单测是 QA 的工作
这是混同了单元测试和集成测试的边界。
单元测试是白盒测试,应该随着代码一起产出,一起批改。单元测试的目标是让程序员 ” 擦洁净本人的屁股 ”,保障绝对小的模块的确在依照设计指标工作。单元测试须要代码和程序同时变动,不要说 QA,就是换个开发写单测都赶不上这个节奏(除非结对编程)。所以单元测试肯定是开发的工作。
集成测试是黑盒测试,个别是端到端的测试,很大的工作量在保护上下游环境的兼容上。集成测试运行的频率也比单元测试低,这部分工作由 QA 来作还是能够承受的。
结语
本文讲述了单测的术和道,介绍了单测的一些情理和具体的技术计划。文中提到的 spock 并非是强硬性要求,只是我集体偏好而已。其实用 junit 写也是能写的,只是代码量会比拟多。看你哪个用的熟。
我集体感觉要害的是在于建立起对单测的正确认知,在实际操作中做到文中提到的几个准则和要求。越是重要的我的项目,我越举荐你写单元测试。单元测试就是咱们程序员的救生圈,在代码的陆地中为程序员提供安全感。有了单元测试的保障,程序员才有信念在约定工夫内实现联调和公布,才敢对已有的程序作批改和重构而不放心引入新问题。
参考文章
- 写有价值的单元测试
- Spock vs JUnit 5 – the ultimate feature comparison | Solid Soft
- Spock Primer 官网文档
- Spock in Java 缓缓爱上写单元测试
- https://github.com/alibaba/te…