高效开发与彻底测试
一、“千般路”与“磨豆腐”
很久以前听一个故事:从前有个小伙子,少时有大志,长大后却无好营生,开了个豆腐作坊,每天磨豆腐累得腰酸背疼。每到夜深人静,小伙子辗转反侧,总想找条更好的“事业之路”,可是想过千百条、尝试过几十条路,都走不通。夜不成寝,白天干活更累,小伙子不由慨叹:“晚上想过千般路,白天还得磨豆腐”。
不久以前看过一篇文章:《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生成的测试代码自动实现的。