如何无思考地编码?






4.98/5 (15投票s)
在测试驱动开发中使用窄焦距策略
引言
在观察不同的团队和个人开发人员未能建立测试驱动开发流程时,我遵循了一个TDD配方,这个配方在过去几年里一直对我很有用。在本文中,我概述了TDD无效(当它无效时)的可能原因,并提出了一个逐步的算法,该算法使我将TDD作为一种自然的软件开发方法来使用。
如何阅读?
从左到右,从上到下。为了更轻松地阅读本文,可以跳过“特性测试驱动实现”部分。它详细阐述了TDD风格中处理变更请求或后期改进的流程。这可能对某些读者有用,但跳过它并不会破坏其余内容的一致性。至少在我看来是这样。
什么是精力吞噬者?
我曾听奥地利SCRUM大师Andreas Wintersteiger说过:“你一周内编写的所有有用且高效的代码,都可以在周五下午写出来。”也许他甚至没有说“高效”,我不记得了。
难以反驳的是,如果你只保留有用的代码,你会发现敲打代码所花费的精力,与思考这段代码应该做什么(即行为)、如何实现它(例如,如何组合LINQ表达式),以及为什么它没有按预期工作(调试)相比,微不足道。打字工作量与其余工作量之间的差距绝不会是一个两位数的因子。
我们思考的时间比打字的时间多得多。重点是,我们同时思考着非常不同的事情:代码行为、实现细节、副作用……这就是为什么它需要更长时间的原因。而且,输出效果也不尽如人意:我们很可能会遗漏一些东西。这样一来,我们就会产生更多的bug,需要进行更多的重构,这又让我们回到了同一个循环,距离截止日期所剩时间更少,因此也就没有时间停下来改进流程。
所以,如果我们没有详细设计就开始编码,那么我们的思维效率会很低,因为上下文切换过于频繁。另一方面,我们也不会把每个public
方法都放到时序图中,对吧?
更糟糕的是,随着行为复杂性的增长,我们的思维往往会变得混乱。我们会在生产代码的不同部分和不同的行为案例之间来回跳跃,每一次都会降低我们的效率,并产生新的潜在设计和代码问题。这似乎具有累积效应。确实如此。
窄聚焦策略
但是我们如何控制我们的思想呢?当我思考“是什么?”的时候,我如何才能禁止自己思考“如何?”呢?
这是一种技能,可以也应该加以训练。
但也有一个方法。我可以把“如何?”的事情放到另一个房间,甚至另一个楼层。我可以在时间和空间上将代码行为的思考和实现工作分离开来,以至于根本不可能将它们混淆。
对我来说是这样运作的。
阅读用户故事,并以测试方法名称的形式描述单元测试和集成测试(是的,就像书上说的那样),例如:
public void Ctor_Initializes_EmployeeName_WithPassedParameter()
{
Assert.Inconclusive();
}
根据用户故事,写下所有你能想到的测试用例。只管写下来,一天、两天,甚至更多。坚持下去。不写任何测试代码,更不写生产代码,只要用户故事范围内存在你还能想到的未涵盖的行为案例。
是的,你将连续参加多个每日站会,并说“昨天,我写了测试定义。今天将继续进行。没有障碍。”祝你玩得开心,不客气。
是的,这确实需要一些信心。
除了同事的尊重,你还能获得什么?
收益是您思考代码行为的效率。您的思维不会分散到不同的事物上,因为您根本就没有在处理它们。您只专注于(窄聚焦于)行为,从而最大程度地避免遗漏任何东西,而这些东西在后期,当您认为(并报告!)您“几乎完成”时,将很难实现。
设计阶段:专注于测试骨架
好吧,你可以把它好好包装一下。我的意思是,为了每日站会。
这是你的设计阶段.
一方面,这是你的设计。另一方面,你正在为未来的自动化测试编写占位符。然后你将被迫要么实现并通过所有测试,要么删除一些测试,从而明确取消相关的行为案例。因此,在彻底的分析和设计结束时,你将拥有一套完整的测试套件,可以自动地……好吧,没必要讨论一套完整的自动化测试套件有多好。
你唯一无法客观验证的,是你的测试套件的完整性。你未来能够毫无问题和已知 bug 地完成这个故事的幸福,正建立在行为描述准确性这个不稳定的基础之上。
好消息是之后你不需要那么多精力集中。在测试定义/设计阶段尽力而为之后,你可以几乎不假思索地编码。少思考意味着少混乱。
请注意,以其通常形式的设计,例如使用UML,我们称之为老式设计,并没有达到同样的效果。老式设计不是生成一个未来测试套件的测试骨架,它完全定义了你的下一步行动,而是产生了一些UML图,你很可能不会把它们放在代码文档中,因为你的代码很可能会与你在Visio中草草画下的内容大相径庭。老式设计并不那么“敏捷”。(耶,我知道我能把它放在某个地方!)
用户故事示例
我们来看看它在真实用户故事中是如何运作的。
假设你有一个员工团队,你派他们出差,进行本地网络安装、安全硬件维护、品酒、拯救世界,等等。在这个用户故事中,他们乘坐乘用车出行。
为了正确的成本核算和报酬,作为部门负责人的用户希望在会计软件中有一个功能,他们可以指定团队驾驶过的车辆,将团队成员与相关车辆关联起来,并指定他们的角色,即司机或乘客。
用户避免出现诸如同一司机关联多辆车、司机多于车辆、同一乘客在不同车辆等错误,这很重要,原因有几点。确实,知道团队成员中谁将支付超速罚单会很棒。
应用程序的图形布局由一个两窗格控件组成,左侧部分是所谓的汉堡菜单,用于切换右侧窗格的内容。用户故事指定应该在汉堡菜单中添加一个自己的按钮,用于切换到车辆/团队管理功能。
产品负责人没有详细说明这个故事,因为他们喜欢对生产代码和数据库模式进行未经通知的更改,所以他们没有太多时间编写详细的验收标准。这就是你工作的现实。用户故事现在归你了。
“用户希望拥有一个功能……”
用户故事规范一方面,应用程序的现有框架另一方面,意味着新功能视图模型应该添加到主视图模型的子视图模型列表中。这会自动导致左侧窗格中出现新的菜单选项。因此,第一个测试如下:
[TestClass]
public class MainViewModelsTests
{
[TestMethod]
public void Ctor_Adds_ManageVehiclesViewModel_To_SubPages()
{
Assert.Inconclusive();//don't forget to implement me
}
/*
some other tests from previous user stories
*/
}
如果新的视图模型在列表中,并且其视图在主窗口的XAML中被指定为相关的数据模板,则我们应用程序框架的(经过测试的)功能可以确保用户可以访问新功能。然而,XAML内容并不是我们进行单元测试的东西。
看来我们忘记了在新视图模型中,我们需要团队成员列表来分配给车辆。是的,我们确实忘记了。让我们继续。
“……他们可以在其中指定团队驾驶过的车辆……”
所以,ManageVehiclesViewModel
最初(至少在这个用例中)有一个空的车辆列表,提供添加和删除车辆的可能性,让外界知道这一切正在发生(*),并且有一个验证功能,该功能对保存操作有影响。啊,还有一个保存命令!
(*) 相关属性可以是 IEnumerable<Vehicle>
类型。如果其背后的字段是 ObservableCollection
或 BindingList
,WPF 会检查它。不确定 Xamarin.Forms。如果背后的字段是 List 或数组,它应该改变引用并引发属性更改事件,否则绑定将不起作用(仅属性更改事件不足以)。后者似乎是最通用的选项,即它肯定适用于 WPF 和 Xamarin.Forms。为了简洁起见,我们将使用 BindingList
。
所以,用户应该能够添加或删除车辆。为此,我们需要在 ManageVehiclesViewModel
中有一个命令和一个可观察集合,该命令在合理时添加车辆,并在不合理时禁用。添加的车辆视图模型应该有一个“删除我”命令,并且应该有一种方式将此意图传达给 ManageVehiclesViewModel
(在这种情况下,我总是使用命令-事件对,以利于隔离单元测试)。我们只为“……他们可以指定团队驾驶过的车辆……”添加了十几个测试。看来我们有足够的工作要做,而无需责怪产品负责人用户故事定义不清。
public void Ctor_Initializes_Vehicles_With_EmptyBindingList()...
public void Ctor_Initializes_AddVehicleCommand_With_CanExecute_True()...
public void AddVehicleCommand_Adds_VehicleViewModel_ToVehicles()...
public void On_VehicleViewModel_RemoveMeEvent_RemovesSender_FromVehicles()...
很快,我们就会发现我们缺少队友集合。例如,当我们意识到我们不能无限数量地添加车辆,反正不能超过尚未分配的队友。
什么?!是的,另一个集合,未分配队友的集合,它在构造函数中从传入的队友列表初始化,当你将其中一些添加为车辆的司机或乘客,或删除包含某些乘客的整辆车时,它会发生变化,这反过来又会改变添加车辆命令和保存命令的 can-execute 状态,而 can-execute 状态的改变,命令会引发 can-execute-changed 事件……
听起来很简单:只需阅读用户故事,然后以空测试的形式写下所有想到的东西。即使你必须重新修改它,也没什么大不了的,因为它们背后还没有任何实现工作。
会有测试,更多的测试,新的行为案例,针对它们的新测试,然后你又会发现更多新的行为案例,如此循环,似乎永无止境……
嗯,在大多数情况下,它确实有尽头。达到这个目标非常有趣,因为它发生得很突然。突然间,你发现你无事可加,什么都没有了,而你迄今为止写的所有测试都通过了。那么,你就完成了那个故事。
如果不是这样,那与TDD无关。你对行为细节的分析——这正是你一直在做的事情——得出的结论是用户故事没有一致的解决方案,至少在你理解的范围内没有。能够如此早地发现这一点是件好事,在编写一行生产代码之前。是时候进行头脑风暴并与产品负责人沟通了。
我们的用户故事确实有一个一致的解决方案。它在项目 Sample2
中实现。
然而,事实证明,后续添加/删除乘客或司机会导致他们在初始列表中的顺序发生变化。
点击第一辆车可用司机集合中的朱可夫,将其分配为司机。如果我们将艾森豪威尔选为第二辆车的司机,也是如此。在这两种情况下,这些团队成员都会从两辆车的可用乘客和可用司机中移除。
如果我们点击已分配司机的司机按钮,该司机将被取消分配,并返回到两辆车的“可用乘客”和“可用司机”中。然而,未分配团队成员的顺序现在不同了。
该功能可以按照用户故事中指定的方式使用,但是产品负责人觉得它不好看,而且难以反驳。事实上,我们应该将车辆-司机-乘客的关联恢复到其初始状态,我们确实也做到了,但是用户期望看到整个视图恢复到其初始状态。
让我们详细看看这种改进的测试驱动实现。
后期功能的测试驱动实现
首先,我们为此描述几个测试。
啊,不!首先,我们决定把这些测试放在哪里。
如果你查看项目 Sample2
,你会发现我们已经测试过
- 所有车辆视图模型中未分配团队成员的集合共享相同的引用,并且
- 可用乘客和可用司机会自动与未分配团队成员的集合同步。
最初,第一个立场可能看起来是多余的。我们真的需要测试这些东西吗?嗯,在这个故事发生的原始客户项目中,我们没有这样做,这就需要测试车辆之间的同步。对于本文,我从头开始以不同的方式实现了它,这样我就可以省去近十几个集成级别的测试,甚至不需要添加更多的单元级别测试。
根据这个观察,看来我们在一个车辆视图模型中也测试这个新功能就足够了。让我们定义这样的测试:
[TestFixture]
public class VehicleVieweModelTests
{
[Test] public void
Setting_Removing_Driver_Preserves_OriginalOrder_OfUnassignedEmployees()...
[Test] public void
Adding_Removing_Passengers_Preserves_OriginalOrder_OfUnassignedEmployees()...
}
因为我不是LINQ高手,所以不知道如何实现它,此时我宁愿不去思考。这很符合这个方案。
这两个测试不太相似
[Test]
[TestCase(0)]
[TestCase(1)]
[TestCase(2)]
public void Setting_Removing_Driver_Preserves_OriginalOrder_OfUnassignedEmployees
(int expected)
{
// arrange
var target = new VehicleViewModel(this.unassingedEmployees,
this.unassingedEmployees.ToList());
var labRat = this.unassingedEmployees[expected];
// act
target.AvailableDrivers.Single
(el => el.Person == labRat).SelectCommand.Execute(null);
target.Driver.SelectCommand.Execute(null);
// assert
var actual = this.unassingedEmployees.IndexOf(labRat);
Assert.AreEqual(expected, actual);
}
在这个测试中,我们看到员工集合被两次传递给VehicleViewModel
的构造函数,但作为两个不同的实例。你可以在本节下方找到相关的讨论。该测试精确地验证了我们上面在截图中观察并描述的内容。但有些东西告诉我们,如果我们用乘客尝试,情况也会一样。甚至可能更复杂,因为我们可以以任意顺序添加和删除多个乘客。
[Test]
[TestCase(new[] { 0 }, new[] { 0 })]
[TestCase(new[] { 1 }, new[] { 1 })]
[TestCase(new[] { 2 }, new[] { 2 })]
/*lots of test cases ...*/
[TestCase(new[] { 0, 1, 2 }, new[] { 1, 0, 2 })]
[TestCase(new[] { 1, 0, 2 }, new[] { 1, 0, 2 })]
[TestCase(new[] { 2, 0, 1 }, new[] { 1, 0, 2 })]
[TestCase(new[] { 2, 1, 0 }, new[] { 1, 0, 2 })]
[TestCase(new[] { 2, 1, 0 }, new[] { 2, 0, 1 })]
public void Adding_Removing_Passengers_Preserves_OriginalOrder_OfUnassignedEmployees
(int[] toAdd, int[] toRemove)
{
// arrange
var target = new VehicleViewModel
(this.unassingedEmployees, this.unassingedEmployees.ToList());
var labRats = this.unassingedEmployees.ToArray();
foreach (var i in toAdd)
{
target.AvailablePassengers.Single(el => el.Person == labRats[i])
.SelectCommand.Execute(null);
}
// act
foreach (var i in toRemove)
{
target.Passengers.Single(el => el.Person == labRats[i])
.SelectCommand.Execute(null);
}
// assert
foreach (var expected in toAdd)
{
var actual = this.unassingedEmployees.IndexOf(labRats[expected]);
Assert.AreEqual(expected, actual);
}
}
然而,对我来说,在所有可用队友被选为乘客,然后以不同顺序取消选择之后,原始顺序能否正确恢复,似乎并不明显(现在仍然不明显)。与其盯着生产代码,试图弄清楚在更复杂的情况下它将如何工作,或者纠缠于数学归纳法或其他什么,我只是添加测试用例,并且如果它们通过而算法对我来说还不太清楚,我认为这就足够好了。
有很多这样的情况,例如在数值方法中,理解每个算法细节与任何可想到的应用案例的联系根本不切实际。
为了确保此实现对可用乘客和可用司机产生预期效果,请回想一下我们已经测试过这些集合与this.unassingedEmployees
同步。
还有一个尚未被任何测试覆盖的细微之处,那就是 ManageVehiclesViewModel
创建了一个新的车辆视图模型,其中包含两个不同的集合,即 this.unassignedEmployees
和 this.originalEmployees
。
var newVehicleVm =
new VehicleViewModel(this.unassignedEmployees, this.originalEmployees);
车辆视图模型共享前者的集合引用,因此其内容会随时间变化。我们真的能用它来保持订单模板吗?
测试这么小的东西是相当烦人的,尤其是当我们无法立即弄清楚如何以优雅的方式完成它时。然而,如果因为一个愚蠢的复制粘贴错误而不起作用,那会更烦人。
我尽力将其保持在尽可能简单的程度
[Test]
[TestCase(0, 1)]
[TestCase(1, 2)]
public void
Adding_Removing_Passengers_ForTwoVehicles_Preserves_OriginalOrder_OfUnassignedEmployees
(int toAddRemove1, int toAddRemove2)
{
// arrange
var target = new ManageVehiclesViewModel(this.employees, this.containerMock.Object);
target.AddVehicleCommand.Execute(null);
var vehicle1 = target.Vehicles.Last();
vehicle1.AvailablePassengers.Single(el => el.Person ==
this.employees[toAddRemove1]).SelectCommand.Execute(null);
target.AddVehicleCommand.Execute(null);
var vehicle2 = target.Vehicles.Last();
vehicle2.AvailablePassengers.Single(el => el.Person ==
this.employees[toAddRemove2]).SelectCommand.Execute(null);
// act
vehicle1.Passengers.Single(el => el.Person ==
this.employees[toAddRemove1]).SelectCommand.Execute(null);
vehicle2.Passengers.Single(el => el.Person ==
this.employees[toAddRemove2]).SelectCommand.Execute(null);
// assert
CollectionAssert.AreEqual(this.employees, target.UnassignedEmployees.ToList());
}
这是一个集成测试。为了减少其与VehicleViewModelTests
中的单元测试的重叠,我这里只保留了那些如果我们将this.unassignedEmployees
作为第二个参数传入(如下所示)就会失败的测试用例。
var newVehicleVm =
new VehicleViewModel(this.unassignedEmployees, this.unassignedEmployees);
它的实现不需要太多思考,因为它类似于VehicleViewModelTests
中的类似单元测试。然而,它的价值是什么?我的意思是,除了涵盖这种荒谬的复制粘贴机会之外。嗯,它验证了传递两个不同的集合确实是必要的,所以我们这里没有增加任何技术债务,也不需要考虑最终的简化。在某个时候,我曾有过疑问。
在捣鼓上述集成测试时,我发现了另一个行为案例,即删除包含乘客或司机的整辆车,其中也需要验证正确的顺序恢复。因此,添加了更多的集成测试。这次,这些无疑是集成案例。
public void On_Removing_VehicleViewModel_Adds_VehiclesAssingedPassengers_
ToUnassgignedEmployees_InOriginalOrder()...
public void On_Removing_VehicleViewModel_Adds_VehiclesAssingedDriver_
ToUnassgignedEmployees_InOriginalOrder()...
这些新案例将需要重用最初实现为 VehicleViewModel
方法的“插入到原始顺序”算法。现在我们将其移动到一个单独的实用类 OriginalOrderTemplate
中,我们应该测试它,不是吗?那么 VehicleViewModelTests
中已经编写的测试怎么办?它们会被重复吗?
不,其实不然。最初编写的测试只验证了在这个用户故事中可能发生的案例。但是新类是一个实用工具。因此,它的使用应该要么仅限于我们的用户故事案例,这将需要定义和测试它对超出所需范围的案例的反应,例如抛出参数异常。或者我们扩展其范围,并在用户故事范围之外实现一些极限案例测试。在这种特定情况下,我发现添加极限测试案例更具实用性,从而 a) 为这个实用工具增加更多价值,b) 使其更不易碎,以及 c) 避免更改生产代码逻辑,我也会测试这些逻辑。无论如何,在 OriginalOrderTemplateTests
中总有一些东西需要测试。
上述测试和相关的生产代码更改可在项目 Sample3
中找到。
重要的是要指出,无需在自动化UI测试中验证此改进。事实上,我们不仅测试了“可用乘客”和“可用司机”的同步,还测试了它们是可观察类型(在本例中为IBindingList
)。因此,唯一可能出错的是相关的XAML绑定表达式,作为开发人员,我们不使用自动化测试覆盖它。如果QA想这样做,他们可以,但他们肯定不需要为这项特定的改进额外做这项工作。
正如你所看到的,后期定义行为案例和后期测试实现,涉及我在开头谈到的行为分析、测试定义和实现细节之间混乱的跳跃,即旧的、好的且昂贵的“思考-编码”过程。
TDD算法
通常情况下,你会在已经处理生产代码的实现细节时,发现一些新的行为案例。
算法很简单:无论你在做什么,如果你发现一个新的行为案例,立即停止并为它编写一个空测试。这可以防止你忘记那个新案例(如果你发现第二个或第三个,你就会忘记它)。此外,如果你的设计或用户故事定义有任何不一致之处,你就有机会尽早发现并采取措施,从而降低过度成本的风险。无论如何,只要你能在测试骨架中添加或更改任何东西,你就会一直停留在测试定义阶段。
如果你按以下方式定义工作优先级,可能会有所帮助:
- 以空(不确定)单元/集成测试的形式定义行为案例。如果这里没有什么可添加的,请与产品负责人或团队成员一起审查,并根据需要重复此点。如果没有添加任何内容,请转到下一个优先级级别,然后……
- ...在生产部分实现测试和样板代码,以使测试编译通过。在出现新的行为案例时,返回第1点。如果所有测试都可编译,则继续……
- ...实现生产部分以使所有测试通过。在任何机会下,返回2或1。
- 如果你已经达到了这一点,你就完成了。
至少你可以这么认为,即使你忘记了 XAML 部分。我经常系统性地忘记视图。无论如何,你会在站会上和队友们一起取笑它,然后轻松愉快地完善和调整 UI 部分,因为事情已经奏效了。
阶段2和阶段3可以合并。这取决于您的个人偏好以及您对这个特定用户故事中生产部分实现的信心或不自信程度。我通常会合并。
从生产代码的角度来看,这项工作的意义是什么?
- 分析需求,定义类结构,用“散文”定义新类的行为和交互。
- 根据其公开的编程接口定义新类的行为和交互。
- 实现新类。
至于类之间的交互,它很像CRC设计,只不过你是在集成测试中定义它。此外,你定义的类行为比你在通常的CRC或UML设计中定义得更详细。
无论如何,每一步你都在进行生产性工作。最后拥有一个完整的测试套件是额外的收获。
借用编程的乐趣
软件开发人员的工作是创造性的。这就是我们喜欢它的原因。如果建议的方法将思考工作从最具创造性的地方——生产代码实现中移除,那会怎样?
嗯,它确实在一定程度上做到了。
然而,有一种现象会破坏超级创意工作的乐趣,那就是永无止境的故事带来的痛苦经历。下图显示了在一个技术债务(糟糕的设计和测试覆盖率是其中一部分)较高的项目或用户故事中,功能F与成本C的关系。
一开始超级热情和创造性的功能驱动工作的乐趣,最终会变成沮丧。此时,你根本不想去想如果变更请求来了会发生什么。你可能会经历一次、两次,甚至更多次……
下一张图表显示了另一种情况,即在 TDD 风格的项目中,成本与功能曲线是什么样子的。你突然完成了。而且是确定地完成了。
在这里,你可能一开始会感到不舒服,因为你工作很努力,但没有产生功能增量。然后,当你开始让测试通过时,你可能会想“不,不可能这么简单!”是的,可以!感觉你几乎是不假思索地在工作。这是因为你的思维效率更高。你只专注于实现细节,所以不需要花费那么多精力。
这些图表通常适用于技术债务较高和较低的项目。测试驱动开发有助于您减少与糟糕设计和测试覆盖率相关的技术债务部分。
这就像生活中的许多其他情况一样:要么你提前投入,然后享受;要么你从一开始就开心,但不会持续很久。
为什么(何时)TDD无效?
缺乏信心
如果你不确定自己能否实现生产部分,你如何能够(数日!)编写空的单元测试?
你不能。如果你不确定生产部分,那么这就是原型的情况。
原型制作
正如书中所说,原型是你在之后会丢弃的东西。你进行原型设计的时间越长,停止它并开始一个干净的生产解决方案就越困难。你继续的时间越长,技术风险就越低,但使用设计不佳的原型代码继续生产的风险就越高。
原型代码通常不会用单元测试覆盖,原因很简单,你可能需要过于频繁和深入地重构它,以至于重构相关的单元测试可能成本过高。
过度自信
你认为你对软件组件的行为和生产类之间的交互理解得足够好,可以跳过这项借用工作。我经常有这种诱惑。它可能导致我的测试覆盖率出现漏洞。安慰我的是一种希望,那就是那些未被覆盖的类未来永远不会被改变,也不会受到我软件组件其他部分任何更改的影响。
过度重构
即使一开始你对生产部分很有信心,但在后期你遇到了需要进行重大重构的情况,此时你已经有了大量的单元测试,这些测试也需要重构。因此,如果没有单元测试,重构成本会相当适中,而有了它们,成本就会变得非常高昂。
设计问题在于如何在生产代码和测试中组织事物,以使这种深度重构的可能性足够低。有没有一个好的设计方法?是的,有。阅读有关设计反模式的文章并避免它们。即使你出于某种原因不喜欢使用设计模式,仅仅避免反模式就能让你的代码足够好。
在一个真实的发生这个示例用户故事的项目中,车辆最初用字符串(车牌号)表示。这与你原样传递事件数据,而不将其封装到EventArgs
派生类中是相同的情况。我没有找到针对这种情况的特定反模式,所以我将其命名为类型不足。当需要添加汽车里程时,我们已经有很多单元测试,其中我们必须更改测试数据类型。
太费力了
我们曾有过一次很棒的经历,完全按照我这里所说的,定义了所有可想到的测试案例。但我们是以SCRUM Planning II的形式进行的,即全体团队或几乎全体团队坐在一起,在白板上写下这些东西。我们只用这种方式完成了一个用户故事,所有人在随后的回顾会议上都对其给予了高度评价。但我们再也没有这样做过。
在我目前的理解中,TDD 应该很舒适。我甚至会说,这就是目标。
花费太多精力
这意味着,你觉得它带来的收益小于其成本。这是任何事业的致命伤。
然而,我们绝不能忘记技术债务的影响。它总是滞后,总是不可避免。图5和图7展示了它的运作方式。当成本爆炸时,吸取的教训可能会说服你开始编写代码文档、重构、增加测试覆盖率等。但你无法挽回已经发生的成本。没有早点做这些的遗憾将一直存在。
尽管如此,如果能知道……
如何降低单元测试成本?
每个自动化测试都有其价值和成本。目标是始终让前者尽可能高,后者尽可能低。在无法预先估算每个单独测试的价值和成本的情况下,可以使用一些规则来提高价值预期并降低成本预期。
- 预先编写的测试在统计上比为现有代码编写的测试更有价值且成本更低。
- 在较低集成级别编写的测试通常比在较高集成级别测试相同行为案例的成本更低,价值更高。
正如马丁·福勒在他的《测试金字塔》一文中描述的那样,测试在金字塔中的位置越低,成本就越低。
一般来说,对于纯单元测试和集成测试都是如此,除非生产类进行单元测试隔离需要付出太大努力。本文中的示例用户故事中包含集成测试。基本上,如果我有选择,我会在测试金字塔中尽可能低层测试任何行为。
如果您已在较低级别测试过某件事,请避免在其他地方重复测试。换句话说,如果我已在较低级别(较低集成度)测试中测试过某些行为,那么我在较高级别测试中完全依赖于该结果。
不要复制粘贴创建测试数据的代码。使用辅助方法。
还有……
关于单元测试有许多建议和最佳实践。我们将其置于本文范围之外。
TDD何时特别有用?
在任何你对软件组件的行为案例和详细设计一无所知的情况下。好吧,好吧……你只是没有过度的信心,是吗?换句话说,如果你知道这不是什么高深的技术,但你不知道从何开始,那么从空的测试案例开始,自上而下,就像上面的例子一样,就不会有错。
如果您感到疲倦且注意力不集中,专注于简单、小巧但有用的事情(例如空测试用例)可能会有所帮助。
从何开始?
如果你的团队是TDD新手(否则,他们会告诉你从何开始),你应该首先与队友达成一致,以TDD风格完成一个或两个用户故事。如果你的团队练习宠物项目,这可能是一个尝试的好地方,没有立即成功的义务。
如果您的项目指南要求高测试覆盖率,那么首先编写测试会更合理。由于我在本文中试图解释的原因,它只需更少的时间。此外,先有测试,您将只测试行为,您的测试将更短(在重构情况下不会有太多重构),并且您的生产代码将被强制实现测试协作。
每当你觉得太难的时候,就回想一下你学骑自行车的时候。
更多提示
越多的人审查您的空测试,您剩下的工作量就越少。
在测试定义阶段,结对编程比在实现阶段容易得多,因为你唯一需要达成一致的是行为。此外,在测试定义阶段进行结对编程尤其有价值。
如果你甚至不知道从哪个空测试开始,就像我在这个用户故事中遇到的情况一样,将功能区域添加到单独的测试文件中,例如“构造函数”、“添加/删除车辆”、“验证和保存”等。请记住,思考需要时间,打字不需要。
考虑将Assert.Inconclusive()
添加到空白或复制粘贴的测试中。记住空白测试,以免留下通过的测试占位符是很累的。打字是……你现在知道了。为什么是Assert.Inconclusive()
而不是抛出未实现异常?听起来很合乎逻辑,但那样的话,你可能无法提交并共享实现工作。
源代码
源代码是一个 VS2019 解决方案。它包含三个可执行的生产项目,即 Sample1
、Sample2
和 Sample3
。前者是一个样板 WPF 项目,第二个实现了所定义的用户故事示例,后者添加了在“特性测试驱动实现”中讨论的改进。
生产项目有其对应的测试项目,即 Sample1.Tests
、Sample2.Tests
和 Sample3.Tests
。
前者只包含我实施前想到的、定义行为案例的空测试。
第二个测试项目包含Sample2
的测试套件。
两个示例项目的测试套件是不同的。第一个项目中的某些测试在第二个项目中被删除。这是正常的。确实,在空测试套件中,它涉及到行为定义。后来,可能会发现某些行为案例不重要,应该有所不同,甚至被取消。有时,你无法预先知道。
测试探索中显示的测试数量显著增加,即Sample2.Tests
中有83个,而Sample1.Tests
中有43个,这是因为我从MSUnit
更改为NUnit
,并使用多个数据驱动测试案例实现了一些测试。NUnit
将数据测试案例计为单个测试。Sample3.Tests
显示133个测试,尽管我们只添加了6个测试方法。
还有一个实用工具项目,其中包含辅助内容及其单元测试。
历史
- 2020年2月14日:初始版本