TDD实践之实用主义
1. 为沟通选择语言
我们在一个海员管理系统的开发中遇到了问题,这个领域的专业术语我们很难翻译。即使勉强翻译出了,也感觉辞不达意,无论是初看上去,还是过一段时间再看都一头雾水。比如,我们写出了下面的测试用例:
public void test_should_return_NOT_pass_if_duty_higher_than_second_mate_or_second_engineer_and_education_level_is_secondary_and_guraduated_after_2002_02_01() {
……
}
但其中second mate/second engineer
是什么意思呢? secondary的education level具体又是什么?
还有:
public void test_should_return_third_mate_course_for_jianxi_third_mate() {
……
}
jianxi_third_mate
是什么? 等等。
当然,我们可以制定一个术语表,请专业人士先帮我们翻译好,然后在代码中遵循这个术语表。然而随着需求的增加,术语层出不穷,并且有特定中国特色的名词根本就没有对应的翻译,于是这个问题就一直困扰着我们。
而直到有一天,在一次重构中我们把上面的第一个测试用例重命名了一下,一切似乎突然间开朗了:
public void test_应该算未通过_if_职务高于二副二管轮_而_学历只是中专_并且_毕业时间晚于2002年2月1日() {
……
}
Team里的人纷纷围过来,看着这个跟需求描述里的验收条件几乎一模一样的测试用例名称,感受到一种前所未有的清澈。大家几乎在几秒钟之内就做出了选择:这种形式是可以接受的,而且表达能力更强,交流效果不错。
什么?使用中文作为函数名?这似乎只是那些被主流舆论鄙视的"汉语编程"研究者才搞的东西,我们一直就被教育离这些东西远点,甚至汉语拼音都不推荐使用,一个经常拿来做反面教材的例子就是数据库表的列名使用汉语拼音,这被看作不专业的表现。又或者,以后团队中加入外国开发者怎么办?
幸运的是,我们是软件工程师,不是计算机科学家。学术理论可以极端,而工程一定是某种折衷。定理由自然界精确遵守,而工程却是各种应力的人为平衡。
具体到这个案例,让我们正视现实:
- 团队成员并不擅长本项目领域的专业英语。
- 任何翻译都会造成一定的信息损失,尤其在一些具有中国特色的领域,比如"中专"翻译为英语就很难像中文一样简洁直观。
- 在可预见的将来,不会有老外加入开发团队。
而选用中文却能够让我们更好的坚持以下原则:
- 代码除了完成功能, 另外一个重要的功能是交流。(我们选择了对团队来说最有效的交流方式)
- 用测试用例的名字来描述需求。(用中文描述更精确, 易于理解)
当然我们也会失去一些东西,比如对上面提到的"应该坚持使用英文"原则的放弃。在这里我们认为放弃这条原则的收益大于损失。一种损失就是失去了学习英文的机会,比如上面最后一个测试用例,用中文写出来就是:
public void test_见习三副应该参加三副的培训() {
……
}
或者有人会说:"见习"的英语是"intern",常用词啊。然而系统中还有另外一类角色,叫"实习三副"等,那才是"intern"。实习是实际动手,担当实际的职责;见习是只看不练,跟在后面观摩学习。见习的英文单词是"noviciate",并不为项目组所熟悉,而我们也不再关心它。
总之,在实践中应当权衡各种利弊,选择对你来说最有效的方式。
2. 用大量测试来驱动
在项目组中曾经发生过自以为完成了某个特性,后来却发现漏掉了某些验收条件,甚至是比较重要验收条件的事情。而这也是TDD经常被质疑的一点,就是如何保证测试的完备性。因为总是想一点写一点,不经过深入的思考,不可避免的会漏掉某些测试条件。
然而,TDD并不妨碍你深入思考,只是劝你在实现时步子不要太大,小步前进获得对问题的进一步认识以随时调整设计和实现。不过今天不争论TDD的哲学问题,我们只是关注用什么样的实践来消除对于TDD测试完备性的疑虑。
事实上,完全可以根据需求文档,验收条件,进行"深入思考",从一开始就写下所有能想到的单元测试用例,就跟测试人员在产品出来前就对着需求准备测试用例一样。
哦,等等,这还叫 TDD 吗? TDD 所强调的小步前进,随时应变哪里去了?为全面的测试用例所花费的大量努力岂不是有浪费的风险?
嗯,不错,TDD强调小步前进的原因就是要避免浪费。如果我们能找到一种方法,既能够提醒我们不要忘记需求,又让我们在需求变化时不致浪费太多,岂不是皆大欢喜?
想想,我们用什么来描述需求?是测试用例名称,而不是测试用例的函数体,而名称的书写几乎是没有成本的。从需求文档中把验收条件抠出来即可。如:
public void test_会被认为不服从调配_if_the_seaman_在当前职位上曾经旷工() {
// TODO
}
public void test_会被认为不服从调配_if_the_seaman_在当前职位上曾经请过病假() {
// TODO
}
public void test_会被认为不服从调配_if_the_seaman_在当前职位上曾经请过事假() {
// TODO
}
public void test_会被认为不服从调配_if_the_seaman_在当前职位上曾经被遣返() {
// TODO
}
public void test_不会被认为不服从调配_if_the_seaman_在当前职位上从未旷工_请病假_请事假_和遣返() {
// TODO
}
两分钟,我们就把这个用户故事的测试用例按照验收条件里说的全部描述出来了。函数体全部都是空的,因此所有的测试都是通过的,不会强迫你一次性把所有的测试都实现。
每次你流览或修改这段代码,空的函数体或里面的 // TODO
都会提醒你还有测试没有完成。即使你不在这个特性上工作了,切换过来的Pair也会从你遗留的测试用例名称中迅速的了解需要做什么。
这种方法是怎么解决问题的呢?
第一,还是让我们正视现实:如果需求描述不和代码放在一起,开发人员很少会在开发过程中去翻阅需求文档,甚至是特性编码结束后。这在成熟的开发团队中会有改善,但仍然不可避免。把需求描述以测试用例名称的方式放进代码,便会无时无刻不在提醒开发者,还有这个这个这个验收条件没满足。
第二,我们依然坚持了以下原则:
- 用测试用例的名字来描述需求。
- 小步前进,编写一个测试用例,实现一段产品代码,编写下一个测试用例,实现下一段产品代码。(因为所有的未完成测试都是通过的,不妨碍你运行测试,提交代码和持续集成)
- 当实现过程中发现事情并不是当初想的那样时,随时更改或删除之前写的测试用例,不会造成大的浪费。(因为只是函数名加空的函数体,成本很低)
在开始写下"所有"的测试用例名称并不意味着一劳永逸。中间当需求发生变化,我们需要对应的添加或删除一部分测试用例。在实现的过程中,发现某些条件不在验收范围内,或许是之前没考虑到,那么跟BA/QA确认后,需要添加到用例列表中。
(细心的读者可能发现,这里面存在着重复。就是以普通文本形式存在的验收条件和以测试用例名称存在的验收条件。或许应该有类似SVN2Wiki的工具,来消除这类重复)
当然,完备性还有其它的含义和检测条件,不是说你提前多写几个用例就是完备了。这里只是用一种成本最低的方式来解决问题。
3. 一个环境,多个断言
随着时间的推移,项目组发现测试运行的越来越慢。当然,这是一个普遍现象,也已经有很多方法来加速测试的运行速度。但很多需要新工具的支持,而项目组暂时没时间去切换工具。有没有其它更方便的做法?
像其它性能问题一样,我们首先需要确定瓶颈在哪里。我们发现主要是每个测试用例运行前搭建测试所需的环境相对较为耗时,尤其是Selenium测试,它需要启动和关闭浏览器。并且,很多测试用例其实使用相同的环境设置,只是每个用例仅仅去断言其中一个需求。
我们可以修改Runner,让一组测试使用同一个浏览器实例,每次环境的清理通过清理Session来完成。而我们也可以采取另外一种方法,就是合并使用相同环境设置的测试用例,把它们的断言都放进同一个用例。
哦,这又违反了Kent Beck 为TDD制定的原则:每个测试用例最好只有一个断言。
好,让我们再一次分析原则背后的理念。一个用例一个断言,是为了让测试更清晰,更精确的描述需求,测试失败更容易定位。那么有没有一种方法,既能让多个断言共享相同的环境设置,又能清晰精确的描述需求呢?
想想,我们用什么来描述需求?是测试用例的名称,确切的说,是函数的名称。只要我们把一组组相关的断言封装到一个个函数里,给它们一个能够清晰精确的描述这组断言对应的需求的名称,然后在测试用例里面调用这些函数就可以了。这样我们只需为多个断言设置一次环境,而同时又保留了清晰精确的表达需求的能力。
@Test
public void test_should_show_step_details_info_in_todo_item_page() throws Exception {
TodoItemPage page = navigator.gotoTodoItemPage( );
should_show_step_name_as_page_title(activeStepOfNonStartedInstance, page);
should_show_start_processing_button_if_current_step_status_is_waiting(page);
should_show_transition_buttons(activeStepOfNonStartedInstance, page);
should_NOT_ask_user_to_input_his_opinion_if_current_step_status_is_NOT_processing(page);
should_show_comment_box_after_click_start_process(page);
}
private void should_show_step_name_as_page_title(FlowStep step, TodoItemPage page) {
assertEquals(step.getName(), page.title());
}
private void should_show_start_processing_button_if_current_step_status_is_waiting(TodoItemPage page) {
assertTrue(page.isStartProcessingButtonVisible());
}
private void should_show_comment_box_after_click_start_process(TodoItemPage page) {
page。clickStartProcessingButton();
assertTrue(page.isCommentBoxAppear());
}
private void should_ask_user_to_input_his_opinion_if_current_step_status_is_processing(TodoItemPage page) {
assertTrue(page.isCommentBoxVisible());
assertTrue(page.isActionButtonsVisible());
}
private void should_ask_user_to_select_next_step_operators(FlowTransitionDefinition nextTransitonOfStep,
TodoItemPage page) {
assertTrue(page。isUserGroupVisible(nextTransitonOfStep.getId()));
}
……
小结
回过头来我们看看上面的三个实践,它们如出一辙的,一次又一次的"违反"了某种原则。它们分别是"不能用汉语","不能一次编写多个测试用例",和"不能在一个用例里面使用多组断言",而实际上,我们违反的只是这些原则的外在形式,但却坚持了这些原则背后的思想,如最有效的沟通,注重实效而不是形式。以此为基石,我们可以在出现新的约束的情况下,灵活运用,发明各种实践,并享受由此带来的效率提升。