BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

Mock不是测试的银弹

| 作者 胡凯 关注 0 他的粉丝 发布于 2009年5月14日. 估计阅读时间: 21 分钟 | CNUTCon 了解国内外一线大厂50+智能运维最新实践案例。

开发者编写高质量测试的征途上可谓布满荆棘,数据库、中间件、不同的文件系统等复杂外部系统的存在,令开发者在编写、运行测试时觉得苦恼异常。由于外部系 统常常运行在不同机器上或者本地单独的进程中,开发者很难在测试中操作和控制它们。外部系统以及网络连接的不稳定性(外部系统停止响应或者网络连接超 时),将有可能导致测试运行过程随机失败。另外,外部系统缓慢的响应速度(HTTP访问、启动服务、创建删除文件等),还可能会造成测试运行时间过长、成 本过高。种种问题使开发者不断寻找一种更廉价的方式来进行测试,mock便是开发人员解决上述问题时祭出的法宝。mock对象运行在本地完全可控环境内,利用mock对象模拟被依赖的资源,使开发者可以轻易的创建一个稳定的测试环境。mock对象本地创建,本地运行的特性更是加快测试的不二法门。

我所在团队设计开发的产品是持续集成服务器,产品特性决定了它需要在各个平台(Windows, Mac, Linux等)与各种版本管理工具(svn, mercurial,git等)、构建工具(ant, nant, rake等)进行集成,对于外部系统的严重依赖让我们在编写测试时遇到了很多困难,我们自然而然的选用了JMock作为测试框架,利用它来隔离外部系统对 于测试的影响,的的确确在使用JMock框架后测试编写起来更容易,运行速度更快,也更稳定,然而出乎意料的是产品质量并没有如我们所预期的随着不断添加 的测试而变得愈加健壮,虽然产品代码的单元测试覆盖率超过了80%,然而在发布前进行全面测试时,常常发现严重的功能缺陷而不得不一轮轮的修复缺陷、回归 测试。为什么编写了大量的测试还会频繁出现这些问题呢? 在讨论之前先来看一个真实的例子:

我们的产品需要与Perforce(一种版本管理工具)进行集成,检测某段时间内Perforce服务器上是否存在更新,如果有,将更新解析为 Modification对象。将这个需求反应在代码中,便是首先通过Perforce对象检测服务器更新,然后将标准输出(stdout)进行解析:

  public class PerforceMaterial {
    private Perforce perforce;
    .....
    .....
    public List findModifications(Date start, Date end) {
        String changes = perforce.changes(start, end);    //检测更新,返回命令行标准输出(stdout)        
        List modifications = parseChanges(changes);//将标准输出解析为Modification
        return modifications;
    }

    private List parseChanges(String output) {
         //通过正则表达式将stdout解析为Modification对象
    }
}

public class Perforce {
    public String changes(Date start, Date end) {
          //通过命令行检测更新,将命令行标准输出(stdout)返回
    }
}           

相应的mock测试也非常容易理解:

      .....
    .....  
    @Test
    public void shouldCreateModifiationWhenChangesAreFound() {
        final String stdout = "Chang 4 on 2008/09/01 by p4user@Dev01 'Added build.xml'"; //设置标准输出样本
        final Date start = new Date();
        final Date end = new Date();

        context.checking(new Expectations() {{
            one(perforce).changes(start, end);
            will(returnValue(stdout));
        }});//设置perforce对象的行为,令其返回设定好的stdout

        List list = perforceMaterial.findModifications(start, end);//调用被测方法

        assertThat(list.get(0).revision(), is("4"));
        assertThat(list.get(0).user(), is("p4user@Dev01"));
        assertThat(list.get(0).modifiedTime(), is("2008/09/01"));
    }

测试中的stdout是在真实环境下运行Perforce命令行所采集的标准输出(stdout)样本, 通过mock perforce对象,我们可以轻易的控制changes方法的返回值,让验证解析逻辑的正确性变得非常容易,采用mock技术使开发者无需顾忌 Perforce服务器的存在与否,而且可以采用不同的stdout来覆盖不同的情况。然而危机就在这看似完美的测试过程中被埋下了,事实上 Perforce stdout中的时间格式会依用户环境的设定而变化,从而进一步导致parseChanges方法中的解析逻辑出现异常。由于测试中的stdout全由假 设得来,并不会依照环境变化,即便我们将测试跑在多种不同的环境中也没能发现问题,最终在产品环境才由客户发现并报告了这个缺陷。

