单元测试难?来试试这些套路

阿里妹导读:测试不该该是一门很高大尚的技术,应该是咱们技术人的基本功。但如今好像慢慢地,单元测试已经脱离了基本功的范畴。笔者曾经在不一样团队中推过单元测试,要求过覆盖率,但发现实施下去很难。后来在不停地刻意练习后,发现阻碍写UT的只是笔者的心魔,并非时间和项目的问题。在通过一些项目的实践后,也是有了一些本身的理解和实践,但愿和你们分享一下,和你们探讨下如何克服“单元测试”的心魔。css


文末福利:开发者成长计划,最强助力!html


内功

前人们在单元测试方面的研究不少,有不少的方法论,咱们能够拿来即用。我简单介绍两个方法论,一个概念。但愿你们能够查阅更多的资料,凝聚本身的内功心法。

TDD

Test Driven Development,也被认为是Test Driven Design,咱们这里按第一种定义来聊。TDD一改以往的破坏性测试的思惟方式,测试在先、编码在后,更符合“缺陷预防”的思想。简单来讲,TDD的流程是“红-绿-重构”三个步骤的循环往复。

  • 红:测试先行,如今尚未任何实现,跑UT的时候确定不过,测试状态是红灯。编译失败也属于“红”的一种状况。
    前端


  • 绿:当咱们用最快,最简单的方式先实现,而后跑一遍UT,测试会经过,变成“绿”的状态。java


  • 重构:看一下系统中有没有要重构的点,重构完,必定要保证测试是“绿”的。
    web


业界有不少TDD的呼声,也有TDD已死的文章。方法原本没有对错,只有优劣,咱们要辩证地来看。只能说TDD不是一个银弹,不能解决全部问题。以笔者本身的经验,TDD比较适用于输入输出很明确的CASE,不少时候咱们在摸索一种新的模式的时候,可能并不太适用。

若是你和前端已经商议好了接口的出参、入参,能够尝试一下TDD,一种新的思路,新的思想。

BDD

严格来讲BDD是TDD衍生出来的一个小分支。但也能够用于一些不一样维度的东西。概念你们自行寻找资料。这里讲一下BDD的一点实践经验。直接上代码:

@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()); }}

第一个UT是以方法维度,把全部场景放到一个方法来测试。

第二个UT是以case为角度,针对每一个case单独的测试。

其实TDD里面有一个概念是隔离性,单元测试之间应该隔离开,不要互相干扰。另外,从命名上,第二种也更好一点。我我的仍是比较推荐如下命名方式的:

  • should:返回值,应该产生的结果
    spring

  • when:哪一个方法sql

  • given:哪一个场景
    typescript


另外BDD或者TDD中也有Task的概念,写代码以前先准备好case。你们能够看一些BDD的文章,本身体会。若是对这个感兴趣,能够在评论区探讨。

测试金字塔



上图来自martin fowler博客的TestPyramid[1]一文,也能够读一下《Practical Test Pyramid》[2]。特别棒的文章,但愿你们能够去读一读。

上面的金字塔的意思是,从Unit到Service,再到UI,速度愈来愈慢,成本也愈来愈高。

咱们能够从服务端的角度把这三层稍微改一下:

  • 契约测试:测试服务与服务之间的契约,接口保证。代价最高,测试速度最慢。
    数据库


  • 集成测试(Integration):集成当前spring容器、中间件等,对服务内的接口,或者其余依赖于环境的方法的测试。
    json


// 加载spring环境@RunWith(SpringBootRunner.class)@DelegateTo(SpringJUnit4ClassRunner.class)@SpringBootTest(classes = {Application.class})public class ApiServiceTest {
@AutowiredApiService apiService;//do some test}

  • 单元测试(Unit Test):纯函数,方法的测试,不依赖于spring容器,也不依赖于其余的环境。




咱们如今写测试,通常是单元测试和集成测试两层。针对具体场景,选择适合本身的测试粒度。

招数

其实写单元测试是有一些招数的,下面会介绍笔者很喜欢的一种单元测试代码组织结构,也会介绍一些经常使用的招数,以及使用场景。

常见问题

  • 一个类里面测试太多怎么办?

  • 不知作别人mock了哪些数据怎么办?

  • 测试结构太复杂?

  • 测试莫名奇妙起不来?


Fixture-Scenario-Case

