微软的代码覆盖与代码注入技术之前世今生

代码覆盖(code coverage)和代码注入(code injection)都是在测试过程中有着重要用途的工具。如今在Visual Studio IDE里面代码覆盖已经是内建的功能,代码注入则不是,工程师普遍使用像Moq一样的开源库来实现类似代码注入的虚假依赖(dependency mock)。让人意外的是,微软的代码覆盖和代码注入技术原是一体,后来才逐渐分道扬镳,这篇文章要讲的就是他们的前世今生和内部秘辛。

早期在微软内部Visual Studio IDE并不是像现在那样整合程度极高,代码覆盖是通过内部工具分离实现的。这个名为麦哲伦(Magellan)的工具是几个命令行可执行程序以及相当老的、现在已经淘汰的本地网页渲染工具(用来展示覆盖/错失的代码行)。麦哲伦的使用过程极其繁琐——这也是当时一个项目的代码覆盖测试需要一个工程师负责的缘故:

  1. 先调用命令行处理需要测量代码覆盖的EXE或者DLL文件,以及其PDB文件,结果会产生另一对可执行文件及其PDB文件。所以如果你有20个可执行文件,这件事就得做20遍,好一点的,你得准备命令行文件把这20件事写在里面。这里还得指向一个SQL数据库用来保存测试过程收到的覆盖数据。
  2. 如果你的项目是安装包安装的,那还得把重新制作安装包的部分加上;否则,把可执行文件和PDB部署到测试环境也是个麻烦活。
  3. 每个含有需要测试的可执行文件的机器都需要安装一个Windows Service,用来收集覆盖数据。
  4. 把要测试的步骤在覆盖测试的环境执行一遍(可能是手动的喔)。
  5. 在第3步里面的每台机器上执行命令行,把覆盖数据上传到SQL数据库里面。
  6. 最后,用命令行把SQL数据库里面的代码覆盖报告捣腾出来,用那个本地网页渲染工具打开发报告。

一般来说,负责的工程师到第4步就开始呕吐了,因为他们往往在前3步之一漏掉某个可执行文件,或者忘了在某个机器上安装Windows Service,导致第4步白做了,有时甚至是到第6步才发现。不得不说,能坚持下来的都是坚韧不拔的主。从而使得这种代码覆盖测试在以前微软长达三个月到半年的milestone里面只能做一次。

可以想象的是,当报告发出来之后,肯定能发现不少没覆盖到的重要地方,于是项目经理也好、开发经理也好,提出增加测试用例的时候测试经理都好说,但是如果坚持再做几次代码覆盖看看改善情况、或者坚持要求代码覆盖率必须要到80%什么的,那么除了破口大骂之外,动起手来也不是不可能的......

微软的测试老行尊Alan Page、Ken Johnston和Bj Rollison写过一本书,How we test software at Microsoft,里面把代码覆盖测试单独写一节。开始我还不明白这有什么大不了的,直到我自己对着一个只有两个EXE的项目搞了一遍之后才明白过来,这与其说是介绍经验,还不如说是吐槽大会。

所以你明白为何这个工具在微软让人爱恨交加,因为没得选、没法改进。麦哲伦一开始支持C++,后来加入对早期.NET代码的支持。整个机制在那个时候算是顶尖的做法:

  1. 分析中间代码(对C++/.NET而言)或源代码(对解释性语言如JavaScript而言)定位关键的点:函数/代码段开始和结束指令、跳转指令(if/while/for/do/try/catch/throw/return/...)、函数调用。
  2. 在这些点的关键位置插入侦听过程的调用,并且记录相对于源代码的(修改代码后的)位置信息。
  3. 侦听过程把传入的关键点的独特ID存储下来。
  4. 生成插入代码之后的新中间/最终/源代码。

例如以下的C++代码:

01 void foo(int i)
02 {
03    if (i > 0)
04    {
05        bar();
06    }
07 }

麦哲伦插入的侦听点位于02、04、05、06、07。实际的工程应用中,因为if的表达式可能是复杂的和/或嵌套表达式,bar函数的调用参数可能是复杂的表达式和其他函数调用,还可能会插入更多的侦听点。

因为除了块覆盖率(block coverage),麦哲伦还会测量弧覆盖率(arc coverage)。还是以上面的代码为例,只需要调用foo(1)就能保证foo函数100%块覆盖率,但是漏掉了03行if语句跳过04-06直达07行的情况,这是块覆盖率看不出来的。弧覆盖率可以清楚的表达从03跳到07这一路径尚未覆盖的事实。除非加上类似foo(0)的用例,否则弧覆盖率达不到100%。

