如何写出优秀的单元测试

2511 篇文章 33 订阅
1797 篇文章 18 订阅

在这里插入图片描述

单元测试已是软件工程师必备的技能,但在我的经验中,有些人写的单元测试实际上却没测到重点,而且还容易因为重构而导致测试失败,可说是为了测试而测试。这样的测试不仅不会带来好处,反而还使专项更不稳健,因此遵循测试的Best Practice是很重要的。本文将介绍Java Unit Test最佳实践与案例,并提出许多人遭遇的盲点。

想像一下这样的场景:

新人小A的第一个任务是新增一个简单的功能,也许只需要十几行代码。但是当他完成后,自动测试系统却回报了大量的测试失败。

小A很难去理解这些测试的内容,以及为什么会失败,因为他这次写的小功能看起来和测试没有关系。本应很快完成的工作却花了小A好几天的时间。扼杀了他的工作效率,也挫伤了他的士气。

大家都知道单元测试有许多好处,前辈们也很负责的将产品加入了许多单元测试。但不幸的是,他们写的这些单元测试却产生了反效果:

测试结果不准确:这次的更改并没有加入真正的 bug,但是测试却失败了。

测试意义不清楚:小A很难确定哪出了问题、如何修复,以及这些测试最初应该做什么。

这种情况经常发生,那么如何避免呢?优秀的单元测试都有这几个特点:

准确: 有bug时就要尽可能抓出来;没有bug就不应该亮红灯。

维护成本低: 测试一旦写好后就不会再改,除非产品需求异动。

执行速度快: 能快速得到反馈,开发人员也才能快速的做出对应。

测试结果直观: 开发人员才能知道哪一行出了什么问题,并最小化问题的范围。

拥有这些特点,就可以算是优秀的单元测试了!这也是优秀的开发者们应努力追求的目标。但要写出优秀的单元测试并不容易,我将介绍几个手段来更靠近这个目标,但前提是要提升产品代码的可测试性

优秀单元测试的设计原则

情境式的结构

尽量以Arrange, Act, Assert 或 Given, When, Then 的 pattern去写单元测试。通过这样的pattern让测试案例比较能表达某情境下的一种行为或结果,会比较贴近使用者,毕竟单元测试就是在模拟使用者如何使用该产品代码。

一个测试案例只验证一个行为

如同好的快筛应只能筛一种病,一个测试案例也应只验证一种行为;如果不是这个测试案例要检验的行为,就不应该把它们写在一起,而是把它们拆分成多个测试案例。

测试案例之间无相依性

测试案例之间应该要各自独立,也不能有先后顺序的相依。如果测试案例相依,万一其中一个发生测试失败时,容易让其他测试案例一起失败,火烧连环船、一发不可收拾,无法快速厘清问题以及很难发现问题的根源。

测试案例的命名尽量清楚、口语化

命名是一件很高深的学问,对于母语非英语的我们更是难以做出清楚的命名。不过幸好Junit 5 之后就可以透过 @DisplayName 来命名中文的测试案例,而且它也会被输出到测试报告中,相当实用:

@Test@DisplayName("我的测试")void my_test() { 
    // ... }

测试案例不具备逻辑运算

如果在测试案例中加入逻辑运算,例如if, else, 加减乘除等等运算,甚至回圈,会让测试变得更复杂。试问:万一测试失败,到底是产品程序出问题?还是测试本身的问题?这是一个在测试中加入逻辑的不良示范:

@Testpublic void shouldNavigateToAlbumsPage() {
    String baseUrl = "http://photos.google.com/";
    Navigator nav = new Navigator(baseUrl);
    nav.goToAlbumPage();    
    assertThat(nav.getCurrentUrl()).isEqualTo(baseUrl + "/albums"); // 这个结果会多一个 slash }

这个例子用字串 + 运算把bug也埋进来了。测试案例就是应该清楚直观,尽量将预期结果直接hard code清楚地写出来,而不要用运算的。

进行相依验证时,只验证有side-effect的行为

实际操作上我们常用Mockito.verify来验证待测物件 (SUT) 与相依物件 (DOC) 的互动。例如有一个grant user程序的单元测试如下:

@Test public void grant_user_permission() {   
    // ... arrange, act ...

    verify(mockPermissionDatabase, times(1)).addPermission(FAKE_USER, USER_ACCESS);
    verify(mockPermissionDatabase, times(1)).getPermission(FAKE_USER);}

其实getPermission是不用verify的。我们应该要想清楚这个测试在验证什么?什么才是我们最关心的?通常就是这个 method有没有side-effect,有side-effect的行为我们才需要verify。这是个取舍(trade-off),如果程序每个路径都去 verify,确实最有可能会抓到bug、单元测试保护力最高,但这样却更容易导致让测试变得太敏感,就像刚刚小A的例子一样,就连重构也可能导致测试失败,阻碍了开发人员做重构的意愿。因此比较好的做法是只verify有side-effect 的行为。

验证时,不过度指定 (over specification)

如果我们验证的时候过度指定,就会让测试程序变得很敏感,一点风吹草动就坏了,也容易出现不准确的问题。例如有一个说早安的程序,测试如下:

@Test public void display_greeting_render_userName() {
    when(mockUserService.getUserName()).thenReturn("Fake User");

    userGreeter.displayGreeting(); 
        
    verify(userPrompt, times(1)).setText("Fake User", "Good morning!", "Version 2.1");
    verify(userPrompt, times(1)).setIcon(IMAGE_SUNSHINE);}

这个测试案例所关心的事情应该是userName,如同它的本身命名一样。但是它verify太多不相干的东西,如果setText其它两个参数一但改变,就会亮起红灯。比较好的做法是先想清楚到底要测什么?将我们要测的行为想好,一个测试案例就只关注一件事:

@Test public void displayGreeting_renderUserName() {    
    when(mockUserService.getUserName()).thenReturn("Fake User");

    userGreeter.displayGreeting(); 

    // 只验证我们真正在意的第1件事: userName
    verify(userPrompter).setText(eq("Fake User"), any(), any());}@Test public void displayGreeting_timeIsMorning_useMorningSettings() {
    setTimeOfDay(TIME_MORNING);

    userGreeter.displayGreeting(); 
    
    // 只验证我们真正在意的第2件事: icon
    verify(userPrompt).setIcon(IMAGE_SUNSHINE);}

这样做的好处是:

如果是第一个测试失败,我就可以明确知道,程序没有把名字写对。

如果是第二个测试失败,我就可以明确知道,程序可能把图案设错。

不过度依赖mocking framework

过度依赖mocking framework是很多人会犯的错,因为大家都想把单元测试写出来,所以穷尽了各式各样的mock技巧,但如果mock越多,越会让测试结果与事实结果越背离。例如有一个刷卡交易的程序,验证信用卡是否有被扣款的单元测试如下:

@Test public void credit_card_is_charged() {
    paymentProcessor =
        new PaymentProcessor(mockCreditCardServer, mockTransactionProcessor);    
    when(mockCreditCardServer.isServerAvailable()).thenReturn(true);
    when(mockTransactionProcessor.beginTransaction()).thenReturn(transaction);
    when(mockCreditCardServer.initTransaction(transaction)).thenReturn(true);
    when(mockCreditCardServer.pay(transaction, creditCard, 500)).thenReturn(false);
    when(mockTransactionProcessor.endTransaction()).thenReturn(true);

    paymentProcessor.processPayment(creditCard, Money.dollars(500));

    verify(mockCreditCardServer, times(1)).pay(transaction, creditCard, 500);}

这个例子过度依赖mocking framework了。测试不仅变得不直观又复杂,也暴露了待测物件的processPayment许多细节,此外,太多假的行为 (stubbing) 导致我们根本不知道 transaction有没有真正成功,即测试结果可能不准确。这样看起来好像有在测,但测试实际给予我们的信心却很低,讲白了就是太假了,像极了为了测试而测试。因此这个测试案例可能无法测出真实的问题。

解决办法之一就是该将它改成整合测试,不要mock,将这段程序放到真实的测试环境中做测试。虽然整合测试的成本较高,但结果也会比较贴近真实、有价值,尤其是这个案例与钱有关,所以需要更严谨、多方面的测试。

尽可能验证回传值

如同前面几个例子,用verify作为单元测试的验证有很多需要注意的细节,过多的verify也可能暴露待测物件过多的实操细节,增加了重构让测试失败的风险,进而降低测试案例的维护性,所以尽量测那些有回传值的行为,例如能用assertEquals, assertTrue等方式验证。毕竟有回传值,一翻两瞪眼,是非分明。

结语

信任,是单元测试的基石。如果单元测试写得不好,结果不准、时好时坏,久而久之大家都不会相信这个测试,甚至最后把它砍了,造成白忙一场、浪费大家时间。要写出优秀的单元测试并不容易,本文提供了一些做法给大家参考,希望能让大家将单元测试写得更好。

最后: 如果你平时有很多问题想要解决,你的测试职业规划也需要一点光亮,你也想跟着大家一起分享探讨,我给你推荐一个 「软件测试学习交流群:746506216」 你缺的知识这里有,你少的技能这里有,你要的大牛也在这里……


资源分享【这份资料必须领取~

下方这份完整的软件测试视频学习教程已经上传CSDN官方认证的二维码,朋友们如果需要可以自行免费领取 【保证100%免费】

在这里插入图片描述

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值