FSC(Fixture-Scenario-Case)是一种组织测试代码的方法,目标是尽可能将一些MOCK信息在不一样的测试中共享。其结构以下



  • 经过组合Fixture(固定设施),来构造一个Scenario(场景)。


  • 经过组合Scenario(场景)+ Fixture(固定设施),构造一个case(用例)。


下面是一个FSC的示例:



  • Case:当用户正常登陆后,获取当前登陆信息时,应该返回正确的用户信息。这是一个简单的用户登陆的case,这个case里面总共有两个动做、场景,一个是用户正常登陆,一个是获取用户信息,演化为两个scenario。


  • Scenario:用户正常登陆,确定须要登陆参数,如:手机号、验证码等,另外隐含着数据库中应该有一个对应的用户,若是登陆时须要与第三方系统进行交互,还须要对第三方系统进行mock或者stub。获取用户信息时,确定须要上一阶段颁发的凭证信息,另外该凭证多是存储于一些缓存系统的,因此还须要对中间件进行mock或者stub。


  • Fixture


  • 利用Builder模式构造请求参数。


  • 利用DataFile来存储构造用户的信息,例如DB transaction进行数据的存储和隔离。


  • 利用Mockito进行三方系统、中间件的Mock。


当这样组织测试时,若是另一个Case中须要用户登陆,则能够直接复用用户登陆的Scenario。也能够经过复用Fixture来减小数据的Mock。下面咱们来详细解释看一下每一层如何实现,show the code。

Case

case是用例的意思,在这里用例是场景和一些固定设施的组合。这里要注意的是,尽可能不要直接修改接口的数据,一个场景所依赖的环境应该是另外一个场景的输出。固然有些特定场景下,仍是须要直接改数据的,这里不是禁止,而是建议。

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")); }}

Scenario

JUNIT的用法就不说了,相信你们都了解,这里提两个框架REST Assured和Mock MVC。这两个框架均可以用来作接口测试,Mock MVC是spring原生的,能够指定加载的Resource,必定程度上能够提高UT速度,可是和spring是耦合在一块儿的。REST Assured是脱离Spring的,能够理解为利用http进行接口的测试,耦合性更低,使用灵活。二者各有千秋,笔者比较推荐REST Assured。咱们看一下,一个REST Assured打造的Scenario怎么写,怎么用?

@Datapublic 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

固定设施部分,主要是用来提供一些固定的组件和数据。尽可能的让这部分东西有复用性,若是没复用性,尽可能和测试放在一块儿,不要干扰他人。

(1)方法

a)Mock

mockito挺通用的,并且spring也提供了@MockBean,能够直接将Mock一个bean放入spring的容器中。而后能够利用mockito提供的方法对方法进行模拟或者验证。代码示例:

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


stub是打桩,关于打桩和mock的区别,请自行百度,这里只是想展现一下,在spring的环境下,覆盖原有bean达到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; }}

(c)嵌入式DB

这里简单介绍几种嵌入式DB,能够自行选择使用。


(d)直连DB + Transaction

除了使用嵌入式的DB,也能够直连环境,但不推荐,由于环境上的数据是多变的,若是测试出现问题,排查的复杂度会增长。这里其实想强调下@Transactional。由于Mock的数据最好作到隔离,好比一个接口的操做是批量删除数据,有可能会把一个其余测试依赖的数据删除掉,这样问题一旦出现很难排查,由于单独跑每一个测试都是经过的,可是一块儿跑就会出问题。这里推荐两种作法:

  • 使用@Transactional在一些测试的类上,这样在跑完测试后,数据不会commit,会回滚。但若是测试中对事物的传播有特殊要求,可能不适用。


  • 通用的trancateAll和initSQL经过在每一个测试前跑清除数据、mock数据的脚本,来达到每一个测试对应一个隔离环境,这样数据间就不会产生干扰。


(e)PowerMock

PowerMock是用来建立一些静态方法的Mock的,若是你的代码中会调用一些静态方法,可是静态方法依赖于一些其余复杂的逻辑或者资源。可使用这个包。

PowerMockito.mockStatic(C.class);PowerMockito.when(C.isTrue()).thenReturn(true);