那么既然插入了代码,上述第4步产生新代码的过程就是不可避免的,这就是一切痛苦的根源。作为C++的最终机器代码,除非在操作系统级别使用中断例程,否则侦听这个过程还是要插入到源代码里面。

当时麦哲伦没敢动这个心思,跟Windows kernel团队打交道,而且仅仅是为了微软内部测试的目的,是注定找骂的结局。但是自己的技术继续发扬光大可没有人会批评。于是麦哲伦再接再厉,整出来代码注入,因为代码注入也是修改中间代码而已,而且需求也是现成的:

我要测试的代码调用的某些API,如果要它返回一个错误代码或者数据,在测试环境里面是极其困难的,能不能只为测试目的,让麦哲伦产生的代码覆盖用途的新代码在那些API调用的时候转向我自己写的测试代码呢?

于是麦哲伦的代码注入是这么搞的,秉承了一如既往的繁琐:

  1. 先列个表,写清楚要注入那些函数调用,比如什么OpenFile之类的。
  2. 调用命令行处理要注入的可执行文件和第1步的列表,产生一堆代码模板,与要注入的函数名字一一对应。
  3. 在代码模板里面分别写入你要模拟的逻辑,比如产生想要的返回值之类,注意应该是有条件的,可以根据配置控制调用原来的函数还是你自己的逻辑。这样你可以在代码覆盖测试中分别测试一般情况和边界情况。编译链接它。
  4. 调用命令行处理要注入的可执行文件、第1步的列表和第3步的可执行代码,一起产生新的可执行代码。

这样,你就有了一个可以代码覆盖同时又能失败注入(fault injection)的可执行代码了,接下来把前面的呕吐过程再做一遍吧。为了少吐几次,高手都是预先想好哪些地方要注入,一次弄好代码注入再来搞代码覆盖测试的。能搞得好的那是坚韧不拔加上头脑清晰的主。这样一来,麦哲伦代码注入的用户更加的少,搞得好像能用好是无上牛掰的体现似的。

直到2011年前后,事情发生了一些改变,一下子把整个痛苦状况扭转过来了。

其一,.NET CLR runtime开始支持一些注入的接口,麦哲伦团队一下子抓住了:中间代码(IL)在虚拟机上执行的时候,自带中断例程的分派接口,于是代码注入的功能发生在中断例程的分派接口实现里面,使得调用某个函数的过程可以整个交由分派接口处理,于是它可以压根不管原有函数的入口地址,而是通过.NET反射(reflection)调用预先注册的函数(当然原有函数的入口也被保留了,你可以在预先注册的函数内调用)。这一功能微软对外发布了,成为.NET framework fakes。它也会产生新的可执行代码,但是这并针对不是你要测试的代码,而是要注入的函数所在的可执行代码,而且只是用于注册注入函数。例如下面代码

[TestClass]
public class TestClass1
{
        [TestMethod]
        public void TestCurrentYear()
        {
            int fixedYear = 2000;

            // Shims can be used only in a ShimsContext:
            using (ShimsContext.Create())
            {
              // Arrange:
                // Shim DateTime.Now to return a fixed date:
                System.Fakes.ShimDateTime.NowGet =
                () =>
                { return new DateTime(fixedYear, 1, 1); };

                // Instantiate the component under test:
                var componentUnderTest = new MyComponent();

              // Act:
                int year = componentUnderTest.GetTheCurrentYear();

              // Assert:
                // This will always be true if the component is working:
                Assert.AreEqual(fixedYear, year);
            }
        }
}

注入函数通过lambda表达式指示,System.Fakes.ShimDateTime类则属于动态生成的System.Fakes.dll,这样你可以随时测试千年虫而不用修改系统时间。

这一工具最初也是麦哲伦团队做的分离工具,后来Visual Studio IDE整合进去成为.NET framework下的测试工具。

其二,注意fakes的技术原理,中断例程在CLR runtime的虚拟机内对外开放了,虽然是有限范围内的。前面说麦哲伦团队不是没法在操作系统层面利用吗?但是现在.NET的虚拟机里面可以利用了。既然能跳转,那也能侦听。所以麦哲伦团队很快就把代码覆盖的技术迁移到这个中断例程的分派接口内,也就是说,不需要产生新的可执行代码了,更加没有Windows Service和SQL数据库的事了。