真实perforce对象的行为与测试所使用的mock对象行为不一致是出现上述问题的根本原因,被模拟对象的行为与真实对象的行为必须完全一致称之为mock对象的行为依赖风险。 开发者对API的了解不够、被模拟对象的行为发生变化(重构、添加新功能等修改等都可能引起被被模拟对象的行为变化)都可能导致错误假设(与真实对象行为 不一致),错误假设会悄无声息的引入缺陷并留下非法测试。非法测试在这里所代表的含义是,它看起来很像测试,它运行起来很像测试,它几乎没有价值,它几乎 不会失败。在开发中,规避行为依赖风险最常见的方法是编写功能测试,由于在进行mock测试时,开发者在层与层之间不断做出假设,而端到端的功能测试由于 贯穿了所有层,可以验证开发者是否做出了正确的假设,然而由于功能测试编写复杂、运行速度慢、维护难度高,大部分产品的功能测试都非常有限。那些通过 mock测试的逻辑,便如埋下的一颗颗定时炸弹,如何能叫人安心的发布产品呢?

《UNIX编程艺术》中有一句话“先求运行,再求正确,最后求快”,正确运行的测试是高质量、可以快速运行测试的基础,离开了正确性,速度和隔离性都是无 根之木,无源之水。那么采用真实环境就意味着必须承受脆弱而缓慢的测试么?经历了一段时间的摸索,这个问题的答案渐渐清晰起来了,真实环境的测试之所以痛 苦,很大程度上是由于我们在多进程、多线程的环境下对编写测试没有经验,不了解如何合理的使用资源(所谓的资源可能是文件、数据库中的记录、也可能是一个 新的进程等),对于我们,mock测试作为“银弹”的作用更多的体现在通过屏蔽运行在单独进程或者线程中的资源,将测试简化为对大脑友好的单线程运行环境。在修复过足够多的脆弱测试后,我们发现了编写健壮测试的秘密:

要设计合理的等待策略来保守的使用外部系统。很多情况下,外部系统处于某种特定的状态是测试得以通过的条件,譬如HTTP服务必须启动完 毕,某个文件必须存在等。在编写测试时,开发者常常对外部系统的估计过于乐观,认为外部系统可以迅速处于就绪状态,而运行时由于机器和环境的差异,结果往 往不如开发者所愿,为了确保测试的稳定性,一定要设计合理的等待策略保证外部系统处于所需状态,之所以使用"等待策略"这个词,是因为最常见”保证外部系 统处于所需状态“的方法是万恶的"Thread.sleep", 当测试运行在运算速度/网络连接速度差异较大的机器上时,它会引起随机失败。而比较合理的方法是利用轮询的方式查看外部系统是否处于所需状态(譬如某个文 件存在、端口打开等),只有当状态满足时,才运行测试或者进行Assertion,为了避免进入无限等待的状态,还应该设计合理的timeout策略,帮 助确定测试失败的原因。

要正确的创建和销毁资源漠视测试环境的清理也常常是产生脆弱测试的原因,它主要表现在测试之间互相影响,测试只有按照某种顺序运行时才会成功/失败,这种问题一旦出现会变的非常棘手,开发者必须逐一对有嫌疑的测试运行并分析。因此,有必要在开始时就处理好资源的创建和销毁,使用资源时应当本着这样一个原则:谁创建,谁销毁。 junit在环境清理方面所提供的支持有它的局限性,下面的代码是使用资源最普遍的方式:

  @After
public void teardown() {
   //销毁资源A
   //销毁资源B
}

@Test
public void test1() {
   //创建资源A
}

@Test
public void test2() {
   //创建资源B
}

为了确保资源A与资源B被正确销毁,开发者必须将销毁资源的逻辑写在teardown方法中,然而运行用例test1时,资源B并未被创建,所以必须在 teardown中同时处理资源A或B没有被创建的情况,由于需要销毁的资源是用例中所使用资源的并集,teardown方法会快速得膨胀。由于这样的原 因,我在开源项目junit-ext中加入了对Precondition的支持,在测试用例运行前,其利用标注所声明的多个Precondition的setup方法会被逐一调用来创建资源,而测试结束时则调用teardown方法销毁资源。

  @Preconditions({ResourceIsCreated.class, ServiceIsStarted.class})
@Test
public void test1() {
      //在测试中使用资源
}

public class ResourceIsCreated implements Precondition {
    public void setup() {
           //创建资源
    }
    public void teardown() {
           //回收资源
    }
}

public class ServiceIsStarted implements Precondition {
     public void setup() {
           //创建资源
     }
     public void teardown() {
           //回收资源
     }
}