注意:


  • PowerMock不只仅是用来mock静态方法的。


  • 不建议mock静态方法,由于静态方法的使用场景都是些纯函数,大部分的纯函数不须要mock。部分静态方法依赖于一些环境和数据,针对这些方法,须要考虑下究竟是要mock其依赖的数据和方法,仍是真的要mock这个函数,由于一旦mock了这个函数,意味着隐藏了细节。


(2)数据

(a)Builder模式

数据最简单的mock方式就是Builder,而后本身手填各类参数,但有些对象有几十个字段,而你的一个测试只须要改其中的两个字段,你该怎么办?Copy、Paste?

@Builder@Datapublic 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)数据文件


有时候经过builder构造对象的时候,字段太多,而且数据的来源是前端或者其余服务提供的json。这个时候能够将这个数据存储到文件中,利用一些工具方法,将数据读取成制定的文件。这也是数据mock的经常使用手段。我这里是以json为例,其实sql等数据也能够这样。

数据文件的优势:可承载的数据量大、编辑方便。

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是用在接口测试上,也就是测试金字塔的Integration Test部分,放在这个层次,有几个缘由:

  • FSC自己会给测试带来复杂度,而UnitTest应该简单,若是UnitTest自己都很复杂了,项目带来难以估量的测试成本。


  • Fixture其实能够在任何场景中使用,由于是底层的复用。


缺陷

  • 增长了代码复杂度。


  • 经过IDE工具没法直接定位的测试文件,折衷的方案是case的命名符合ResouceTest的命名。


校场

从简单到复杂

上面咱们介绍了测试金字塔,越考上层,复杂度越高。因此刚接触单元测试的同窗,能够从“单元测试”的层次开始练习,能够练习Builder,Fixture怎么写,方法怎么Mock。若是你感受这些都到了拿来即用的阶段,那就能够往上层写,考虑下怎么给项目增长一些通用的基础设施,来减小测试的总体复杂度。

刻意练习:3F原则


刻意练习,简而言之,就是刻意的练习,它突出的是有目的的练习。刻意练习也有它的一整套过程,在这个过程里,你须要遵照它的3F法则:


  • 第一,Focus保持专一

  • 第二,Feedback(注重反馈,收集信息)。

  • 第三,Fix it(纠正错误,而且进行修改)。


UT自己是一项技术,是须要咱们打磨、练习的,最好的练习方式,就是刻意练习,若是有决心,一个周末在家刻意练习,为项目中的部分场景加上UT,相信收获会很丰富。


打造本身的测试环境

本身要不断的摸索,什么样的组织方式,什么样的工具方法是适合本身项目的。软件工程中没有银弹,没有最好,只有合适。

常见问题

  • 应不该该连平常环境进行测试?


  • 我的不建议直接连平常环境进行测试,若是两我的同时在跑测试,那么颇有可能测试环境的数据会处于混乱状态。并且UT尽量不要依赖过多的外部环境,依赖越多越复杂。测试仍是简单点好。


  • 一个类里面测试太多怎么办?


  • 考虑按测试的case区分,也可按测试的方法区分,也能够按正常、异常场景区分。


  • 不知作别人mock了哪些数据怎么办?


  • 尽可能让你们Mock数据的命名规范,经过Fixutre的复用,来减小新写测试的成本。


  • 测试结构太复杂?


  • 考虑是否是本身应用的代码组织就有问题?


  • 测试莫名奇妙起不来?


  • 须要详细了解JUNIT、Spring、PandoraBoot等是如何进行测试环境的mock的,是否是测试间的数据冲突等。详细的咱们会在方法篇持续更新,遇到问题解决问题。


心魔

单元测试这件事,实施的时候仍是有不少阻力的,笔者原来给本身也找过不少理由,不管是用来讲服领导的,仍是说服本身的。下面是笔者对于这些理由的一些思考,但愿能和你们有一些共鸣。

不会写

虽然很不肯意认可这个事,但最后仍是认可了本身是真的不会写单元测试。刚接触单元测试的时候,看了看junit的文档,心想单元测试,不就是个“Assert”吗,有啥不会的,这东西好学。后来实施过程当中发现,单元测试不只仅是“Assert”,还须要准备环境,Mock数据,复现场景,验证。着实是个麻烦事。