整个工作原理大变样了,代码覆盖的逻辑是单元测试控制台vstestconsole.exe所要配接的test adapter,通过runsettings指定:

  1. 通过IDE指定进行code coverage test之后,编译依然进行,但是不会额外产生新可执行代码。
  2. runsettings里面的代码覆盖的test adatper被发动,注册虚拟机的中断例程分派接口。
  3. 单元测试被发动,测试代码和产品代码都被执行,虚拟机的中断例程不断被触发,有些针对代码覆盖的关键指令会被侦听记录下来,这用不着Windows Service和SQL数据库,垒进一个hash table就够了。
  4. 单元测试结束之后,根据PDB,每个侦听下来的指令对应的代码位置都能得到,翻译成覆盖/错失的代码行和累加报告。

这个过程在IDE和自动执行的Azure DevOps里面都是一样的,意味着你在自己的开发机器上和持续集成管道(CI pipeline)上能得到一致的代码覆盖测试结果。而且,前面说的.NET framework fakes也能集成进去。这样你发现有些路径难以覆盖之后,直接用fakes创造条件跑进那些路径,还能在代码覆盖里面体现出来。

C++呢?不好意思,没有虚拟机那还是得原来的做法,只是让IDE和Azure DevOps自动做了,所以单元测试可以,想按以前的做法,还是得自己去呕吐一次。

其三,这两项技术突破直接导致了微软测试文化和流程的重大变化,因为.NET的单元测试的代码覆盖极其容易得到,所以不再要求全面测试的覆盖率,只需要单元测试的覆盖率达到比如70%即可。管理层可以从持续集成管道随时得到最新的代码覆盖测试结果,自然就没有不达标区域的归属和测试代价扯皮的事情。全面测试则需要根据单元测试的错失部分增加用例。

随着.NET Core的出现,代码覆盖没有变化,代码注入却不再内建在CLR工具里面了:.NET fakes只在.NET framework留存,.NET Core里面不再提供了。这里是新的设计模式依赖注入(Dependency Injection)的大规模应用引发的改变。

从.NET Core开始,这个DI模式被全面拥抱,当你从Visual Studio IDE的项目模板新建一个ASP.NET Core web api service的项目时,代码模板就是基于DI的,Startup部分注册各种接口的类实现,web service controller的类在构造函数里面就接受各种接口:

    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        private readonly ILogger<WeatherForecastController> _logger;
        private IMyDataSource myDataSource;

        public WeatherForecastController(
            ILogger<WeatherForecastController> logger,
            IMyDataSource myDataSource)
        {
            _logger = logger;
            _myDataSource = myDataSource;
        }
    }

当你需要单元测试这个controller类的时候,特别是通过IDE自动产生测试项目,通过构造函数传入的接口可以是你自己实现的各种类,只要分别实现相应的接口即可。Moq为实现这些接口提供了强有力的支持。这样一来,根本不需要fakes这样的工具就可以实现代码注入。以前的代码要实现DI还需要考虑把所有依赖改写成接口的工作量,现在全部都是接口了,有啥可担心的?所以,在.NET Core中干脆不再支持fakes了。

事情到这里就比较完美了,再也没有人为了代码覆盖而呕吐了,除非是达到70%有麻烦,不过通常没有人去可怜,这是能力或者态度问题了。

写到这里我特意去查了一下TypeScript Code Coverage tools,看来只有开源的第三方,不知道TypeScript团队有没有这个想法,不管是编译器搞代码修改还是Edge搞中断例程都可行。但我分析不想做的原因是界面做全自动单元测试性价比比较低:触发事件和读DOM tree容易,自动化验证费时费力不讨好,结果就是没人想做,更别说代码覆盖了。所以我认为TypeScript团队不会去做,呵呵。

如果有天整出个Node.TypeScript呢?这踩到ASP.NET Core的地头在Julia Liuson那边就不太好看了吧,于是就没有然后了。

顺便提一句,fakes在另一个意想不到的.NET framework场景也出现了:微软研究院有个牛掰工具,分析了你的.NET framework函数代码之后,自动产生各种输入参数试图全面覆盖你的代码,和从安全角度实施攻击,比如整数溢出和缓冲区溢出等,还会自动产生所有这些输入的测试代码(当然它担心组合爆炸,最多就1000个组合);如果你的函数包含各种其它依赖的函数调用,它大概率会组合爆炸。这个时候fakes出场了,把那些你不关心的依赖用fakes支开之后,这个工具就顺利执行了。这个功能叫做IntelliTest,有兴趣可以试一下喔,.NET framework才可以。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值