public interface Precondition {
     void setup();

     void teardown();
}

这个框架可以更好的规范资源的创建和销毁的过程,减少因为测试环境可能引起的随机失败,当然这个框架也有其局限性,在ResourceIsCreated 和ServiceIsStarted之间共享状态会比较复杂,在我们的产品中,Precondition大多用于启动新进程,对于共享状态的要求比较低, 这样一套机制就非常适合。每个项目都有其特殊性,面对的困难和解决方案也不尽相同,但在使用资源时如果能遵守“谁创建,谁销毁”的原则,将会大大减小测试 之间的依赖性,减少脆弱的测试。

要设计合理的过滤策略来忽略某些测试。我们很容易在项目中发现只能在特定环境下通过的测试,这个特定环境可能是特定的操作系统,也可能是特 定的浏览器等,之所以会产生这些测试通常是开发者需要在源码中进行一些特定环境的hack,它们并不适合在所有环境下运行,也无法在所有环境中稳定的通 过,因此应该设计一套机制可以有选择的运行这些测试,junit的assumeThat的机制让我再次有点失望,本着自己动手丰衣足食的原则,在junit-ext我添加了利用标注来过滤测试的机制,标注了RunIf的测试仅当条件满足时才会运行,除了内置一些Checker,开发者也可以很方便的开发自己的Checker来适应项目的需要。

  @RunWith(JunitExtRunner.class)
  public class Tests {
    @Test
    @RunIf(PlatformIsWindows.class) //test1仅运行在Windows环境下
    public void test1() {
    }
}

public class PlatformIsWindows implements Checker {
    public boolean satisfy() {
        //检测是否WINDOWS平台
    }
}

要充分利用计算资源而不是人力资源来加快测试。对于加快测试,最普遍也最脆弱的方法是利用多线程来同时运行多个测试,这个方法之所以脆弱, 是因为它会让编写测试/分析失败测试变的异常复杂,开发者必须考虑到当前线程在使用资源时,可能有另一个线程正要销毁同一个资源,而测试失败时,也会由于 线程的不确定性,导致问题难于重现而增加了解决问题的成本。我认为一个更好的实践是在多台机器上并发运行测试,每台机器只需要运行(总测试数/机器数)个 测试,这样所花费时间会近似减少为(原本测试时间/机器数)。相对与购置机器的一次性投入,手工优化的不断投入成本更高,而且很多公司都会有闲置的计算资 源,利用旧机器或者在多核的机器上安装虚拟机的方式,可以很经济的增加计算资源。在项目开发的业余时间,我和我的同事们一起开发了开源的测试辅助工具test-load-balancer。在我们的项目中,通过它将需要90分钟的测试自动划分为数个10分钟左右的测试在多台虚拟机上并发运行,很好的解决了速度问题。

对mock追本溯源,我们会发现它更多扮演的是设计工具的角色而不是测试工具的角色,mock框架在设计方面的局限性李晓在《不要把Mock当作你的设计利器》一文中已经谈的很透彻,在此不再赘述。Mock不是测试的银弹,世上也并无银弹存在,测试实践能否正常开展的决定性因素是团队成员对测试流程,测试方法的不断改进而不是先进的测试框架。不要去依赖mock框架,它的强制约定常常是你改进设计和添加功能的绊脚石,改善设计,依赖一个简洁的代码环境,依赖一套可靠的测试方法才是正途。从意识到mock测试带来的负面影响,到从滥用mock的泥潭中挣扎出来,我们花费了很多时间和经历,希望这些经验可以对同行们能有所借鉴,有所启发。

Mock,还请慎用。

写在最后

在ThoughtWorks工作的三年经历中,对于mock框架的使用从无到有,到滥用到谨慎。三年间,和李晓陶文李彦辉,Chris Stevenson等人反复讨论和辩论使我对mock有了更多的理解。这篇文章反反复复改了许多稿,在此对陈金洲李默的耐心帮助致谢。最后,没有与InfoQ的编辑李剑和霍泰稳在Twitter上的一番谈话,也就没有这篇文章。

参考

Mock Roles, not Objects,  Steve Freeman, Nat Pryce, Tim Mackinnon, Joe Walnes

不要把Mock当作你的设计利器李晓

作者简介:

胡凯,ThoughtWorks公司的敏捷咨询师,近2年一直从事持续集成工具Cruise以及CruiseControl的设计开发工作。 创造和参与了开源测试框架junit-exttest-load-balancer,对于Web开发,敏捷实践,开源软件与社区活动有浓厚的兴趣,可以访问他的个人博客进行更多的了解。