后来反思,为何单元测试麻烦?一开始学习ORM框架的时候不麻烦吗?一开始学Spring不麻烦吗?后来熟悉了Bean的生命周期、BeanFactory、BeanProcessor等,Spring已经不是个麻烦事了。仔细想一想,本身对单元测试的理解仅仅是:“一个Mock加一个Assert”。仅仅学了几个框架,看了几篇文章,还作不到把单元测试这件事真正落地。

在落地单元测试的时候,有一些常见的问题:

场景太复杂,须要的数据太多,怎么处理?

能够直接使用JSON、SQL将现有数据修改后导入到系统中。这样的话可能须要mock的数据就不会那么多了,能够提炼一些工具类,直接从resource中读取数据文件,导入到数据库、或者提供给mock方法使用。

也能够构建一些Fixture,将本身系统中UT的数据固定下来,这样,若是前面一个同窗已经mock过相关数据了,再新写UT的时候能够拿来即用。构建 Fixture能够用工厂模式、构建者模式等来达到数据隔离的效果,避免相互干扰。

好多东西都是和中间件或者其余系统频繁交互,怎么写测试?

数据库层面可使用内存型数据库“H2”、"Embedded Mysql"、“Embedded PostgreSql”等。

若是以上都不能解决问题,可使用mockito直接mock相应的Bean。

单元测试的粒度问题,这个方法该不应写UT,另一个方法为何不须要写UT?

单元测试的粒度没有标准答案,笔者本身总结了一些写UT粒度方面的方法:

  • 不熟悉单元测试写法,尽可能写简单的单元测试,覆盖核心方法。


  • 熟悉单元测试,业务复杂,覆盖正常、通常异常场景,另外对核心业务逻辑要有单独的测试。


测试如何复用?

测试应该是有组织、有结构的,就像咱们写业务代码同样,会想着如何在代码层面复用、如何在功能层面复用、如何在业务维度复用。单元测试也应该有结构,能够尽可能复用一些前人的经验。简单来讲,测试的复用也分为三个维度:数据、场景、用例,好的代码结构应该尽可能的能让测试复用,让增长UT再也不是从头开始。

不想写

写测试有什么用?

不少人都写过单元测试的文章,罗列过不少单元测试的不少好处,这里就不赘述了。这里讲几个感触比较深的用处吧?

  • DEBUG:阿里如今的基础设施是真的完善,中间件、各类监控、日志,只要系统埋点够好,遇到的不少问题均可以解决,即便有一些复杂问题,也能够local debug。但在一些特殊场景下,将数据MOCK好,利用UT来DEBUG,可能效率更高,你们能够试试。


  • 测试如文档:咱们如今开发有不少完善的文档,但文档这东西和代码上毕竟有一层映射关系,若是能快速了解业务,完善的测试,有时候也是个不错的选择,例如你们学习一些开源框架的时候,都会从测试开始看。


  • 重构:当你想下定决心重构的时候,才发现项目中没有单元测试,什么心情?


价值不高

在面对复杂的接口时,经常须要Mock不少数据来支撑一个小的点,不少时候心里感受没价值,由于一个if-else的变更,居然须要准备N份数据,得不偿失。

后来反思,为何一个if-else的变更,须要准备N份数据?若是这个接口一开始写的时候就有健全的UT,那一个if-else的变动还须要准备N份数据吗?大几率不须要了吧,有可能只须要改一个测试case就行了。因此说如今成本高,未来成本会更高,如今作了,作的好一点,后面可能成本就低了。

笔者观点:写单元测试,应该比写代码的成本更低。


这个不用说吧,通用理由,你们都明白。路是人踩出来的,总要有人要先走。Why not you?

最后

若是你们对于单元测试有好的实践,或者对文章中的一些观点有些共鸣,你们能够在评论区留言,咱们互相学习一下。你们也能够在评论区写出本身的场景,你们一块儿探讨如何针对特定场景来实践。

相关连接

[1]https://martinfowler.com/bliki/TestPyramid.html
[2]https://martinfowler.com/articles/practical-test-pyramid.html




开发者成长计划
最强资源,最强助力


阿里云开发者成长计划来啦!面向整年龄段开发者提供免费云服务器、学习成长路线及场景体验实践,全面帮助开发者轻松掌握云上技能,助推成长,培养数字经济时代的云计算技术人才!


识别下方二维码,或点击 “阅读原文” ,快去参与吧~



戳我,去开发者成长计划。

本文分享自微信公众号 - 阿里巴巴技术质量(AlibabaTechQA)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索