单元测试难?来试试这些套路
阿里妹导读:测试不应该是一门很高大尚的技术,应该是我们技术人的基本功。但现在好像慢慢地,单元测试已经脱离了基本功的范畴。笔者曾经在不同团队中推过单元测试,要求过覆盖率,但发现实施下去很难。后来在不停地刻意练习后,发现阻碍写UT的只是笔者的心魔,并不是时间和项目的问题。在经过一些项目的实践后,也是有了一些自己的理解和实践,希望和大家分享一下,和大家探讨下如何克服“单元测试”的心魔。
文末福利:开发者成长计划,最强助力!
红:测试先行,现在还没有任何实现,跑UT的时候肯定不过,测试状态是红灯。编译失败也属于“红”的一种情况。
绿:当我们用最快,最简单的方式先实现,然后跑一遍UT,测试会通过,变成“绿”的状态。
重构:看一下系统中有没有要重构的点,重构完,一定要保证测试是“绿”的。
@RunWith(SpringBootRunner.class)
@DelegateTo(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {Application.class})
public class ApiServiceTest {
@Autowired
ApiService apiService;
@Test
public void testMobileRegister() {
AlispResult<Map<String, Object>> result = apiService.mobileRegister();
System.out.println("result = " + result);
Assert.assertNotNull(result);
Assert.assertEquals(54,result.getAlispCode().longValue());
AlispResult<Map<String, Object>> result2 = apiService.mobileRegister();
System.out.println("result2 = " + result2);
Assert.assertNotNull(result2);
Assert.assertEquals(9,result2.getAlispCode().longValue());
AlispResult<Map<String, Object>> result3 = apiService.mobileRegister();
System.out.println("result3 = " + result3);
Assert.assertNotNull(result3);
Assert.assertEquals(200,result3.getAlispCode().longValue());
}
@Test
public void should_return_mobile_is_not_correct_when_register_given_a_invalid_phone_number() {
AlispResult<Map<String, Object>> result = apiService.mobileRegister();
Assert.assertNotNull(result);
Assert.assertFalse(result.isSuccess());
}
}
should:返回值,应该产生的结果
when:哪个方法
given:哪个场景
契约测试:测试服务与服务之间的契约,接口保证。代价最高,测试速度最慢。
集成测试(Integration):集成当前spring容器、中间件等,对服务内的接口,或者其他依赖于环境的方法的测试。
// 加载spring环境
@RunWith(SpringBootRunner.class)
@DelegateTo(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {Application.class})
public class ApiServiceTest {
@Autowired
ApiService apiService;
//do some test
}
单元测试(Unit Test):纯函数,方法的测试,不依赖于spring容器,也不依赖于其他的环境。
一个类里面测试太多怎么办?
不知道别人mock了哪些数据怎么办?
测试结构太复杂?
测试莫名奇妙起不来?
通过组合Fixture(固定设施),来构造一个Scenario(场景)。
通过组合Scenario(场景)+ Fixture(固定设施),构造一个case(用例)。
Case:当用户正常登录后,获取当前登录信息时,应该返回正确的用户信息。这是一个简单的用户登录的case,这个case里面总共有两个动作、场景,一个是用户正常登录,一个是获取用户信息,演化为两个scenario。
Scenario:用户正常登录,肯定需要登录参数,如:手机号、验证码等,另外隐含着数据库中应该有一个对应的用户,如果登录时需要与第三方系统进行交互,还需要对第三方系统进行mock或者stub。获取用户信息时,肯定需要上一阶段颁发的凭证信息,另外该凭证可能是存储于一些缓存系统的,所以还需要对中间件进行mock或者stub。
Fixture
利用Builder模式构造请求参数。
利用DataFile来存储构造用户的信息,例如DB transaction进行数据的存储和隔离。
利用Mockito进行三方系统、中间件的Mock。
public class GetUserInfoCase extends BaseTest {
private String accessToken;
@Autowired
private UserFixture userFixture;
/**
* 通用场景的mock
*/
@Before
public void setUp() {
//三方系统mock
userFixture.whenFetchUserInfoThenReturn("1", new UserVO());
//依赖的其他场景
accessToken = new SimpleLoginScenario()
.mobile("1234567890")
.code("aaa")
.login()
.getAccessToken();
}
/**
* BDD的三段式
*/
@Test
public void should_return_user_info_when_user_login_given_a_effective_access_token() {
Response userInfoResponse = new GetUserInfoScenario()
.accessToken(accessToken)
.getUserInfo();
assertThat(userInfoResponse.jsonPath().getString("id"), equals("1"));
}
}
@Data
public class SimpleLoginScenario {
// 请求参数
private String mobile;
private String code;
// 登录结果
private String accessToken;
public SimpleLoginScenario mobile(String mobile) {
this.mobile = mobile;
return this;
}
public SimpleLoginScenario code(String code) {
this.code = code;
return this;
}
//登录,并且保存AccessToken,这里返回自身,是因为有可能返回参数是多个。
public SimpleLoginScenario login() {
Response response = loginWithResponse();
this.accessToken = response.jsonPath().getString("accessToken");
return this;
}
//利用RestAssured进行登录,这个方法可以是public,也可以通过参数传递一些验证方法
private Response loginWithResponse() {
return RestAssured.get(API_PATH, ImmutableMap.of("mobile", mobile, "code", code))
.thenReturn();
}
}
Fixture
public class MockitoTest {
@MockBean(classes = CacheImpl.class)
private Cache cache;
@Test
public void should_return_success() {
// 固定参数,固定返回值
Mockito.when(cache.get("KEY")).thenReturn("VALUE");
// 动态参数,固定返回值
Mockito.when(cache.get(Mockito.anyString())).thenReturn("VALUE");
// 动态参数,固定返回值
Mockito.when(cache.get(Mockito.anyString())).then((invocation) -> {
String key = (String) invocation.getArguments()[0];
return "VALUE";
});
// 固定参数,异常
Mockito.when(cache.get("KEY")).thenThrow(new RuntimeException("ERROR"));
// 验证调用次数
Mockito.verify(cache.get("KEY"), Mockito.times(1));
}
}
(b)stub
//使用spring的@Primary来替换一个bean,如果不同的测试需要的bean不同,推荐使用@Configuration + @Import的方式,动态加载Bean
@Primary
@Component("cache")
public class CacheStub implements Cache {
@Override
public String get(String key) {
return null;
}
@Override
public int setex(String key, Integer ttl, String element) {
return 0;
}
@Override
public int incr(String key, Integer ttl) {
return 0;
}
@Override
public int del(String key) {
return 0;
}
}
使用@Transactional在一些测试的类上,这样在跑完测试后,数据不会commit,会回滚。但如果测试中对事物的传播有特殊要求,可能不适用。
通用的trancateAll和initSQL通过在每个测试前跑清除数据、mock数据的脚本,来达到每个测试对应一个隔离环境,这样数据间就不会产生干扰。
PowerMockito.mockStatic(C.class);
PowerMockito.when(C.isTrue()).thenReturn(true);
注意:
PowerMock不仅仅是用来mock静态方法的。
不建议mock静态方法,因为静态方法的使用场景都是些纯函数,大部分的纯函数不需要mock。部分静态方法依赖于一些环境和数据,针对这些方法,需要考虑下到底是要mock其依赖的数据和方法,还是真的要mock这个函数,因为一旦mock了这个函数,意味着隐藏了细节。
@Builder
@Data
public class UserVO {
private String name;
private int age;
private Date birthday;
}
public class UserVOFixture {
// 注意:这里是个Supplier,并不是一个静态的实例,这样可以保证每个使用方,维护自己的实例
public static Supplier<UserVO.UserVOBuilder> DEFAULT_BUILDER = () -> UserVO.builder().name("test").age(11).birthday(new Date());
}
(b)数据文件
public class UserVOFixture {
public static UserVO readUser(String filename) {
return readJsonFromResource(filename, UserVO.class);
}
public static <T> T readJsonFromResource(String filename, Class<T> clazz) {
try {
String jsonString = StreamUtils.copyToString(new ClassPathResource(filename).getInputStream(), Charset.defaultCharset());
return JSON.parseObject(jsonString, clazz);
} catch (IOException e) {
return null;
}
}
}
FSC本身会给测试带来复杂度,而UnitTest应该简单,如果UnitTest本身都很复杂了,项目带来难以估量的测试成本。
Fixture其实可以在任何场景中使用,因为是底层的复用。
增加了代码复杂度。
通过IDE工具无法直接定位的测试文件,折衷的方案是case的命名符合ResouceTest的命名。
刻意练习,简而言之,就是刻意的练习,它突出的是有目的的练习。刻意练习也有它的一整套过程,在这个过程里,你需要遵守它的3F法则:
第一,Focus(保持专注)。
第二,Feedback(注重反馈,收集信息)。
第三,Fix it(纠正错误,并且进行修改)。
UT本身是一项技术,是需要我们打磨、练习的,最好的练习方式,就是刻意练习,如果有决心,一个周末在家刻意练习,为项目中的部分场景加上UT,相信收获会很丰富。
应不应该连日常环境进行测试?
个人不建议直接连日常环境进行测试,如果两个人同时在跑测试,那么很有可能测试环境的数据会处于混乱状态。而且UT尽可能不要依赖过多的外部环境,依赖越多越复杂。测试还是简单点好。
一个类里面测试太多怎么办?
考虑按测试的case区分,也可按测试的方法区分,也可以按正常、异常场景区分。
不知道别人mock了哪些数据怎么办?
尽量让大家Mock数据的命名规范,通过Fixutre的复用,来减少新写测试的成本。
测试结构太复杂?
考虑是不是自己应用的代码组织就有问题?
测试莫名奇妙起不来?
需要详细了解JUNIT、Spring、PandoraBoot等是如何进行测试环境的mock的,是不是测试间的数据冲突等。详细的我们会在方法篇持续更新,遇到问题解决问题。
不熟悉单元测试写法,尽量写简单的单元测试,覆盖核心方法。
熟悉单元测试,业务复杂,覆盖正常、一般异常场景,另外对核心业务逻辑要有单独的测试。
DEBUG:阿里现在的基础设施是真的完善,中间件、各种监控、日志,只要系统埋点够好,遇到的很多问题都可以解决,即使有一些复杂问题,也可以local debug。但在一些特殊场景下,将数据MOCK好,利用UT来DEBUG,可能效率更高,大家可以试试。
测试如文档:我们现在开发有很多完善的文档,但文档这东西和代码上毕竟有一层映射关系,如果能快速了解业务,完善的测试,有时候也是个不错的选择,例如大家学习一些开源框架的时候,都会从测试开始看。
重构:当你想下定决心重构的时候,才发现项目中没有单元测试,什么心情?
最后
如果大家对于单元测试有好的实践,或者对文章中的一些观点有些共鸣,大家可以在评论区留言,我们互相学习一下。大家也可以在评论区写出自己的场景,大家一起探讨如何针对特定场景来实践。
相关链接
[1]https://martinfowler.com/bliki/TestPyramid.html
[2]https://martinfowler.com/articles/practical-test-pyramid.html
阿里云开发者成长计划来啦!面向全年龄段开发者提供免费云服务器、学习成长路线及场景体验实践,全面帮助开发者轻松掌握云上技能,助推成长,培养数字经济时代的云计算技术人才!
识别下方二维码,或点击 “阅读原文” ,快去参与吧~
本文分享自微信公众号 - 阿里巴巴技术质量(AlibabaTechQA)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
SpringBoot:切面AOP实现权限校验:实例演示与注解全解
点击上方 "后端架构师"关注,星标或置顶一起成长 后台回复“大礼包”有惊喜礼包! 关注订阅号「后端架构师」,收看更多精彩内容 每日英文 You smiled and talked to me of nothing and I felt that for this I had been waiting long. 你微微地笑着,不同我说什么话。而我觉得,为了这个,我已等待得久了。 每日掏心话 生活,就应当努力使之美好起来。真情和假意,只要学着把它看成香槟里飘飞的小气泡,也就不必太认真。 来自:云深不知处|责编:乐乐 链接:blog.csdn.net/mu_wind/article/details/102758005 后端架构师(ID:study_tech) 第 1049次推文 往日回顾:色情版“微信”背后的秘密,太可怕了~ 正文 目录 理解AOP 什么是AOP AOP体系与概念 AOP实例 第一个实例 第二个实例 AOP相关注解 @Pointcut @Around @Before @After @AfterReturning @AfterThrowing 1 理解AOP 1.1 ...
- 下一篇
pagehelper/PageInterceptor导致MyBatis执行SQL问题
问题 同事J上了一个需求, 导致一个跟这个需求毫无关系的接口报错, 报错信息显示是因为SQL语法问题, 正常SQL应该是这样: select * from table where condition order by field limit from, size 但是现在却是: select * from table where condition limit from, size order by field 不知道为什么order by跑到limit后面去了, 所以导致MySQL语法问题 项目配置 springboot 2.1.8.RELEASE mybatis-spring-boot-starter 2.1.0 pagehelper 5.1.10 问题原因 这次锅还是自己的, 对于pagehelper的不熟悉导致的. 我们在xml中的SQL大概是这样 select * from table where condition order by ${orderBy} limit from, size 我们这里为了灵活, 排序规则是传入的, 但是"orderBy"是pagehelper的...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- SpringBoot2配置默认Tomcat设置,开启更多高级功能
- CentOS关闭SELinux安全模块
- Eclipse初始化配置,告别卡顿、闪退、编译时间过长
- CentOS6,CentOS7官方镜像安装Oracle11G
- SpringBoot2整合MyBatis,连接MySql数据库做增删改查操作
- Docker使用Oracle官方镜像安装(12C,18C,19C)
- Hadoop3单机部署,实现最简伪集群
- CentOS7编译安装Gcc9.2.0,解决mysql等软件编译问题
- SpringBoot2整合Redis,开启缓存,提高访问速度
- Windows10,CentOS7,CentOS8安装Nodejs环境