高级单元测试,第一部分 - 概述






4.92/5 (151投票s)
单元测试问题简介。
天哪,任何投票支持[单元测试]的人都有大问题。我讨厌那些东西。而且“先写测试,后写代码”的范式也让我不解。我的意思是,拜托,单元测试并不是万能的,人们!我不在乎你的测试有多复杂,“通过/失败”的评级不足以确保你已准备好投入生产。- David Stone,在“你更喜欢设计还是单元测试”调查中说。
目录
前言
这是一篇关于单元测试和极限编程(XP)过程问题的漫谈文章。更糟糕的是,这是四篇文章中的第一部分。我将这两个主题结合在这里,因为我认为拥有上下文(极限编程)对内容(单元测试)来说很重要。虽然单元测试并非XP独有,但它是XP的`关键`元素。我认为要真正很好地理解单元测试,了解它在XP过程中是如何使用的非常有价值。XP在定义流程方面做得很好,使得更容易以测试驱动开发的方式思考,并有助于确定如何至少创建一组核心单元测试。如果没有这个上下文,将单元测试作为一个自主过程来写会变得困难得多。
本系列共四篇文章
- 第一部分:单元测试简介和一项案例研究,将 XP 流程推进到编写一些单元测试的阶段
- 第二部分:实现一个类似 NUnit 的工具,并使用真实的测试和代码进一步开发案例研究
- 第三部分:NUnit 扩展的实现,修订和推进案例研究
- 第四部分:使用反射创建基于脚本而非代码的单元测试,以及其对案例研究的影响
引言
尽管标题听起来有些冒失,但我个人认为单元测试的概念还有很长的路要走。我认为它过于简化,过分强调,并且经常被误解为“主流”编码技术的替代品——需求文档、设计文档、代码审查、走查、插装、性能分析和质量保证。虽然我也并不特别相信正式的设计过程适用于许多开发工作(所以是的,XP 中的一些技术更准确地描述了我做事的方式),但我开发了一种对我来说非常有效且不同的解决方案。
另一方面,我正在意识到单元测试在代码编写过程中有其一席之地。即使它作用微乎其微,它也有其用处,特别是当程序越来越大,并且改动不仅可能,而且必然会导致未被发现的代码损坏时。
另一方面,我也认为单元测试过于简单化。它无法发现代码更改时可能发生的大多数高级问题。我主要指的是信息流问题,例如确保当库存中收到某个物品时,如果该零件的采购订单与某个工单相关联,则会自动向客户开具账单。这是一个复杂的信息流,需要一个复杂的单元测试。
还有一种观点认为,如果您的单元测试通过,那么您的代码就可以投入生产(或者至少可以进行验收测试)。正如 David Stone 雄辩地指出,这是一个荒谬的假设。问题在于,如果您依赖单元测试来确定代码是否准备就绪,那么您的单元测试最好做得相当好。而编写好的单元测试既耗时、又无聊、又费力、又繁琐、又困难,并且需要技巧。哦等等。它还需要一份好的设计文档,以便您可以测试设计;一份好的需求文档,以便您可以测试需求;一个好的经理,以便您有时间编写所有这些测试;以及大量的耐心,因为设计和需求会改变,所以您还需要更改所有您编写的那些不错的测试。虽然编写单元测试是我们希望交给走廊尽头的小兵去做的事情,但可悲的是,它往往需要小兵所不具备的专业知识水平。
所以,我至少有三种想法,在想我是不是不理解单元测试的好处,还是世界其他地方的人只是喜欢炒作。在我得出预设结论之前,我发现解决这个问题的最好方法是重新创建NUnit,然后添加一些我认为对它进入“真实世界”很重要的功能。没有什么比弄清楚一件事是如何完成的更能理解为什么会做一件事了。
不过,我从一开始就要说一件事。我是一个框架人。我信奉基于“可证明正确的构造”进行构建的理念。这意味着我从小处着手,进行泛化,并用我已证明可以按预期工作的代码(无需单元测试)构建一个框架。毕竟,还有其他测试方法。所以,在构建最低层基础,证明对象和函数是正确的之后,我再构建下一层,证明该层,依此类推。这就是应用自动化层的基础,如下图所示:
结果是,当我编写应用程序时,当然需要编写一些自定义的、特定于应用程序的代码,但大部分时间,我所做的都是将经过验证的代码粘合在一起,这意味着我所做的主要是编码 GUI 和数据库之间的数据流,编码特定于应用程序的规则(使用经过验证的规则引擎),以及编码任何其他业务层问题。换句话说,这都是数据流。
此外,我很少进行重构。没有什么可重构的,因为经过验证的代码设计良好、简单且有效。我进行的任何重构通常都与改进用户界面有关。当然也有例外。例如,我的框架使用脚本文件来编写所有组件的连接逻辑。在一个大型项目中,可能会有数百个脚本文件,这些文件在应用程序加载时会被预解析。那么,猜猜怎么着?当客户端运行病毒查杀程序时,应用程序加载需要15秒,而通常只需要1-2秒。所以这是一个很好的例子,说明了需要进行一些重构。
我为什么要提到重构?因为重构意味着改变代码和对象组织,通常是低级代码。所以,如果有一些自动化的回归测试在那里,你可以用它们来确保代码在所有这些改变之后仍然能正常工作,那就太好了。而回归测试,部分是通过单元测试来执行的。顺便说一句,如果你想知道XP为什么如此依赖单元测试,那是因为它依赖重构,而它依赖重构是因为代码是“边设计边做”——也就是说,一次只设计一点点。当然,没有人会关注系统级的整体设计,包括客户,所以客户在整个过程中会进行大量的更改。XP背后的理念是,它应该处理那些在开发过程中客户可能会提出大量更改的项目。嗯,这是一个自我实现的预言。如果你让客户给你提供需求,而没有预先设计至少主要的组件,那么客户当然会改变需求,你当然会需要重构代码,你当然会需要单元测试来找出哪里出了问题。至于“先写测试”的想法,我稍后再谈。我想,Kent Beck 的任何认可也到此为止了。
什么是单元测试?
单元测试验证一个函数或一组函数“履行其契约”——换句话说,被测试的函数满足需求。单元测试检查黑盒和白盒。
黑盒测试 vs. 白盒测试
黑盒测试与白盒测试不同。您可以对代码执行的测试类型,除其他因素外,决定了单元测试的复杂性。
黑盒测试
黑盒测试(也称为“功能测试”)是指您输入数据并验证输出,而无法检查内部工作原理的测试。此外,通常不具备以下信息:
- 盒子如何处理错误
- 您的输入是否执行了所有代码路径
- 如何修改您的输入以执行所有代码路径
- 对其他资源的依赖
黑盒测试限制了您彻底测试代码的能力,主要是因为您不知道是否正在测试所有代码路径。通常,黑盒测试只验证良好的输入是否产生良好的输出(因此称为“功能测试”)。
类通常被实现为黑盒,只允许类的“用户”访问实现者选择的公共方法和属性。
白盒测试
白盒提供测试所有可能路径所需的信息。这不仅包括正确的输入,还包括不正确的输入,以便也可以验证错误处理程序。这提供了几个优势:
- 您知道盒子如何处理错误
- 你通常可以编写测试来验证所有代码路径
- 单元测试更完整,是一种文档指导,实现者可以在实际编写盒子中的代码时使用
- 资源依赖性已知
- 内部运作可检查
在“先写测试”的场景中,编写完整测试的能力对于最终实现代码的人来说至关重要,因此一个好的白盒单元测试必须确保,至少在概念上,所有不同的路径都得到了执行。
白盒测试的另一个好处是单元测试能够在测试运行后检查盒子的内部状态。这对于确保内部信息处于正确状态很有用,无论输出是否正确。尽管类通常实现有许多私有方法和访问器,但使用 C# 和反射,可以编写单元测试,让您能够调用私有方法并设置/检查私有属性。
测试夹具
单元测试还包含一个“测试夹具”或“测试工具”。
测试夹具执行测试所需的任何设置和拆卸。这可能包括创建数据库连接,实例化一些依赖类,初始化状态等。测试夹具是导致单元测试出现问题的原因之一。非简单的测试可能需要复杂的设置和拆卸过程,这些过程本身可能存在bug、耗时且难以维护。因此,需要“模拟对象”。
测试夹具执行两个级别的设置和拆卸
- 一套测试所需的设置和拆卸
- 单个测试所需的设置和拆卸
为一套测试设置单独的设置和拆卸主要是出于性能原因——重复为每个方法设置和拆卸对象效率远低于为一组对象一次性设置和拆卸。下图说明了这一过程:
模拟对象
模拟对象是模拟复杂对象且功能简化的事物,使创建测试夹具变得更容易。我稍后会详细介绍模拟对象,因为使用它们需要做出一些系统范围的前期设计决策。但现在,请记住,单元测试通常需要模拟对象来模拟硬件、连接或其他可能无法用于测试夹具的资源。单元测试还需要模拟对象以提高性能——与生产对象的接口可能过于复杂(需要太多的设置和拆卸)和/或生产对象会降低测试的性能。由于单元测试通常运行非常频繁,因此测试性能是一个因素。
单元测试测试什么?
这也许是最重要的问题,也是最难回答的问题。简而言之,单元测试验证需求是否得到满足。说起来容易,但真正识别需求是什么以及哪些需求值得测试却非常困难。
客户需求
客户需求通常指定功能、性能、数据和工作流的某种组合。一个通用的模板可以表示为:
客户通常会从用户界面的角度思考,点击一个按钮来做某事,然后用户界面因此发生变化。客户还会从展示层面的角度指定数据。
程序通过将工作流分解为一组过程(通常也由客户决定,因为客户希望工作流中的过程保持熟悉)来实现此工作流。然后每个过程又分解为一组函数,同样,通常是客户熟悉的函数。自动化工作流(其中整个工作流对客户来说是一个黑盒)与客户概念的耦合度较低。
因此,客户需求的单元测试包含几个方面,每个方面具有不同的量化粒度。自下而上:
-
测试每个函数
-
测试每个流程
-
测试工作流
应当理解,函数级别的单元测试是不够的。流程级别整合函数,就像工作流级别整合流程一样。仅仅因为函数正常工作并不意味着程序员正确地组合了流程,对于由流程构建的工作流也是如此。请记住,“函数”一词与客户的概念相关,并不一定一对一地映射到类方法。
实现需求
在设计/实现阶段,实际发生的是程序员正在将客户需求转化为模式和实现。以下是这个概念的粗略想法(不要认为这些是固定不变或明确的——这个图示旨在进行粗略分类并引发思考):
测试实现需求的单元测试通常不同于测试客户需求的单元测试。
- 在客户数据表示和更优化的内部数据表示之间进行转换
- 将函数转换为对象
- 将数据存储转换为模式(关系型、XSD、结构体等)
全局需求
这包括元设计问题,客户通常对此一无所知。扩展前面的插图,您会看到元设计单元测试的作用:
元设计从整体角度考虑整个应用程序,并关注诸如以下问题:
-
通过使用设计模式进行对象解耦
-
一个应用程序范围的框架
-
不同功能模块的组件化
-
仪器
在这里,单元测试再次呈现出不同的形态。元设计通常与结构、抽象、资源管理以及其他应用程序范围的问题相关。在此级别的单元测试可能更关注验证容器的性能、测量资源利用率、网络流量以及其他系统范围的问题。
什么是 NUnit?
NUnit 是一个旨在促进单元测试的应用程序。它同时包含命令行和 Windows 界面,使其既可以交互式使用,也可以在自动化测试批处理中使用,或与构建过程集成。以下部分讨论 NUnit 如何应用于 C# 编程。
NUnit 如何工作?
NUnit 使用属性来指定单元测试类的不同方面。
TestFixture
TestFixture
属性表示一个类是一个测试夹具。这样指定的类包含设置、拆卸和单元测试。
SetUp
SetUp
属性与测试夹具类中的特定方法关联。它指示单元测试引擎在调用每个单元测试之前应调用此方法。一个测试夹具只能有一个 SetUp
方法。
TearDown
TearDown
属性与测试夹具类中的特定方法关联。它指示单元测试引擎在调用每个单元测试之后应调用此方法。一个测试夹具只能有一个 TearDown
方法。
测试
Test
属性表示测试夹具中的一个方法是单元测试。单元测试引擎会为每个测试夹具调用所有用此属性指示的方法一次,如果在测试方法之前定义了设置方法,并在测试方法之后定义了拆卸方法,则会分别调用它们。
测试方法签名必须是特定的:public void xxx()
,其中“xxx”是测试的描述性名称。换句话说,是一个不带参数且不返回参数的公共方法。
从被测试方法返回后,单元测试通常会执行断言以确保方法正确工作。
ExpectedException
ExpectedException
属性是一个可选属性,可以添加到单元测试方法(使用 Test 属性指定)。由于单元测试应部分验证被测试方法是否抛出适当的异常,此属性会使单元测试引擎捕获异常并在抛出正确异常时通过测试。
那些返回错误状态而不是异常的方法需要使用 NUnit 提供的 Assertion
类进行测试。
Ignore
Ignore
属性是一个可选属性,可以添加到单元测试方法。此属性指示单元测试引擎忽略关联的方法。必须提供一个字符串来指示忽略测试的原因。
Suite
Suite
属性已被弃用。最初的目的是指定测试子集。
一个示例
[TestFixture] public class ATestFixtureClass { private ClassBeingTested cbt; [SetUp] public void Initialize() { cbt=new ClassBeingTested(); } [TearDown] public void Terminate() { cbt.Dispose(); } [Test] public void DoATest() { cbt.LoadImage("fish.jpg"); } [Test, Ignore("Test to be implemented")] public void IgnoreThisTest() { } [Test, ExpectedException(typeof(ArithmeticException))] public void ThrowAnException() { throw new ArithmeticException("an exception"); } }
这个例子说明了六种不同属性的使用。
一个案例研究
为了把我写过的这些内容充实起来,让我们看一个我的船厂客户的真实需求示例——自动客户计费。这是一个相当复杂的例子,但我个人不喜欢微不足道的例子,因为它们留下了太多未解答的问题。
极限编程过程
我将使用极限编程方法,并从“测试先行”的角度来看待这个问题。这意味着我们必须暂停单元测试的话题,快速回顾一下XP的设计过程:
- 用户故事
- 发布计划
- 迭代计划
- Tasks(任务)
- CRC 卡
一旦我们完成这些步骤,讨论将回到单元测试!如果您想跳过此部分,请随意。然而,它之所以在这里,是为了说明`如何`确定单元测试。为此,我想通过一个示例向您展示单元测试是如何确定的,这需要从“用户故事”开始。
用户故事
用户故事如下:
针对特定工单的零件采购应在发票到达时自动向客户收费,并应将额外的供应商费用作为账单的一部分。
发布计划
这个故事的详细要求如下:
- 一个或多个工单与特定客户相关联
- 采购订单与供应商相关联
- 采购订单上的每个行项目反映为该供应商采购的零件
- 库存系统中的零件可能由多个供应商提供
- 每个供应商对该零件都有自己的成本
- 库存系统管理其自己的零件“成本”
- 库存成本使用移动平均值进行调整:(4*旧成本 + 新成本)/5
- 零件被指定为应税或不应税
- 每个采购订单行项目要么是为库存采购,要么是为工单所需的零件采购
- 当供应商发票到达时,运费、危险品等额外费用会添加到采购订单中。
- 采购订单在采购项目与发票核对后关闭
- 当采购订单关闭时,任何为工单采购的零件会自动向客户开具账单。
- 采购订单上的额外费用会添加到收款单中
- 由于采购订单上的行项目可能与来自不同客户的不同工单相关联,因此额外费用必须公平分配。
- 关于这种分配,唯一能想到的规则是根据零件成本与总采购订单成本的比例来分摊费用。
- 零件在收款单上开具账单,这模仿了正在使用的手动流程
- 一个或多个收款单与一个工单相关联
- 通过从库存中选择零件来添加到采购订单中。
- 只有来自将要购买零件的供应商的零件才能添加到采购订单中
- 客户享有不同的折扣率,并且可能应税或不应税
可以图示为:
迭代计划
由于这是一个完整的功能“包”,客户为本次迭代选择了整个用户故事。
Tasks(任务)
团队将用户故事分解为具体任务
-
创建一个数据库模式来管理这些数据
-
设计/实现用户界面
-
设计/实现采购订单系统
-
创建采购订单
-
添加零件
-
核对供应商发票中的额外费用
-
数据访问层接口
-
采购订单上的所有零件都从一个供应商采购
-
-
设计/实现工单录入系统
-
创建工单
-
添加零件
-
将工单分配给客户
-
数据访问层接口
-
-
设计/实现收款单系统
-
自动创建收款单
-
自动将零件添加到收款单
-
根据成本分配规则自动将额外零件添加到收款单
-
数据访问层接口
-
-
设计/实现零件的概念
-
零件有内部零件编号
-
零件有供应商成本(一个零件可以有多个供应商)
-
零件有供应商零件编号
-
零件有内部成本
-
零件有一个“应税”字段。如果清除,则零件不征税。
-
-
存在一个隐含的“供应商”概念
-
将供应商实现为模拟对象
-
-
实现客户概念
-
客户享有不同的折扣率
-
为客户采购的零件可能应税或不应税
-
非任务
由于此用户故事排除了管理采购订单、工单和零件(删除或修改)的理念,因此这些流程将排除在当前迭代之外。这还允许我们主要关注构建和验证自动计费流程所需的基础设施。此外,经过仔细检查,此迭代和用户故事并未暗示需要持久数据存储,因此暂时我们将忽略所有与数据访问层的交互。
CRC 卡
使用类、职责和协作卡(CRC 卡),我们可以为这个系统创建一些模型
我可能遗漏了一些东西,或者做得不够好。接下来是看看 CRC 卡在模拟客户设想的场景中如何工作。
使用 CRC 卡的场景演练
一旦创建了 CRC 卡,重要的是要“将它们放在桌面上”,看看它们在整个工作流中如何出现和消失。这也有助于识别对象之间的数据事务,这是静态 CRC 卡本身无法做到的,除非我们通过模拟场景来使用它们。
创建一些供应商 | ![]() |
将零件添加到供应商列表 | ![]() |
将供应商零件与库存零件关联 | ![]() |
创建一些客户 | ![]() |
创建一些与客户关联的工单 | ![]() |
为特定供应商创建采购订单并与客户的工单关联 | ![]() |
将零件添加到采购订单中,从供应商的零件列表中选择 | ![]() |
等待发票到达,然后在采购订单上记录任何额外费用 | ![]() |
创建与工单关联的收款单 | ![]() |
将采购订单中的零件和费用添加到收款单中 | ![]() |
新发现的非任务
现在我们对项目所有不同部分中的对象如何交互有了相当清晰的认识。这个过程产生的一个结果是,目前实际上不需要库存对象。所有零件都直接与供应商零件列表关联,因此尽管客户从库存的角度看待事物(这是很自然的,因为采购的零件进入库存,使用的零件从库存中取出),用户故事的需求可以在没有库存对象的情况下得到满足。
另一点是,上面的例子表明,零件被正确地添加到收款单中,而无需考虑税率、折扣等。这些问题实际上是第二个流程,即“客户计费”周期的一部分。重要的是要认识到,在不同任务的设计阶段,我们学到了一些额外的关键信息,这些信息改变了用户故事和从用户故事派生出来的任务。这些信息确实需要反馈给客户。
关于对象纠缠和系统级规划的一点说明
请考虑一下由相应的对象模型造成的纠缠:
这正是大型系统中应该避免的,因为改变任何一个对象都会影响大多数,如果不是全部,其他对象。好的,您会说:
-
这就是我们有单元测试可以用于回归测试的原因!
-
如果我们设计系统,将流程和类封装成组件,那么问题就可以得到管理!
-
更好的是,如果我们使用一些优秀的设计模式实践,我们就可以解耦这些依赖关系!
嗯,现在问题来了
-
在 XP 流程中,我们什么时候有机会考虑系统规划问题,例如组件管理框架?
-
在哪里/何时您会考虑将您的对象稍微抽象化的问题,例如使用接口以便您可以插入不同的解决方案?
-
在 XP 流程中,我们何时何地会研究类工厂和消息传递等设计模式来管理一点抽象?
-
何时何地会加入 instrumentation——调试跟踪以捕获单元测试未发现的问题,并记录客户如何制造问题的审计线索?
一个好的框架应该自动提供插装,这意味着使用消息传递或其他机制在对象之间进行通信,这也能减少对象纠缠。但是所有这些考虑都是系统级规划的一部分,而 XP 流程中没有明确考虑这些问题。哎呀,根据我的经验,这些问题通常在任何开发流程中都没有被考虑过。但无论如何,这就是我成为框架人的原因——我在软件设计中`首先`解决这些问题,而不是最后(或从不)。
关于对象的一点说明
所有这些对象都必要吗?不!我们真的需要将数据从数据库复制到对象中吗?不!难道不能通过一些抽象以及 GUI、业务层和数据访问层之间的一些智能接口,嗯,也许还有一些`脚本`来完全处理这些事务吗?是的!而这正是好的框架能为您做的另一件事——它会真正减少硬编码对象的数量,从而减少您需要编写的代码量,这消除了大量的单元测试。现在,在我看来(呃,打个比方),这才是真正的简单。
单元测试
有了 CRC 卡和对象概念之后,单元测试可以完成的第一件事就是为核心类编写测试。这些类(按依赖关系顺序)是:
-
零件
-
供应商
-
收费
-
收款单
-
工单
-
采购订单
-
发票
-
客户
我们将根据 CRC 卡上的信息,使用 NUnit 属性语法为每个类创建单元测试。我认为这个过程相当清楚地说明了预先编写良好单元测试以防止下游出现严重问题的重要性,以及编写良好单元测试的复杂性和难度。
零件
根据这张 CRC 卡,零件单元测试主要需要验证零件类是否可以无错误创建,并且具有设置和返回预期值的 setter 和 getter。此外,我们希望验证新构造的 Part 对象是否初始化为定义良好的状态。
[TestFixture] public class PartTest { [Test] public void ConstructorInitialization() { Part part=new Part(); Assertion.Assert(part.VendorCost==0); Assertion.Assert(part.Taxable==false); Assertion.Assert(part.InternalCost==0); Assertion.Assert(part.Markup==0); Assertion.Assert(part.Number==""); } [Test] public void SetVendorInfo() { Part part=new Part(); part.Number="FIG 4RAC #R11T"; part.VendorCost=12.50; part.Taxable=true; part.InternalCost=13.00; part.Markup=2.0; Assertion.Assert(part.Number=="FIG 4RAC #R11T"); Assertion.Assert(part.VendorCost==12.50); Assertion.Assert(part.Taxable==true); Assertion.Assert(part.InternalCost==13.00); Assertion.Assert(part.Markup==2.0); }
供应商
从供应商 CRC 卡中,我们看到它管理与该特定供应商关联的零件。用户故事或迭代中没有明确指定(这可能是我自己的错,但嘿,这种事情经常被忽略)的是,相同零件号不应在特定供应商中出现多次(尽管,通常,不同供应商会使用相同的零件号,所以这没关系)。在这个特定案例中,请注意,对于每个测试,我们都使用设置方法来实例化一个 `Vendor` 类。
[TestFixture] public class VendorTest { private Vendor vendor; [Setup] public void VendorSetup() { vendor=new Vendor(); } [Test] public void ConstructorInitialization() { Assertion.Assert(vendor.Name==""); Assertion.Assert(vendor.PartCount==0); } [Test] public void VendorName() { vendor.Name="Jamestown Distributors"; Assertion.Assert(vendor.Name=="Jamestown Distributors"; } [Test] public void AddUniqueParts() { CreateTestParts(); Assertion.Assert(vendor.PartCount==2); } [Test] public void RetrieveParts() { CreateTestParts(); Part part; part=vendor.Parts[0]; Assertion.Assert(part.PartNumber=="BOD-13-25P"); part=vendor.Parts[1]; Assertion.Assert(part.PartNumber=="BOD-13-33P"); } [Test, ExpectedException(DuplicatePartException)] public void DuplicateParts() { Part part=new Part(); part.PartNumber="Same Part Number"; vendor.Add(part); vendor.Add(part); } [Test, ExpectedException(UnassignedPartNumberException)] public void UnassignedPartNumber() { Part part=new Part(); vendor.Add(part); } void CreateTestParts() { Part part1=new Part(); part1.PartNumber="BOD-13-25P"; vendor.Add(part1); Part part2=new Part(); part2.PartNumber="VOD-13-33P"; vendor.Add(part2); } }
如单元测试所示,实现者必须遵守某些规定:
- 重复的零件导致异常
- 未分配零件编号的零件会抛出异常
- 零件以添加时的相同顺序检索
- 零件按序数检索
弱点 #1:不完整的单元测试
这最后一个要求,“零件按序数检索”,说明了单元测试的一个有趣的“特性”。这将暗示实现该类的人,`ArrayList` 足以管理零件。但现在考虑一个更完整的测试:
... [Test] public void RetrievePartsByName() { CreateTestParts(); Part part; part=vendor.Parts["BOD-13-25P"]; Assertion.Assert(part.PartNumber=="BOD-13-25P"); part=vendor.Parts["BOD-13-33P"]; Assertion.Assert(part.PartNumber=="BOD-13-33P"); } ...
此测试要求 `Vendor` 类实现基于零件编号(而不仅仅是零件集合中的序数)的检索机制。这可能会改变零件集合的实现方式,并对“按相同顺序检索...”的实现产生影响。
弱点 #2:没有性能测量
给定序数和基于字符串的零件查找,实现者仍然可以选择使用数组列表,并通过简单的 0..n 搜索来实现基于字符串的查找。这是非常低效的,但单元测试不衡量这种性能。
这是一个我们将在第三部分中进一步探讨的问题——扩展单元测试功能。如果单元测试包含性能测量,那么实现者将有一个选择合适集合的指导方针。 |
弱点 #3:没有资源利用率测量
典型的供应商将有数千个零件。因此,Vendor 类应该实现一个 Dispose 方法,手动清除零件集合,而不是等待垃圾回收器(GC)来释放未引用的内存。同样,缺少对此的单元测试。
如果内存管理不是单元测试的一部分,以后会出现性能问题,导致大量不必要的重构。我也会在第三部分中探讨这个问题。 |
弱点 #4:低阶依赖
如果以后出于性能原因,实现方式发生变化,导致零件不再按创建顺序维护,那么单元测试也必须随之修改。不幸的是,修改单元测试并不能明确指出依赖于此要求的代码也需要重构。对此确实没有什么可做的,只能识别这种依赖关系,并为“更高层”的对象编写专门测试此要求的单元测试。这种“向上浮动”的测试要求很难跟踪。关键是,当更高层级的过程依赖于某些特定的低阶功能时,更高层级的单元测试也必须确保低阶功能仍然按预期执行。
有人可能会争辩说,这个问题可以通过不改变功能来解决,这样单元测试就不会中断。这是不切实际的。形式(类架构)和功能都必须偶尔改变到一定程度,导致单元测试重构。虽然这可以通过正式的弃用过程来缓解,但关键是,如果高层流程缺少依赖的低阶功能单元测试,那么您可能很容易错过所有需要重构的地方。当然,低阶类通过了其单元测试,但高阶类及其单元测试`可能不会`。这就是单元测试中成本效益权衡之一——您是否花时间为高阶单元测试中的所有低阶依赖项编写单元测试?
收费
费用非常简单——它们有描述和金额。这个对象的单元测试没什么特别的。
[TestFixture] public class ChargeTest { [Test] public void ConstructorInitialization() { Charge charge=new Charge(); Assertion.Assert(charge.Description==""); Assertion.Assert(charge.Amount==0); } [Test] public void SetChargeInfo() { Charge charge=new Charge(); charge.Description="Freight"; charge.Amount=8.50; Assertion.Assert(charge.Description=="Freight"); Assertion.Assert(charge.Amount==8.50); }
收款单
收款单是零件和费用的集合。一张收款单上没有那么多费用/零件,因此性能和内存利用率并不是真正的问题,这使得单元测试相当简单。与供应商单元测试类似,我们希望确保零件和费用添加正确,并且“空”零件和费用会抛出异常。顺序无关紧要。
[TestFixture] public class ChargeSlipTest { private ChargeSlip chargeSlip; [Setup] public void Setup() { chargeSlip=new ChargeSlip(); } [Test] public void ConstructorInitialization() { Assertion.Assert(chargeSlip.Number=="000000"); Assertion.Assert(chargeSlip.PartCount==0); Assertion.Assert(chargeSlip.ChargeCount==0); } [Test] public void ChargeSlipNumberAssignment() { chargeSlip.Number="123456"; Assertion.Assert(chargeSlip.Number=="123456"; } [Test, ExpectedException(BadChargeSlipNumberException)] public void BadChargeSlipNumber() { chargeSlip.Number="12345"; // must be six digits or letters } [Test] public void AddPart() { Part part=new Part(); part.PartNumber="VOD-13-33P"; chargeSlip.Add(part); Assertion.Assert(chargeSlip.PartCount==1); } [Test] public void AddCharge() { Charge charge=new Charge(); charge.Description="Freight"; charge.Amount=10.50; chargeSlip.Add(charge); Assertion.Assert(chargeSlip.ChargeCount==1); } [Test] public void RetrievePart() { Part part=new Part(); part.PartNumber="VOD-13-33P"; chargeSlip.Add(part); Part p2=chargeSlip.Parts[0]; Assertion.Assert(p2.PartNumber==part.PartNumber); } [Test] public void RetrieveCharge() { Charge charge=new Charge(); charge.Description="Freight"; charge.Amount=10.50; chargeSlip.Add(charge); Charge c2=chargeSlip.Charges[0]; Assertion.Assert(c2.Description==charge.Description); } [Test, ExpectedException(UnassignedPartNumberException)] public void AddUnassignedPart() { Part part=new Part(); chargeSlip.Add(part); } [Test, ExpectedException(UnassignedChargeException)] public void UnassignedCharge() { Charge charge=new Charge(); chargeSlip.Add(charge); } }
弱点:纠缠和复杂性
随着我们从低阶函数转向高阶函数,我们可以看到一些发展:
- 单元测试与被测对象所需的其他对象纠缠不清
- 由于其他必要的设置,设置单元测试变得更加复杂。
这些问题将在第四部分中讨论,我将在其中讨论一种脚本化单元测试方法,并演示将设置数据导出到文件(例如 XML 文件)的优势。其主要优点是,您可以进行数据驱动的单元测试,迭代各种数据组合,这些组合可以轻松更改而无需重新编译程序。 |
工单
工单有一个必需的六位工单号,并跟踪所有与它相关的收款单。它很像供应商类,因为它跟踪收款单的集合。
[TestFixture] public class WorkOrderTest { private WorkOrder workOrder; [Setup] public void WorkOrderSetup() { workOrder=new WorkOrder(); } [Test] public void ConstructorInitialization() { Assertion.Assert(workOrder.Number=="000000"); Assertion.Assert(workOrder.ChargeSlipCount==0); } [Test] public void WorkOrderNumber() { workOrder.Number="112233"; Assertion.Assert(workOrder.Number=="112233"; } [Test, ExpectedException(BadWorkOrderNumberException)] public void BadWorkOrderNumber() { workOrder.Number="12345"; Assertion.Assert(workOrder.Number=="12345"; } [Test] public void AddChargeSlip() { ChargeSlip chargeSlip=new ChargeSlip(); chargeSlip.Number="123456"; workOrder.Add(chargeSlip); } [Test] public void RetrieveChargeSlip() { ChargeSlip chargeSlip=new ChargeSlip(); chargeSlip.Number="123456"; workOrder.Add(chargeSlip); ChargeSlip cs2=workOrder.ChargeSlips[0]; Assertion.Assert(chargeSlip.Number==cs2.Number); } [Test, ExpectedException(DuplicateChargeSlipException)] public void DuplicateParts() { ChargeSlip chargeSlip=new ChargeSlip(); chargeSlip.Number="123456"; workOrder.Add(chargeSlip); workOrder.Add(chargeSlip); } [Test, ExpectedException(UnassignedChargeSlipException)] public void UnassignedChargeSlipNumber() { ChargeSlip chargeSlip=new ChargeSlip(); workOrder.Add(chargeSlip); } }
弱点:有点没用的测试
此时,我开始质疑我的“检索...”测试的有用性,无论是这个类还是其他类。它真的测试了我从系统中取回的是我放入的相同收款单吗?我不这么认为。实现者很容易通过创建一个新的收款单并只复制收款单号来愚弄它!但是测试应该依赖于两个收款单之间的浅层比较,即它们的内存地址吗?不!返回的项很容易是一个副本。这导致了下一个问题……
弱点:未设计的
这些类中确实需要内置一个深层比较运算符。但我做设计时没有考虑到这一点,因为我过于专注于用户故事,忘记了良好的整体设计实践。因此,我没有想到深层比较运算符,也没有编写任何单元测试来确保比较运算符正确工作。现在,也许如果我与一个程序员团队合作,这种情况就不会发生。也许他们中的一个人会说,哎呀,我们需要在这里遵循良好的设计实践,并为这些类实现深层比较运算符。
弱点:解决不存在的问题
这也引发了一系列设计问题,这些问题很容易被忽略。例如,如果两个收款单具有相同的零件和费用,但它们的零件和费用的顺序不同,它们是否相等?除了单元测试,我们真的还需要深层比较运算符吗?我们是否在解决一个根本不存在的问题?在这方面,我会说“是”。
发票
发票很简单,基本上只是一个与发票相关的费用占位符,这些费用与采购订单相关联,并添加到工单的收款单中。为此,只需跟踪发票号和费用集合。
[TestFixture] public class InvoiceTest { private Invoice invoice; [Setup] public void InvoiceSetup() { invoice=new Invoice(); } [Test] public void ConstructorInitialization() { Assertion.Assert(invoice.Number=="000000"); Assertion.Assert(invoice.ChargeCount==0); Assertion.Assert(invoice.Vendor=null); } [Test] public void InvoiceNumber() { invoice.Number="112233"; Assertion.Assert(invoice.Number=="112233"; } [Test] public void InvoiceVendor() { Vendor vendor=new Vendor(); vendor.Name="Nantucket Parts"; invoice.Vendor=vendor; Assertion.Assert(invoice.Vendor.Name=vendor.Name); } [Test, ExpectedException(BadInvoiceNumberException)] public void BadInvoiceNumber() { invoice.Number="12345"; Assertion.Assert(invoice.Number=="12345"); } [Test] public void AddCharge() { Charge charge=new Charge(); charge.Number="123456"; invoice.Add(charge); } [Test] public void RetrieveCharge() { Charge charge=new Charge(); chargeSlip.Number="123456"; invoice.Add(charge); Charge c2=invoice.Charges[0]; Assertion.Assert(chargeSlip.Number==c2.Number); } [Test, ExpectedException(UnassignedChargeException)] public void UnassignedChargeNumber() { Charge charge=new Charge(); invoice.Add(charge); } }
客户
客户管理工单集合。
[TestFixture] public class CustomerTest { private Customer customer; [Setup] public void CustomerSetup() { customer=new Customer(); } [Test] public void ConstructorInitialization() { Assertion.Assert(customer.Name==""); Assertion.Assert(customer.WorkOrderCount==0); } [Test] public void CustomerName() { customer.Name="Marc Clifton"; Assertion.Assert(customer.Name=="Marc Clifton"); } [Test] public void AddWorkOrder() { WorkOrder workOrder=new WorkOrder(); workOrder.Number="123456"; customer.Add(workOrder); } [Test] public void RetrieveWorkOrder() { WorkOrder workOrder=new WorkOrder(); workOrder.Number="123456"; customer.Add(workOrder); WorkOrder wo2=customer.WorkOrders[0]; Assertion.Assert(customer.Name==wo2.Name); } [Test, ExpectedException(UnassignedWorkOrderException)] public void UnassignedWorkOrderNumber() { WorkOrder workOrder=new WorkOrder(); customer.Add(workOrder); } }
您可能已经注意到,这些测试都非常相似,而且相信我,为这篇文章编写这些测试确实很无聊。我们将在第四部分中研究如何使用反射自动化类似测试,以减轻这种繁重的工作。 |
采购订单
采购订单是将所有这些概念粘合在一起的最后一块。采购订单包含与工单相关联的零件。当发票到达时,可能需要调整零件定价,并可能需要添加额外费用。完成此操作后,采购订单“关闭”。然后将零件和费用添加到收款单中,并将收款单添加到工单中。采购订单可以包含与不同工单相关联的零件,这增加了一点复杂性。此过程的单元测试很大。关于自动计费逻辑是否应成为采购订单的一部分或从中提取的设计问题未得到彻底考虑。目前,它将保留在采购订单对象中。
另一个复杂性是,如何在不同工单所创建的收款单之间公平分配(对采购订单来说是全局的)费用。目前唯一可能的“公平”算法是根据每个收款单的相对金额来分配额外费用。
[TestFixture] public class PurchaseOrderTest { private PurchaseOrder po; private Vendor vendor; [Setup] public void PurchaseOrderSetup() { po=new PurchaseOrder(); vendor=new Vendor(); vendor.Name="West Marine"; po.Vendor=vendor; } [Test] public void ConstructorInitialization() { Assertion.Assert(po.Number=="000000"); Assertion.Assert(po.PartCount==0); Assertion.Assert(po.ChargeCount==0); Assertion.Assert(po.Invoice==null); Assertion.Assert(po.Vendor==null); } [Test] public void PONumber() { po.Number="123456"; Assertion.Assert(po.Number=="123456"); } [Test] public void AddPart() { WorkOrder workOrder=new WorkOrder(); workOrder.Number="123456"; Part part=new Part(); part.Number="112233"; vendor.Add(part); po.Add(part, workOrder); } [Test, ExpectedException(PartNotFromVendorException)] public void AddPartNotFromVendor() { WorkOrder workOrder=new WorkOrder(); workOrder.Number="123456"; Part part=new Part(); part.Number="131133"; vendor.Add(part); po.Add(part, workOrder); } [Test] public void AddInvoice() { Invoice invoice=new Invoice(); invoice.Number="123456"; invoice.Vendor=vendor; po.Add(invoice); } [Test, ExpectedException(DifferentVendorException)] public void AddInvoiceFromDifferentVendor() { Invoice invoice=new Invoice(); invoice.Number="123456"; Vendor vendor2=new Vendor(); invoice.Vendor=vendor2; po.Add(invoice); } [Test] public void RetrievePart() { WorkOrder workOrder=new WorkOrder(); workOrder.Number="123456"; Part part=new Part(); part.Number="112233"; po.Add(part, workOrder); WorkOrder wo2; Part p2; po.GetPart(0, out p2, out wo2); Assertion.Assert(p2.Number==part.Number); Assertion.Assert(wo2.Number==workOrder.Number); } [Test] public void RetrieveCharge() { Invoice invoice=new Invoice(); invoice.Number="123456"; po.Add(invoice); Invoice i2=po.Invoices[0]; Assertion.Assert(i2.Number==invoice.Number); } [Test, ExpectedException(UnassignedWorkOrderException)] public void UnassignedWorkOrderNumber() { WorkOrder workOrder=new WorkOrder(); Part part=new Part(); part.Number="112233"; po.Add(part, workOrder); } [Test, ExpectedException(UnassignedPartException)] public void UnassignedPartNumber() { WorkOrder workOrder=new WorkOrder(); workOrder.Number="123456"; Part part=new Part(); po.Add(part, workOrder); } [Test, ExpectedException(UnassignedInvoiceException)] public void UnassignedInvoiceNumber() { Invoice invoice=new Invoice(); po.Add(invoice); } [Test] public void ClosePO() { WorkOrder wo1=new WorkOrder(); WorkOrder wo2=new WorkOrder(); wo1.Number="000001"; wo2.Number="000002"; Part p1=new Part(); Part p2=new Part(); Part p3=new Part(); p1.Number="A"; p1.VendorCost=15; p2.Number="B"; p2.VendorCost=20; p3.Number="C"; p3.VendorCost=25; vendor.Add(p1); vendor.Add(p2); vendor.Add(p3); po.Add(p1, wo1); po.Add(p2, wo1); po.Add(p3, wo2); Charge charge=new Charge(); charge.Description="Freight"; charge.Amount=10.50; po.Add(charge); po.Close(); // one charge slip should be added to both work orders Assertion.Assert(wo1.ChargeSlipCount==1); Assertion.Assert(wo2.ChargeSlipCount==1); ChargeSlip cs1=wo1.ChargeSlips[0]; ChargeSlip cs2=wo2.ChargeSlips[0]; // three charges should exist for charge slip #1: two parts and one // freight charge Assertion.Assert(cs1.ChargeCount==3); // the freight for CS1 should be 10.50 * (15+20)/(15+20+25) = 6.125 Assertion.Assert(cs1.Charges[0].Amount=6.125; // two charges should exist for charge slip #2: one part and one // freight charge Assertion.Assert(cs2.ChargeCount==2); // the freight for CS2 should be 10.50 * 25/(15+20+25) = 4.375 // (also = 10.50-6.125) Assertion.Assert(cs2.Charges[0].Amount=4.375; // while we have a unit test that verifies that parts are added to // charge slips correctly, we don't have a unit test to verify that // the purchase order Close process does this correctly. Part cs1p1=cs1.Parts[0]; Part cs1p2=cs1.Parts[1]; if (cs1p1.Number=="A") { Assertion.Assert(cs1p1.VendorCost==15); } else if (cs1p1.Number=="B") { Assertion.Assert(cs1p1.VendorCost==20); } else { throw(IncorrectChargeSlipException); } Assertion.Assert(cs1p1.Number != cs1p1.Number); if (cs1p2.Number=="A") { Assertion.Assert(cs1p2.VendorCost==15); } else if (cs1p2.Number=="B") { Assertion.Assert(cs1p2.VendorCost==20); } else { throw(IncorrectChargeSlipException); } Assertion.Assert(cs2.Parts[0].Number="C"); Assertion.Assert(cs2.Parts[0].VendorCost==25); } }
正如采购订单单元测试中所看到的,将这些测试作为一种渐进式过程来实现会很有效——如果一个通过,则继续下一个。最终的单元测试,它将所有零件和费用组合在一起,并确保它们被添加到收款单中,可以利用这种渐进式,减少编写测试所需的时间。它还鼓励对基本功能进行更严格的测试,即便没有其他原因,也仅仅是因为程序员知道投入到更简单测试中的精力可以在更复杂的测试中得到利用。我将在第三部分中进一步探讨这一点,届时我将扩展基本的单元测试功能。 |
接下来...
至此,关于单元测试的漫长主题就结束了。在下一篇文章中,我将实现一个类似于 NUnit 基于 Windows 应用程序的单元测试环境,并实现案例研究功能以说明基本的单元测试。请注意,上面的单元测试实际上尚未编译或测试,很可能存在错误。我正在按照我实际经历“测试先行”过程的方式编写本文,所以您将看到我所有的错误。