背景
最近在给一个客户做技术咨询,然后发现了客户对于单元测试的一个有意思的现象。分享出来,大家一起学习探讨一下。
现状分析
这里以java后端项目例,发现客户写的测试长下面的样子。(代码已经脱敏处理过。)
@Autowired
private SampleJob handler;
@Test
public void testStart() throws Exception {
SampleParamVo paramVo = new SampleParamVo();
paramVo.setStartTime("2021-03-18");
paramVo.setEndTime("2021-03-18");
handler.execute(paramVo);
}
@Autowired
private SampleHandler handler;
@Test
public void testHandler() {
handler.doHandler(new DateTime("2021-11-26"), null);
}
那么这样的测试代码有什么问题呢?
- 别人看不懂这个测试是在做什么。首先测试的方法名没有任何意义,其次测试代码也只是调用了某个函数。
- 无法运行。这类测试代码运行往往需要启动其他服务或者需要一些特殊的设置。无法运行就意味着它不能成为CI跑测试的一部分。
- 没有断言。没有断言就无法知道测试的代码的正确性。
- 使用了
@Autowired
这样的代码,增加了测试的耦合以及编写成本。
和客户深聊了之后发现,原来客户不同的人对单元测试的理解也不一样。
- 写这个代码的开发人员说,“这些代码是在开发完成之后做一些自测的辅助脚本。”
- 有的开发人员说,“我们是微服务,单元测试需要调用其他服务,写起来很麻烦,而且如果其他服务不可用时,测试也跑不过。”
- 测试人员说:“单元测试我们有的,我每天都在写测试用例,到单元测试的时候我就会把我的用例全部过一遍。”
所以我们可以发现,有的开发人员口中的单元测试其实应该属于集成测试或者E2E测试,有的开发人员完全没有写过单元测试,而测试人员理解单元测试是自己手动测试的时候用的测试用例。
那我们就先来说说什么是单元测试。
什么是单元测试?
通常在java的世界里面,单元测试就是指对一个public
的方法编写检查和验证的代码。
为什么要写单元测试?
写单元测试主要有两大目的:
- 验证功能实现。
- 保护已有功能不被破坏。
当我们写完一个方法,我们如何知道自己写的方法是按期望工作的呢?这个时候就可以添加单元测试来验证我们的代码是按期望工作的。即当我们给定指定的输入,我们获得期望的输出,则我们说这个功能是符合期望的。
其次,代码不是写了就永远不变的,当需求变更时,新增需求时,修复bug时,都会修改代码,而单元测试则能保护我们已有的功能不被破坏。保护已有功能不会被自己破坏,被新人破坏,被新功能破坏。
如何写单元测试?
下面是一个单元测试的例子
@Test
public void should_return_fizz_given_input_can_be_divided_by_3() {
FizzBuzz fizzBuzz = new FizzBuzz(); // Given
String actual = fizzBuzz.sayIt(6); // When
Assertions.assertEquals("Fizz", actual); // Then
}
一个标准的单元测试包含以下几个部分:
- 能描述清楚做了什么的测试名(方法名)
-
单元测试的Given、When、Then具体内容。
- Given:初始状态或前置条件
- When:行为发生
- Then:断言结果
写好单元测试要主要几个要点:
- 因为测试代码并不会进入生产环境,同时我们期望测试即文档,因此测试的名称写很长也没有关系,重要的是能清晰的表达我们这个测试所覆盖的用例是什么。
- 一个测试只测一种case。
-
单元测试通常需要覆盖大量的case来保证我们的代码在绝大多数场景下都是按期望工作的。因此要做到这一点可以参考下面两大原则。这里就不详细讲解这两个原则,具体内容可以Google。
- CORRECT原则
- Right-BICEP原则
-
单元测试有一个考核的标准就是测试覆盖率,指的是我们的代码有百分之多少被单元测试测到了。
- 测试覆盖率分几种:行覆盖率,分支覆盖率,路径覆盖率,条件覆盖率等。每种都可以单独设置百分比。通常我们会看中行覆盖率和分支覆盖率。
- 通常行业里面常设置测试覆盖率在85%以上。
- 为什么不是100%?因为不是所有代码都能被测到的,比如private的构造函数是无法被测到的,这种就会降低覆盖率。
- 通常所有的自动化测试都是开发人员来写,比如单元测试,集成测试等。
测试金字塔
说到单元测试,就不得不提测试金字塔,如下图,最底层是单元测试,最顶层是UI测试。(测试金字塔有好几种,但道理都是相通的)
看左边的箭头,越往下越快,越往上越慢,它主要包括编写越快,运行越快,定位问题越快等。
看右边的箭头,越往下成本越低,越往上成本越高,包括时间成本,金钱成本,人员成本,维护成本等。
什么是mock?
我们在做单元测试的时候,常常可能访问外部系统或者外部类,这些外部的不可控性会让我们的单元测试成本变得很高。
常见的外部不可控性有:HTTP访问,增删文件,随机性,时间相关性,接口类等。
于是开发者便开始探索更廉价的方式来写单元测试,mock就是其中的解决方案。
mock 对象运行在本地完全可控环境内,利用 mock 对象模拟被依赖的资源,使开发者可以轻易的创建一个稳定的测试环境。
mock是Test double理论中的一种,如果对test double理论感兴趣,可以到这里了解更多,这里就不展开说了。
如何用mock?
还是以java为例,java的世界中常用的mock框架比如mockito。
下面是一个mock的例子。
@Test
void should_return_100_when_get_list_size() {
List map = mock(List.class);
//当调用list.size()方法时候,返回100
when(map.size()).thenReturn(100);
Assert.assertEquals(100, map.size());
}
单元测试是我们测试的最小单位,因此我们只测当前这个public
的方法中的实现,而方法中调用第三方类的东西,我们都应该mock掉。
这样的好处有两个:
- 不会因为其他类的不可控性而导致这个测试方法变得难写。
- 其他类的修改不会导致这个测试方法挂掉。所有的变化都被隔离出去了。
什么是TDD?
最后再升华一下,简单说一说TDD,TDD的全称是Test driven development,即测试驱动开发。它是极限编程XP中的一个标准实践。
TDD要求在编写某个功能的代码之前先编写测试代码,然后只编写使测试通过的功能代码,通过测试来推动整个开发的进行。
这样做有四大好处:
- TDD是一个很好的契机,可以让你在考虑解决方案之前先考虑问题。
- 首先考虑测试会迫使你首先考虑与代码的接口。先思考接口可以帮助你将接口与实现分开。
- 简单设计。
- 几乎100%的测试覆盖率。
这里我就不详细叙述TDD相关的话题了,因为TDD是一个比较大的话题,如果感兴趣,下次专门开一个新话题来聊TDD。