高级单元测试,第五部分 - 单元测试模式






4.95/5 (77投票s)
2004年1月5日
23分钟阅读

1045529
单元测试模式简介。
系列前几篇文章
有什么新功能?
添加了关于表示层测试模式的部分。
加入高级单元测试项目!
我们正在寻找经验丰富的 C# 开发者,能够投入高质量时间完成大量待办功能,帮助 Visual Test Studio 成为单元测试开源工作的领导者。你是否对使用尖端技术并在快速发展的工程实践中担任领导者感兴趣?你是否希望在单元测试领域因帮助架构和开发 Visual Test Studio 而闻名?那么请访问http://aut.tigris.org/注册并联系 Marc,了解如何贡献!
目录
引言
单元测试的概念似乎总是能引起人们的强烈反响。对于那些接受这一概念的人,他们一致表示编写好的单元测试很困难,有些人质疑他们编写的测试是否真的值得,而另一些人则对其有效性赞不绝口。另一方面,也有一个庞大的社区对单元测试嗤之以鼻,尤其是“当代码通过单元测试时,代码就是好的”这一概念。当所有的喧嚣平息下来,单元测试可能有一天会被束之高阁,成为“又一个程序员工具”。如果这种命运要改变,单元测试必须得到社区和工具开发者的共同认可。微软下一版 Visual Studio 将包含自动化重构的工具。在我看来,自动化单元测试生成的工具不仅可以解决一些关于维护和成本的问题,而且可以将这一概念引入更广泛的受众。
然而,为了实现这种接受度,单元测试必须正规化,使其成为一门真正的工程学科,而不是一种依赖程序员可疑能力的临时方法。毕竟,单元测试是为了测试程序员编写的代码。如果程序员一开始就编写了糟糕的代码,你又怎能期望测试中出现更高质量的内容呢?更令人担忧的是,单元测试应该在被测试的代码之前编写。在某种程度上,这意味着程序员不仅要考虑代码将*做什么*,还要考虑代码是*如何设计*的。两者都驱动接口。这就是为什么许多人反对首先编写单元测试——它将他们置于一种不舒服的境地,必须在没有意识到的情况下进行前期设计工作。
所以,我们面临着一把双刃剑。首先,社区中没有建立正式的单元测试工程规范,为程序员提供指导并确保一定水平的单元测试质量。其次,在编写任何测试之前,设计必须在某种程度上正式化,这一先决条件给许多程序员带来了困难,因为他们要么没有正式的设计经验,要么根本不喜欢前期设计工作。加剧这种情况的是,前期设计工作可以在“重构”的幌子下被取代。
为了钝化这把剑,需要两件事——通过建立单元测试模式使单元测试正式化,以及在开发应用程序中早期采用面向对象设计模式,以专门针对单元测试的需求。本文将用非常大的笔触描绘这个双管齐下的解决方案。目的是激发您的兴趣,并希望能引发您之间的对话,从而形成更正式的单元测试工程流程,类似于面向对象设计、设计模式和重构。
在阅读本文时,请记住其中一个目标是一个工具套件,可用于自动生成单元测试,既作为逆向工程,也作为正向工程过程。对于后者,应该能够为被测代码生成方法存根。毕竟,单元测试的好处之一是它为实现者提供了一些关于被测代码的预期结构和行为的文档。此外,为了让本文适合一般读者,没有代码示例。
模式
我目前已识别的模式大致可分为
- 通过/失败模式
- 集合管理模式
- 数据驱动模式
- 性能模式
- 流程模式
- 模拟模式
- 多线程模式
- 压力测试模式
再次强调,这些都是大笔触。根据我的研究,这似乎是一个相当新的领域。
通过/失败模式
这些模式是您保证代码质量的第一道防线(或攻击,取决于您的观点)。但请注意,它们在告诉您代码方面具有欺骗性。
简单测试模式
通过/失败单元测试是最简单的模式,也是我最关心其有效性的一种单元测试模式。当单元测试通过一个简单测试时,它只告诉我,如果我给被测代码与单元测试完全相同的输入,代码就会工作。一个测试错误陷阱的单元测试也类似——它只告诉我,在与单元测试相同的条件下,代码会正确地捕捉错误。在这两种情况下,我都没有信心代码能在任何其他条件下正确工作,也不能在任何其他错误条件下正确捕捉错误。这真的只是基本逻辑。然而,在这种情况下,你会听到很多人在单元测试树上的所有节点都变成绿色时,高喊“它通过了!”。
代码路径模式
简单测试模式是典型的“黑盒测试”。在不检查代码的情况下,你所能做的就是对被测代码可能遇到的情况(包括成功案例和失败案例)进行有根据的猜测,并对这些猜测进行测试。一个更好的测试可以确保至少所有代码路径都得到执行。这是“白盒测试”的一部分——了解被测试代码的内部工作原理。这里的优先级不是设置通过/失败的测试条件,而是设置测试代码路径的条件。然后将结果与给定代码路径的预期输出进行比较。但现在我们遇到一个问题——当代码还没有编写时,如何进行白盒测试(测试代码路径)?在这里,我们立即面临着“先设计后编码”这把双刃剑的另一面。这里的规范,以及单元测试通过强制进行一些前期设计所带来的好处是,单元测试可以测试实现者可能不会通常考虑的代码路径。此外,单元测试精确地记录了代码路径应该做什么。反过来,当发现单元测试没有预见到的代码路径时,在实现过程中需要规范——是时候修复单元测试了!
参数范围模式
尽管如此,上述测试虽然改进了简单测试模式,但并不能让我相信代码能够处理各种通过/失败条件。为了做到这一点,代码应该使用一系列条件进行测试。参数范围模式通过向代码路径模式提供多个参数集来实现这一点。现在我终于开始相信被测代码实际上可以在各种环境和条件下工作。
数据驱动测试模式
构建参数范围单元测试对于某些类型的测试是可行的,但是当使用单元测试本身生成的复杂排列集来测试一段代码时,它会变得效率低下且复杂。数据驱动测试模式通过将测试数据与测试分离来降低这种复杂性。测试数据现在可以独立于测试生成(这本身可能是一个耗时的任务)和修改。
简单测试数据模式
在最简单的情况下,一套测试数据会被迭代用于测试代码,并且预期会得到一个直接的结果(通过或失败)。计算结果可以在单元测试本身中完成,也可以随数据集提供。结果不允许有差异。这类简单测试数据模式的例子包括校验和计算、数学算法和简单的商业数学计算。更复杂的例子包括加密算法和无损编码或压缩算法。
数据转换测试模式
数据转换测试模式适用于需要对结果进行定性测量的场景。这通常应用于有损压缩等转换算法。例如,在这种情况下,单元测试可能需要衡量算法在压缩率与数据丢失方面的性能。单元测试可能还需要验证数据是否可以在一定容差范围内转换回与输入数据相似的内容。这种单元测试还有其他应用——一个偏袒商家而不是顾客的四舍五入算法就是一个简单的例子。另一个例子是精度。精度在商业中经常出现——税收、利息、百分比等的计算,所有这些最终都必须四舍五入到分或美元,但如果在整个计算过程中精度没有得到正确管理,可能会对最终值产生巨大影响。
数据事务模式
数据事务模式是解决数据持久性和通信问题的开端。关于这个主题的更多讨论将在“模拟模式”下进行。此外,这些模式有意省略了压力测试,例如服务器上的负载。这将在“压力测试模式”下讨论。
简单数据 I/O 模式
这是一种简单的数据事务模式,其作用仅限于验证服务的读/写功能。它可以与简单测试数据模式结合使用,以便将一组数据传递给服务并读回,从而使事务测试更加健壮。
约束数据模式
约束数据模式通过测试服务的更多方面以及服务可能包含的任何规则来增强简单数据 I/O 模式的健壮性。约束通常包括:
- 可为空
- 必须唯一
- 默认值
- 外键关系
- 更新时级联
- 删除时级联
如示意图所示,这些约束是根据数据库服务中常见的约束建模的,并且是“写入”导向的。此单元测试旨在验证服务实现本身,无论是数据库模式、Web 服务还是使用约束来提高数据完整性的其他模型。
回滚模式

回滚模式是其他事务测试模式的辅助。虽然单元测试应该不考虑顺序执行,但在使用数据库或其他持久存储服务时,这会带来问题。一个单元测试可能会更改数据集,导致另一个单元测试不恰当地失败。大多数事务单元测试应具备将数据集回滚到已知状态的能力。这可能还需要在单元测试开始时将数据集**设置**为已知状态。出于性能原因,最好在测试套件开始时而不是在每个测试中将数据集配置为已知状态,并使用服务的回滚功能为每个测试恢复该状态(假设服务提供回滚功能)。
集合管理模式
许多应用程序所做的就是管理信息集合。虽然程序员可以使用各种集合,但验证(并因此记录)代码正在使用正确的集合非常重要。这会影响排序和约束。
集合排序模式
这是一个简单的模式,用于验证给定无序列表时的预期结果。测试验证结果是否符合预期
- 无序
- 有序
- 与输入序列相同
这为实现者提供了关于容器如何管理集合的关键信息。
枚举模式
此模式验证枚举或集合遍历的问题。例如,集合可能需要向前和向后遍历。当集合是非线性的,例如树节点集合时,这是一个重要的测试。边界条件也很重要——当集合枚举到集合中第一个或最后一个项目之外时会发生什么?
集合约束模式
此模式验证容器是否处理约束冲突:空值和插入重复键。此模式通常仅适用于键值对集合。
集合索引模式
索引测试验证并记录集合容器必须支持的索引方法——按索引和/或按键。此外,它们还验证利用索引的更新和删除事务是否正常工作,并受到保护以防止索引丢失。
性能模式
单元测试不应只关注功能,还应关注形式。被测代码执行其功能的效率如何?速度如何?它使用了多少内存?它是否有效地权衡了数据插入和数据检索?它是否正确释放了资源?这些都是单元测试的范畴。通过在单元测试中包含性能模式,实现者有了一个要实现的目标,从而产生更好的代码、更好的应用程序和更满意的客户。
性能测试模式

可以衡量的基本性能类型有
- 内存使用情况(物理内存、缓存、虚拟内存)
- 资源(句柄)利用率
- 磁盘利用率(物理、缓存)
- 算法性能(插入、检索、索引和操作)
请注意,有些语言和操作系统使得获取这些信息变得困难。例如,C# 语言及其垃圾回收机制在测量内存利用率方面相当难以处理。此外,为了获得有意义的指标,此模式通常必须与简单测试数据模式结合使用,以便指标可以测量整个数据集。请注意,即时编译使得性能测量变得困难,自然不稳定的环境(最值得注意的是网络)也是如此。我在关于高级单元测试系列的第四篇文章中讨论了性能和内存检测的问题,该文章位于 https://codeproject.org.cn/csharp/autp4.asp。
流程模式
单元测试旨在测试“单元”……即应用程序的基本功能。有人可能会争辩说,测试过程应该留给验收测试程序,但我不同意这种说法。一个过程只是一种不同类型的单元。使用单元测试器测试过程与其他单元测试提供了相同的优势——它记录了过程的预期工作方式,并且单元测试器还可以通过不按顺序测试过程来帮助实现者,同时快速识别潜在的用户界面问题。“过程”一词也包括状态转换和业务规则,两者都必须经过验证。
流程顺序模式
此模式验证代码按顺序执行时的预期行为,并验证代码不按顺序执行时的问题是否得到正确捕获。过程序列模式也适用于数据事务模式——它不是强制回滚、重置数据集或加载一个全新的数据集,而是一个过程可以在上一步工作的基础上构建,从而提高单元测试结构的性能和可维护性。
流程状态模式
状态的概念不能与过程解耦。管理状态的全部意义在于,过程可以从一个状态顺利地转换到另一个状态,执行任何期望的活动。特别是在无状态系统(如 Web 应用程序)中,状态(如会话状态)的概念对于测试非常重要。要实现在没有复杂客户端-服务器设置和手动操作的情况下完成此任务,需要一个能够理解状态和允许的转换,并且可能还需要使用模拟对象来模拟复杂客户端-服务器环境的单元测试器。
流程规则模式
此测试类似于代码路径模式——目的是验证系统中的每个业务规则。要实现此类测试,业务规则确实需要与周围代码正确解耦——它们不能嵌入到表示层或数据访问层中。正如我在其他地方所说,这只是良好的编码习惯,但我总是惊讶于我遇到的代码有多少违反了这些简单准则,导致代码很难以离散单元进行测试。请注意,这是单元测试的另一个好处——它强制实现高水平的模块化和解耦。
模拟模式
数据事务很难测试,因为它们通常需要预设配置、开放连接和/或在线设备(仅举几例)。模拟对象可以通过模拟代码正在进行事务处理的数据库、Web 服务、用户事件、连接和/或硬件来解决问题。模拟对象还能够创建在现实世界中很难重现的故障条件——有损连接、缓慢的服务器、故障的网络集线器等。但是,要正确使用模拟对象,代码必须使用某些工厂模式来实例化正确的实例——无论是真实对象还是模拟对象。我经常看到代码创建数据库连接并向数据库发送 SQL 语句,所有这些都嵌入在表示层或业务层中!这种代码在没有所有支持系统(预配置的数据库、数据库服务器、数据库连接等)的情况下无法模拟。此外,测试数据事务的结果需要另一个事务,从而创建另一个故障点。单元测试本身不应尽可能地受到其试图测试的代码之外的故障的影响。
模拟对象模式
为了正确使用模拟对象,**必须**使用工厂模式实例化服务连接,并且**必须**使用基类,以便可以使用虚拟方法管理与服务的所有交互。(是的,另外,可以使用面向切面编程实践来建立一个切入点,但 AOP 在许多语言中都不可用)。基本模型是这样的:
要实现这种构造,在编码过程中需要一定的远见和纪律。类需要抽象化,对象必须在工厂中构建而不是直接在代码中实例化,需要使用外观和桥接来支持抽象,并且需要从表示层和业务层中提取数据事务。这些都是良好的编程实践,可以带来更灵活和模块化的实现。当使用模拟对象时,模拟和测试复杂事务和故障条件的灵活性为程序员带来了进一步的优势。
服务模拟模式
此测试模拟服务的连接和 I/O 方法。除了模拟现有服务外,此模式在开发尚未实现功能部件的大型应用程序时也很有用。
比特错误模拟模式
我只在有限的应用程序中使用过这种模式,例如模拟卫星通信中雨衰引起的比特错误。然而,至少考虑一下数据流中的错误将在何处处理是很重要的——它们是由传输层还是由更高层代码处理?如果你正在编写传输层,那么这是一个非常相关的测试模式。
组件模拟模式
在此模式中,模拟对象模拟组件故障,例如网线、集线器或其他设备。经过适当的时间,模拟对象可以做各种事情:
- 抛出异常
- 返回不完整或完全缺失的数据
- 返回“超时”错误
同样,此单元测试记录了被测代码需要处理这些条件。
多线程模式
单元测试多线程应用程序可能是最困难的事情之一,因为你必须设置一个本质上是异步且因此是非确定性的条件。这个主题本身可能就是一篇大文章,所以我这里只提供一个非常通用的模式。此外,为了正确执行许多线程测试,单元测试器应用程序本身必须将测试作为单独的线程执行,这样当一个线程进入等待状态时,单元测试器就不会被禁用。
信号模式
此测试验证工作线程最终会向主线程或另一个工作线程发出信号,然后该线程完成其任务。这可能依赖于其他服务(模拟对象的另一个良好用途)以及两个线程正在操作的数据,因此也涉及其他测试模式。
死锁解决模式
此测试可能非常复杂,因为它需要对工作线程有非常透彻的理解,它验证死锁是否已解决。
压力测试模式
大多数应用程序都在理想环境中进行测试——程序员使用快速机器,网络流量小,使用小型数据集。现实世界则大相径庭。在完全崩溃之前,应用程序可能会出现性能下降,对用户响应缓慢或出现错误。验证代码在压力下性能的单元测试应该与理想环境下的单元测试一样受到重视(如果不是更甚)。
批量数据压力测试模式
此测试旨在验证处理大型数据集时数据操作的性能。这些测试通常会揭示插入、访问和删除过程中的低效率,通常通过审查数据模型的索引、约束和结构来纠正,包括代码应该在客户端还是服务器上运行。
资源压力测试模式
资源消耗压力测试依赖于操作系统的特性,并且使用模拟对象可能会更好。如果操作系统支持模拟低内存、低磁盘空间和其他资源,则可以执行简单的测试。否则,必须使用模拟对象来模拟操作系统在低资源条件下的响应。
加载测试模式
此测试测量当另一台机器、应用程序或线程正在加载“系统”时的代码行为,例如高 CPU 使用率或网络流量。这仅仅是一个模拟(不使用模拟对象),因此其价值值得怀疑。理想情况下,旨在模拟大量网络流量的单元测试将创建一个线程来完成此操作——向网络注入数据包。
表示层模式
单元测试中最具挑战性的方面之一是验证信息是否正确地到达表示层本身的用户,以及应用程序的内部工作原理是否正确地设置了表示层状态。通常,表示层与业务对象、数据对象和控制逻辑纠缠不清。如果您计划对表示层进行单元测试,您必须意识到,清晰的关注点分离是强制性的。解决方案的一部分包括开发一个合适的模型-视图-控制器 (MVC) 架构。MVC 架构提供了一种在处理表示层时开发良好设计实践的方法。然而,它很容易被滥用。需要一定的纪律来确保您实际上是正确地实现了 MVC 架构,而不仅仅是口头上的实现。
Sun Microsystems 有一个网页,我认为它是 MVC 架构的圣经:http://java.sun.com/blueprints/patterns/MVC-detailed.html。这里总结一下 MVC 模式:
使用事件进行模型通知更改和用户手势(例如点击按钮)至关重要。如果您不使用事件,模型就会崩溃,因为您无法轻松切换视图以根据特定的表示层要求调整表示。此外,没有事件,对象就会纠缠不清。此外,事件(例如由事件池管理)允许进行检测和简化调试。我在实践中发现的唯一例外是,有时模型中的状态更改可能会被控制器中的事件而不是视图中的事件捕获。某些模型更改(例如用户授权)是无视图的,但最终会影响控制器的其他方面。
视图状态测试模式
此测试验证,对于模型状态的更改,视图是否适当地更改状态。
此测试仅执行 MVC 模式的一半——模型事件通知视图,以及视图管理这些事件对表示层内容和状态的影响。控制器在此测试模式中不起作用。
模型状态测试模式
一旦我们使用视图状态模式验证了应用程序的性能,我们就可以进入更复杂的模式。在此模式中,单元测试通过直接在表示层中设置状态和内容来模拟用户手势,并在必要时调用状态更改事件,例如“KeyUp”、“Click”等。
如上图所示,单元测试验证模型状态是否已适当更改,并且预期事件已触发。此外,视图的事件可以被挂钩并验证是否针对模拟的用户手势正确触发。此测试可能需要对模型本身进行一些设置,并将控制器视为黑盒,但是可以检查模型状态以确定控制器是否正在适当管理模型状态。
结论
本文描述了 24 种测试模式,希望这些模式能使单元测试技术更接近于一种更正式的工程学科。在编写单元测试时,回顾这些模式应该有助于识别要编写的单元测试类型,以及该单元测试的有用性。它还允许开发人员选择单元测试需要多么详细——并非每段代码都需要进行压力测试,这样做也不划算。
从本文中可以看出,我们迫切需要一些能够自动生成单元测试的工具。我并不太推崇代码生成器,但我可以看到单元测试将从代码生成中真正受益。它几乎可以消除基于成本和有效性的所有反对单元测试的论点,并且还可以用作应用程序生成工具——给定单元测试信息,代码生成器还可以创建应用程序代码类、结构和存根。