相关阅读

[ ThoughtWorks实践集锦(1)] 我和敏捷团队的五个约定

[ ThoughtWorks实践集锦(2)] 如何在敏捷开发中做好数据迁移

[ ThoughtWorks实践集锦(3)] RichClient/RIA原则与实践(上)(下)

[ ThoughtWorks实践集锦(4)] 为什么我们要放弃Subversion

[ ThoughtWorks实践集锦(5)] “持续集成”也需要重构


给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家加入到InfoQ中文站用户讨论组中与我们的编辑和其他读者朋友交流。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

right by Zhang Gavin

的确是这样。如果mock不能很好的模拟真实场景,那么基于这个mock所作的测试必然是不完全可信的

这不能怪Mock by kingo liang

如果Mock的时候没有能够模拟出对象的真实行为,那么这种就不能算是基于Mock的测试。

re by Lee Vincent

说了半天,原来作者就是想告诉大家——单元测试不能代替集成测试,这简直是废话。

Re: re by Jacky Li

能不能给出你的推理过程来?

Re: re by Lee Vincent

“然而出乎意料的是产品质量并没有如我们所预期的随着不断添加的测试而变得愈加健壮……然而在发布前进行全面测试时,常常发现严重的功能缺陷而不得不一轮轮的修复缺陷、回归 测试。为什么编写了大量的测试还会频繁出现这些问题呢?”
“由于测试中的stdout全由假 设得来,并不会依照环境变化,即便我们将测试跑在多种不同的环境中也没能发现问题,最终在产品环境才由客户发现并报告了这个缺陷。”
“在开发中,规避行为依赖风险最常见的方法是编写功能测试”
然后开始将如何写出能快速运行的集成测试……

Mock应用的场景只是单元测试 by Liu Min

感觉作者小题大做了。
真正的测试是很丰富的,开发人员要做单元测试、功能测试、性能测试,测试人员要做场景测试、集成测试和自动测试。
mock使用的场景仅仅是单元测试,保证代码的覆盖率和在预期环境下可以正常的工作。
单元测试不能替代功能测试和场景测试,功能测试是真实的测试,需要依赖外部资源,测试基本功能点。
场景测试比功能测试更复杂,是功能测试的复杂组合,涵盖了很多功能测试的地方,但是检查的内容更复杂一些。

Re: Mock应用的场景只是单元测试 by chan hyddd

和楼上同感,不同的测试保证不同的东西,使用MOCK也一样,看你的焦点在哪儿,而且不应就一种测试类型保证产品质量。

Re: Mock应用的场景只是单元测试 by wu darui

我觉得这个不能怪mock,日期格式随环境不一样返回值不一样,说到底还是测试没有考虑到各种因素,也就是说你mock的返回值,没有考虑到各种情况,只是测试了一条路径而已。
不过mock测试是很难写的,主要是你很难考虑到各种情况,在集成测试中也是一样的吧。
如果你在集成测试中可以反映的问题,在mock中也就可以模拟出来,这个是工作量和你能够设定的case问题。

Mock就是Mock,用错了不应该怪工具。 by 徐 毅

如题。

没什么道理 by w ym

整篇文章唯一有价值的地方就是提出了MOCK测试中容易犯的错误.但这个错误显示是人为疏忽造成的,却归咎于MOCK.....

在测试中最基本的覆盖所有条件这一步没做好,明显是编写MOCK的人经验不足,或者不细心造成的.导致这一后果的原因很可能是被mock的方法的注释写的不完整,对各种返回值没有明确说明.或者是写测试的人压根就没仔细看注释说明.

工具不能代替人思考 by wei zhang

作者所举的日期格式随环境而变的例子,是因为“没想到”,所以定义了一个不完善的mock,导致bug遗漏到用户处。可是不用Mock,或者任何其他的工具和方法能够帮助“想到”有这个需求么?答案显然是否定的。从而更加有力的证明了:决定软件质量的是人的经验和能力,而不是采用的过程和方法。Mock方法是一个工具,仅此而已。既然没有银弹已经成为公理,那么显然,Mock不是银蛋不证自明。

Re: Mock就是Mock,用错了不应该怪工具。 by zh shu

同意,从文中描述来看,作者对于怎样使用MOCK并不熟悉,路径覆盖不完全是设计case场景不到位的问题,对于作者提到的外部系统要保守使用,看起来是测试的问题,实际上是产品代码设计的问题

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

12 讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT