“千般路”与“磨豆腐”
很久以前听一个故事:从前有个小伙子,少时有大志,长大后却无好营生,开了个豆腐作坊,每天磨豆腐累得腰酸背疼。每到夜深人静,小伙子辗转反侧,总想找条更好的“事业之路”,可是想过千百条、尝试过几十条路,都走不通。夜不成寝,白天干活更累,小伙子不由慨叹:“晚上想过千般路,白天还得磨豆腐”。
不久以前看过一篇文章:《CMM欺骗了中国的软件业》,内容是对CMM热的反思。CMM当然不会主动欺骗人,实际上是我们的软件业自己欺骗自己。我们从来不缺少“某某模式”,“面向某某”,“某某认证”等等听起来美妙无比的东西,问题是实际的研发过程中能做得到码?现实是残酷的,美妙的概念漫天飞舞,开发过程仍然是作坊式的,正是:“晚上想过千般路,白天还得磨豆腐”。
中国的故事通常都有圆满的结局,现在接着说“磨豆腐”的故事。过了很长时间,小伙子终于面对现实,不再沉迷于不切实际的空想,用心磨好豆腐,闲时琢磨些个窍门,慢慢地,他的豆腐质量越来越好,每天产量也越来越多,作坊越开越大,成了远近闻名的“豆腐老板”,后来,他做起了别的生意,发现年轻时的空想,其实很多都是可行的,因为现在“能力”和“财力”都不同了。
再说软件开发。我们不反对任何理论、技术、方法、模式等等,但第一,您的企业或团队做得到吗?不要做“如果开发时间延长一倍,就可以做到”之类毫无意义的假设。第二,做了真的有效益吗?效益是指扣除成本之后的收益。如果不具备这两点,那么还是不要整天想着“千般路”,首先想想如何好好的“磨豆腐”吧。
对于所有软件开发来说,代码编写都是无可逃避的“磨豆腐”。改进代码编写工作,高率效低成本地开发出高质量的代码,对于软件产品能否在激烈的竞争中胜出,对于软件企业的生存和发展,都具有重要的现实意义。
本文是Visual Unit应用的范例项目C++代码文档生成器的主题文档,叙述的正是改进代码编写工作的方法和工具,所有内容均经过实战检验,具有"可行"和"效益"两个特征,"可行"是指较低门槛或没有门槛,凭现有条件即可实施;"效益"是指能产生立竿见影的效果。
本文所援引的范例项目,模拟最糟糕的开发团队,最混乱的开发流程:由很少写代码的测试和预研部门开发,人员不固定,时间也不固定,谁有空就写上一些;没有设计,没有文档,基至也不在代码文件中保存编码人员的信息,成员完全依赖于阅读代码和测试用例来理解其他成员写的代码;除了简单的命名规则外,没有其他规范,甚至连一个函数原则上不能超过50行之类的基本规范也没有(范例中有超过200行的函数CMacro::Unwind(),一万多条路径)。
任何开发团队和开发流程都会好于范例项目的开发团队和开发流程,因此,范例所展示的方法和工具,具有"广泛可行性"。
本文介绍如何进行高效编码调试和实现彻底的单元测试。编码调试是任何软件开发都无可逃避的工作,在Visual
Unit的支持下编码调试,只是把本来就一定要做的工作改变一下方式,不需要多做什么,就可以大幅提高编程效率和质量;另一方面,Visual
Unit彻底改变了单元测试难于实施或成本昂贵的局面,无论团队中开发与测试人员的比例是怎么样的,都可以轻松快捷地实现彻底的单元测试。
高效编码调试
任何软件开发,都离不开编码调试。对于稍为复杂一点的函数,一般来说,编写几行代码,就要执行一下,看它们是否按预想的工作,然后再继续写,写完后还要将各种可能输入都执行一下。如何执行?一般由别的代码来调用,也就是说需要驱动,驱动通常是在开始编写函数实现代码之前建立,这样才能一边编写一边调试。驱动大致可分为自然驱动和专门驱动。
自然驱动:利用项目中已有的代码作为驱动,通常是在被调试的函数中加断点,从界面执行一个需要调用该函数的功能,调试器中断时就可以调试了;专门驱动:为需要调试的函数编写专门的驱动代码,通过执行驱动代码来执行被调试函数。
自然驱动的主要优点是不需要其他工作就可以直接调试,甚至感觉不到需要驱动,主要缺点是输入数据通常是公共的,即很多代码都使用相同的输入源进行调试,实际输入往往是经过其他代码处理后的中间结果,要针对各种可能输入都进行调试往往很困难,造成调试不全面,程序员的思维受到局限,难于做到全面地考虑各种可能输入。
专门驱动的主要优点是输入数据是专门针对于被测试程序,容易做到比较全面,程序员的思维也会比较全面,对编写功能齐全的健壮的程序很有好处,要针对某种特定输入进行调试比较容易,缺点是需要花费大量的时间来编写驱动代码。
显然,自然驱动的主要问题是不全面,代码错误较多,专门驱动的主要问题是编写驱动代码很费时。有没有更好的方法,既不需要编写驱动代码,又能方便且全面地调试?有
!这就是自动驱动,即在Visual Unit的支持下编码调试,不但无需费时间写驱动代码,更拥有多种独特的便利,可以大幅提高编码调试的质量和效率。
Visual Unit是单元测试工具,但也是高效编程调试的支持环境,在Visual Unit的支持下调试,既全面又省时:
- 自动生成驱动代码,但又可以方便地设定调试输入;
- 测试用例编辑器列出全部输入,可以很方便地检查是否全面。
- 除了上述优点外,在Visual Unit的支持下调试,还可以:
- 可视化地选择调试输入;
- 调试过程中还可以切换输入;
- 无限制的后退,重复。
上述仅是免费的个人版的功能,对于企业版用户,实际上大多数单步调试都可以省略:
- 自动输出参数、成员变量的输入输出值,返回值,用户也可以用简单的语法输出任何变量或表达式的值,这些数值都是上下文相关的;
- 显示在一个用例下,程序所执行的代码,可以很方便地查看程序是否按预想的流程执行。
- 程序无论多复杂,无非就是执行一些代码,读写、计算一些数据,因此,上述两方面信息已完整地描述了程序行为,一眼就能看出程序干了什么,通常可以很快判断程序是否按预想的工作并找到出错原因,比单步调试要快得多。
下面以实例来进一步分析三种调试方式的优缺点。这里所用的示例是范例项目中的CExFunction::ParseOneParameter()函数,这是一个很普通的函数,读者也可以随便拿其他有些复杂度的代码来比较。该函数的功能是解析C++代码中的一个参数,原形如下:
PARAMETER* CExFunction::ParseOneParameter(CTokenList&
iList);
PARAMETER 是保存一个参数对象的结构,定义如下: struct
PARAMETER
{
CString type; //参数类型
CString name; //参数名
CString defVal; //缺省值
CString array; //如果参数是数组,保存[]及[]内的文字常量
}; |
参数iList是一个输入参数(范例的命名规则是用i表示输入参数),传递由C++代码中的一个参数经过词法分析转换获得的记号对象序列,例如参数int*
pi,将转换为三个记号对象,分别对应于:int, *, pi。该函数将记号对象序列解析到一个PARAMETER结构的指针中,并作为返回值返回。
在这个示例中,如果要进行比较全面的调试,输入至少要考虑以下可能:
- 普通输入,如int i;
- 类型中有符号,如int* pi;
- 类型中有多个符号,如int*& pi;
- 模板类,如CList<int, int> list;
- 带缺省值,如int i=0;
- 数组,如int ai[10];
- 类型有多个单词,如const unsigned int& i;
- 缺少参数名,如const int;
我们在编写这个函数的实现代码前,首先建立调试驱动,以例边编码边执行调试。
自然驱动
假设界面和要调用这个函数的其他代码都已完成。在函数的入口处插入断点,以调试方式运行工程,在界面中选择要生成文档的工程目录,点击"生成文档",程序中断时就可以调试了。这种方式相信所有程序员都很熟悉,并且很多人都会认为这种方式最省时,但实际上,这种方式只是开头省时,当你试图把各个可能输入都调试一遍,就会发现它很费时:可能输入通常分散分布于输入源中(这里的输入源是代码文件),如果要比较全面地调试,通常要整理输入源,否则几乎不可能全面地调试,也就是说,要全面地调试,仍然要费时间整理输入,并不能完全依赖自然输入;
要针对某种输入进行调试,例如要调试参数带有缺省值的情形,一般通过反复跟踪直到想要的输入出现,或者设置条件断点拦截所需要输入,反复跟踪当然费时不少,设置条件断点也是要花时间的,并且有时无法满足需要
,很多时候,要针对特殊输入进行调试都是很大费时的;
由于是公共输入源,输入数据很难管理,尤其是条件断点更不可能无限期地保存,以后需要再次调试时可能要做很多重复工作。
如上所述,自然驱动并不省时,不过这种方式的时间消耗隐藏在调试过程中,通常不会引起重视,其实"隐藏于调试过程中",其成本更大,因为分散了开发人员的注意力,影响思维的连续性。
自然驱动的更主要问题是不全面,开发人员常常会将思维局限于现有的输入源,导致一些可能输入根本就没有考虑到,在本例中,很可能只是试一下解析一两个文件,检查得到的结果是否正确,如果文件中没有
带数组的参数,那这种输入很可能就被忽略掉。这种不全面,导致代码功能不齐,健壮性差,后期测试和维护成本居高不下,甚至导致项目的失败,因此,这是看起来高效,实际上很低效的方式,读者可以在看完后面两种方式的介绍后,自
已尝试并对比一下,可以拿任何有一定复杂度的代码编写来对比,不局限于这里所举的例子。
专门驱动
专门驱动离单元测试只有一步之遥了,只要在驱动代码中添加判断预期输出的语句就构成了完整的测试代码,因此,在实际工作中,采用专门驱动最好是边编码边测试,并使用测试代码作为调试驱动。下面是为函数CExFunction::ParseOneParameter
()编写的调试驱动代码:
{CExFunction* pObj = new
CExFunction();
CTokenList iList;
CTokenReader reader;
reader.ReadTokenList(iList, "int i");
PARAMETER* ret = pObj->ParseOneParameter(iList) ;
ASSERT( ret->type == "int" );
ASSERT( ret->name == "i" );
reader.ClearTokenList(iList);
delete pParam;
delete pObj;} |
上面的代码其实是一个测试用例的完整代码,测试代码通常都是很简单的,功能无非是使被测试的代码得于执行,被测试代码通常都涉及到外部数据,如参数、成员变量、全局变量什么的,这些数据当然要设定初始值,例如,上面的测试用例是将字符串"int
i"经过CTokenReader对象的ReadTokenList方法转换成CToken对象指针的列表作为参数iList的输入。
在实际工作中,函数的输入输出常常不是简单的数据类型,而是某些对象甚至是对象的集合,本例中,输入的数据就是CToken对象
指针的列表。这种情况下, 一般借助现有的代码来生成数据,通常,这些"现在代码"都是存在的,因为即使不做测试,也总有代码要调用该函数的,调用代码本来就需要生成相应的数据。本例中,CTokenReader::ReadTokenList()函数就是把字符串转换为CToken对象指针的列表。
只要写完了第一个测试用例的代码,更多的用例就简单了,只要拷贝并对输入输出数据进行修改就行。细心的读都可能已注意到,第一个测试用例的前后加了{},这是为了多个测试用例可以使用相同的变量名。
使用这种方法,建立测试代码通常是很快的。编写很简单的函数时不需要调试,当然也不需要测试代码。测试代码的组织也很简单:一个产品工程对应一个测试工程,一个产品类对应一个测试类,一个需要测试的产品函数对应一个测试函数。测试工程可以加一个简单的界面,以便执行指定的测试,也可以使用相应的工具如CppUnit。
再回到我们的主题:调试。有了测试代码,调试就简单了,要调试某种输入,只要在相应的测试用例中加断点就行了。使用这种方式,仅就调试来说,好处也是非常明显的:
- 所有输入在一起列出来,调试比较全面,程序员的思维也会比较全面;
- 要调试指定的输入很容易,通常不需要高级断点,更不需要通过反复跟踪来捕捉需要的输入;
- 调试数据可以永久保存,避免了以后修改代码时的重复工作。
自动驱动
在Visual Unit的支持下编码调试,除了兼具自然驱动和专门驱动的优点外,还能享受Visual
Unit的独特殊功能带来的便利。
首先我们用个人版来说明,个人版是免费的版本,并且开发商也提供免费的基本技术支持。
Visual Unit具有丰富的文档,包括视频教程,这里不再叙述其基本使用方法。只要选择要测试(调试)的类和函数(如果使用企业版的IDE插件,会根据当前文档和光标位置自动选择),VU就会生成测试代码,并弹出测试用例编译器。VU是自动生成测试代码,而不是自动生成测试用例,也就是说,输入输出数据是由用户指定的,不过VU已经生成了输入输出数据的声明。下面是本例中VU生成的第一个测试用例的输入输出:
输入部分:
CTokenList iList =
输出部分:
ret == |
这里的“=”和“==”仅仅表示可能需要赋值或判断输出,对于基本数据类型,可以直接填写数值,高级数据类型需要灵活处理(详细信息请查看帮助或教程)。只要把输入输出改为这样就完成了第一个测试用例的建立:
输入部分:
CTokenList iList = //多余的=会自动删除
CTokenReader reader
reader.ReadTokenList(iList, "int i") |
输出部分:
ret->type == "int"
ret->name == "i" |
更多的测试用例,只要点击"新建",就会自动拷贝当前用例,只要修改输入输出就行了。这里没有涉及到成员变量和变局全量,不过都很简单的(成员变量用点操作符访问,全局变量直接访问),请查看帮助。
可以看出,使用VU建立调试支持环境是很快速的:对于第一个测试用例,输入输出比较复杂时需要写少量简单的代码,输入输出简单时直接填写输入输出数值,其他测试用例只需点击一个按钮拷贝现有测试用例并修改输入输出就行,可以选择相近的用例来生成新的用例,这样通常只需要修改一两个数据就可以得到想要的用例。
那么,还可以得到哪些好处呢?
- 方便地进入调式:在被调试函数的入口加断点,并调试测试工程即可进入调试;
- 方便地选择输入:在测试用例编辑器中轻点鼠标即可指定要调试的输入,如果执行了测试,只要点击出错的测试,就会自动选择相应的输入;
- 方便地切换输入:调试过程中,不需要退出调试,就可以切换到其他输入:只要在测试用例编辑器中选择另一个测试用例,用调试器的"执行到光标所在行"命令回到函数入口,即可切换到新的输入;
无限制后退重复:用调试器的"执行到光标所在行"命令可以自由地后退和重复执行,其实现的原理是"重来",后退时相关数据也会"还原",感觉上是真正的"后退",这个奇特而有用的功能是VU生成的测试代码自动实现的。
上述是免费的个人版所具有功能,VU企业版除了具有这些功能外,还具有"描述程序行为"的功能:
自动输出参数、成员变量的输入输出值,返回值,用户也可以用简单的语法输出任何变量或表达式的值。这些数值都是上下文相关的,也就是说,同一个用例的相关值放在一起;
显示在任一个用例下程序所执行的代码,可以很方便地查看程序是否按预想的流程执行。
程序无论多复杂,无非就是执行一些代码,读写、计算一些数据,因此,上述两方面信息已完整地描述了程序行为,很容易看出"程序干了什么"。这个功能大幅度地提高了开发人员的工作效率:
帮助整理、验证编程思路:写几行代码,就可以看看"程序干了什么",轻松判断"现在所写的对不对",也比较容易想清楚"接下来要怎么写"。
快速找出程序错误:根据输入输出数据和所执行的代码,通常可以很快判断程序是否按预期工作并找到出错原因,比单步调试要快得多。
编程的时间消耗主要不在于敲键盘,而在于编程思路和调试,VU企业版可谓"对症下药",在这两方面大量提高工作效率。
以上所述,都是针对软件开发过程中无可逃避的工作:编码调试。仅从时间上来说,对于编写调试有一定复杂度的程序,使用专门驱动,可能比自然驱动多费一点时间,但也是完全合算的,至于自动驱动,如果使用VU个人版,大概能省时10%,如果使用企业版,则可以省时20-50%!读者可以自行尝试比较一下。是否省时还不是最重要的,更高的价值在于:使用专门驱动或自动驱动编码调试,实际上也已经把令人望而生畏的单元测试工作完成了一半,并清除了实施单元测试的最主要障碍。
实现彻底单元测试
是什么使单元测试难于实施?首先是代码的可测性。可测性是什么?如果一个类具有基本的可测性,那么把它加入到另一个工程后(当然有关联的文件也要加入)能够通过编译,这其实是很低的要求,但对于一个有一定规模的项目,如果开发调试时使用自然驱动,在完成编码后才进行单元测试,那么通常都不具有可测性,因为开发人员常常在无意之中使代码之间产生了不当耦合,这些不当耦合累积起来,会使整个项目的代码纠缠在一起,造成难于测试。
使单元测试难于实施的另一个方面是建立测试用例。在本例中,如果由不熟悉代码的测试人员建立测试用例,那么他很可能不知道如何生成CToken对象
指针的列表。
如果边开发边使用专门驱动(测试代码放在另一个工程中)或自动驱动调试,那么一旦出现影响可测性的不当耦合就会及时发现及时解决,保证了代码的可测性,另一方面,由于至少建立了一个测试用例,测试人员建立其他用例时只要修改一下输入输出数据,从而大大降低建立测试用例的难度。总之,使用专门驱动或自动驱动调试,在不增加开发工作量的同时,已经为单元测试打一下了坚实的基础。
范例项目V0.1处于这样一个阶段:刚刚完成代码编写,并未完成单元测试,现有的多数测试用例都是编码时用于调试的。在此基础上,单元测试由谁做,难度都不大。VU的典型应用是通过三个
阶段来完成针对一个函数的彻底的测试:
1)基本功能测试:测试代码的基本功能;
2)完成白盒覆盖:在现有用例的基础上,使用测试用例设计器为未覆盖的语句、条件、分支、路径设计测试用例,达到100%语句、条件、分支、路径覆盖;
3)执行自动边界测试捕捉意料之外的错误。
以上三阶段可以由不同人员在不同时间完成,团队可以根据实际情况做出灵活安排,下面是一个典型的开发测试流程。
1)开发人员边开发边使用VU调试测试,完成基本功能测试。在VU的支持下开发调试,可以大幅提高开发效率,绝不会影响开发进度。也许读者会认为,开发人员没有时间去设计测试用例,其实这是一种误解,开发人员写代码时肯定要想清楚代码的功能并且要使用基本的输入进行调试,这些就是基本的测试用例,实际上不需要多做什么。开发人员提交代码同时提交测试代码和测试报告。如果项目已完成或部分完成编码,也建议先由程序员首先对重要代码进行基本的功能测试。
2)测试人员检查基本测试用例是否符合设计,并在此基础上完成白盒覆盖和边界测试。由于有了初步的测试,保证了代码可测性,不可能产生因为不当耦合造成难于测试的状况;在现有用例的基础上,使用测试用例设计器找出遗漏用例也不会有太大障碍,这就使测试人员的工作易于进行。测试人员只需要提交更新过的测试代码和测试报告,不需要另外记录测出的问题。
3)开发人员下载新的测试代码和测试报告,执行整体测试,然后针对报告了错误的函数执行函数测试以获取详细信息,必要时进行调试,找出错误,修改代码,使所有测试通过,再次提交产品代码和测试报告。
4)测试人员再次执行整体测试,验证所有的测试均已通过。
以上流程是动态和反复的,并不是编码完成后再单元测试。开发人员写完一个类后即可提交代码由测试人员完成白盒覆盖和边界测试。另外,团队可以根据开发与测试人员的比例调整工作份额,如果团队没有测试人员,那么由开发人员完成全部单元测试也是可行的。
彻底的单元测试对于软件开发来说,其价值是难于估量的:除了保证局部代码的质量外,有了单元测试,任何时候修改代码后都可以通过回归测试来自动检查修改是否引入错误,这就使开发过程可以适应频繁变化的需求,系统分析、概要设计和详细设计都可以做得简单一些,也更能适应螺旋式开发过程,以后的维护升级成本也会大幅降低,同时,高质量的代码使集成测试和系统测试的工作量降低很多(实际上单元测试已包含了大部分的集成测试),发现问题后的修改也会高效得多。总之,要提高软件开发质量、降低软件开发成本,最有效的改进就是进行彻底的单元测试,如果不进行单元测试,任何流程改进都无法保证产品质量,因为,程序始终是由代码构成的,代码的质量没有保证,软件的质量拿什么来保证?单元测试并不排斥其他过程改进,相反,单元测试对开发流程中的所有环节几乎都有促进作用。
下边再谈谈关于白盒覆盖的话题。使用VU实施单元测试,100%的语句、条件、分支覆盖通常都是很容易的,路径覆盖有时候会很难,例如,我们所举的例了,CExFunction::ParseOneParameter
(),有九十多条路径,要覆盖似乎不现实或没必要,这种状况通常是设计不合理造成的,例如,CExFunction::ParseOneParameter
()函数的代码分为三块:1、解析缺省值,2、解析数组,3、解析类型和参数名,前两块解析了缺省值和数组后把相应的Token从列表中删除,这样的话,第3块与前两块是没有逻辑关系的,但是,这3块代码会组合出很多路径,没有逻辑关系的代码所组合出来的路径是没有意义的,这些代码具有"高耦合低内聚"的特征,不应该放在一个函数中。范例中另外写了一个函数:CExFunction::ParseOneParameter2(),把以上三块代码分别独立出来自成一个函数,这样
每个函数都能完成100%的路径覆盖,重构后的代码既易于测试,也易于维护。范例中有大量类似的函数,甚至有超过200行的函数,这是为了检验VU的适应能力,以后的版本是会进行重构。我们建议程序员完成编码后,检查一下路径数量,如果路径很多,代码很可能需要分拆,合理的路径数量应该是等价类数量的一至两倍。另外,从逻辑结构图也可以看出来:图中有两个或两个以上串联的复杂分支结构,往往表示代码的结构有问题。
VU的逻辑结构图具有屏蔽对象的功能,因此,遇到上述情形时可以通过交替屏蔽部分分支结构的方式来实现路径覆盖,但这不是我们推荐的方式,因为它虽然可以保证测试的完整性,但并没有改进代码的结构。
关于单元测试和范例项目,还有很多值得叙述的话题,例如内存泄漏测试以及一些复杂问题的处理等等,这里暂且不谈。最后谈谈已完成编码的项目的单元测试。对于已完成编码的项目,最好先由开发人员"去耦合",方法是将代码文件从底层向上排列,按顺序依次将文件加入到另一个工程并编译,如果产生编译错误,则想办法消除编译错误
,VU提供了文件排序工具,具体的使用方法请查阅帮助中《测试旧工程》部分。 完成“去耦合”后,由开发人员对自己编写的代码完成基本功能测试,测试人员完成白盒覆盖和边界测试。对于编码过程中使用自然驱动调试的已完成编写的代码,完全由测试部门进行单元测试通常是很难的。
|