65.9K
CodeProject 正在变化。 阅读更多。
Home

关于设计、测试以及为什么有些单元测试是浪费

starIconstarIconstarIconstarIconstarIcon

5.00/5 (10投票s)

2021年10月13日

CPOL

24分钟阅读

viewsIcon

11750

本文描述了一种单元测试的替代方法。

免责声明

本文描述了我个人进行单元测试的方法。这种方法并非独创,但我发现将其解释给公众有助于我自己的理解。此外,我尚未在大型项目上尝试过此方法,因此请对关于扩展性的章节持怀疑态度。

“年轻人懂得规则,而老年人懂得例外。”

—— 奥利弗·温德尔·霍姆斯

引言

几年前,我偶然发现了詹姆斯·科普利恩的一篇题为“为什么大多数单元测试是浪费”的论文(后续文章在此)。虽然有些挑衅,但我发现它很有趣,但我当时并没有完全理解詹姆斯试图传达的思想。这篇论文的问题在于它指出了单元测试的缺点,但没有展示如何正确地进行单元测试。由于我现在不再每天编程,我花了几年时间才在我决定测试我的玩具项目“星际漂流者”时偶然发现了詹姆斯所想的。

一个示例应用程序

星际漂流者是一个用 Java 编写并在浏览器中运行的简单太空游戏(在此玩)。其架构如图1所示。

图1. 星际漂流者的架构。

星际漂流者遵循鲍勃大叔的整洁架构,为方便起见,以分层方式呈现。核心层(或企业业务规则层)包含提供空间飞行模拟领域所有所需逻辑的类。此代码不“感知”任何I/O机制或游戏规则,并且仅依赖于Java标准库(尽管整洁架构允许依赖包含专用算法和数据结构的第三方库)。

应用程序层涵盖了原始整洁架构的两个层次。Engine类包含所有游戏逻辑,如开始/停止游戏或计算结果,它相当于整洁架构应用程序业务规则层。此类不“感知”任何I/O机制,并且只依赖于核心层和标准库。Presenter类则负责协调Engine和平台适配层,它相当于整洁架构接口适配器层。作为协调器,此类感知I/O机制的存在(在此例中是GUI和调度器),但它不依赖于任何特定的实现。它自己指定这些功能所需的接口(分离接口模式),并期望这些功能作为构造函数参数传递(控制反转模式)。此类的唯一编译时依赖是Engine类、核心层类和标准库。

最高层是平台适配层,它包括GUI调度器的平台特定实现,以及一个负责将所有组件粘合在一起的引导程序。这一层相当于整洁架构框架与驱动层。此代码依赖于Presenter类以及核心层中的一些类,以及平台特定库(GWT)。

整洁架构多层架构区分开来的最重要因素是,它强烈坚持位于框架与驱动层之下(或被其包围)的所有代码都应平台无关,而框架与驱动层本身不包含任何应用程序逻辑(最好也没有任何复杂逻辑)。对于星际漂流者来说,核心层应用程序层(绿色框)都是平台无关的,应该能够在任何Java实现(例如,桌面、Android或本例中的GWT)上运行。另一方面,作为平台依赖的平台适配层尽可能薄,其中的所有类(红色框)尽可能遵循“谦卑对象”模式。

测试方法

整洁架构应用于星际漂流者,使我能够以一种有趣的方式对其进行自动化测试。单元测试的常见方法是分别测试每个类的每个方法,并为每个所需的依赖项提供模拟。这种方法的问题是,最终会产生爆炸性数量的测试用例(每个类的每个方法都有多个测试用例)以及大量使用模拟框架或手动编写的测试替身(通常每个类都需要在某个时刻被模拟)。这种方法在维护方面成本高昂(需要编写和维护大量测试代码)。它也可能执行缓慢(如果模拟数量庞大,在运行时生成模拟可能会很慢)。为了避免这些问题,我决定通过其“外部API”对整个应用程序进行端到端测试,遵循图2中通用描述的策略。“外部API”应是可自动测试的内部应用程序逻辑与不可测试且可替换的“外部世界”适配器之间建立的API。

图2. 通过“外部API”对整个应用程序进行端到端测试。

为了实现这一点,我通过Presenter类测试了所有内容,将平台适配层替换为手写的测试替身,如图3所示。

图3. 星际漂流者的测试代码架构。

这种方法的优点竟然相当惊人。

精简测试

这种方法的第一个优点是,与“按方法按类”的方法相比,覆盖所有功能所需的测试数量非常少。这是因为Star Drifter的内部层(核心层应用程序层)具有较低的边界面积比(较低的外部复杂性与内部复杂性之比),有时也称为体积表面比。这意味着内部层中有更多的类和方法对平台适配层是私有的,而不是外部可见的。图4展示了这一概念的抽象可视化,其中只有两个内部类(Class 3和Class 10)通过外部层暴露了它们的部分功能。

图4. 边界-面积比的可视化。许多具有复杂交互的内部类隐藏在一个接口相对较小的包容类中。

为了估算星际漂流者的此比率,我决定计算内部层中对平台适配层可见的所有方法的数量,并将此数量除以核心应用程序层中所有非私有方法的数量。表1中显示的结果是0.34,远低于1。

表1. 星际漂流者的边界面积比估算

对于星际漂流者而言,端到端测试方法取得了巨大成功,仅用了23个测试(用@Test注解的方法)就覆盖了所有非GUI功能(我故意没有自动测试GUI)。如果每个类的每个非private方法都单独测试,我可能最终需要维护至少(33+96)*2 = 225个测试用例(假设每个非GUI类的每个非private方法都有一个成功场景测试和一个失败场景测试)。这在测试套件的维护成本上是一个数量级的差异!!!

代码精简

这种测试方法的第二个优点是能够通过删除死代码和冗余代码来精简代码。在编写类的方法时,我习惯性地编写代码来验证参数的值并涵盖所有可能的执行情况(我只是松散地遵循TDD)。如果我编写了多个测试来验证每个类的每个方法的行为,代码覆盖率工具将始终显示被测代码的完全覆盖。通过一次性测试“星际漂流者”的内部层,覆盖率工具帮助我发现许多不需要方法参数验证的地方,因为调用者从一开始就没有传入无效值。此外,我还能够删除一些冗余的执行路径,这些路径在任何可能的情况下都从未执行过。

无需模拟框架

这种方法的第三个优点是,我根本不需要使用模拟框架。由于我只需要模拟两个类,我手动编写了简单的测试替身。如果采用“按方法按类”的测试方式,使用模拟框架是不可避免的。

什么才是好的测试?

为了继续,我需要简短地离题一下,看看在这个话题上最了解的人如何定义好的测试。Kent Beck阐述了好测试的12个特征(视频在此

  • 自动化——测试应该在没有人为干预的情况下运行。
  • 确定性——如果什么都没有改变,测试结果也不应该改变。
  • 隔离性——测试应该返回相同的结果,无论它们以何种顺序运行。
  • 可组合性——应该可以运行整个测试套件的任何子集,如果测试是隔离的,那么我可以运行1个或10个或100个或1,000,000个,并获得相同的结果。
  • 行为导向——测试应该对被测代码的行为变化敏感。如果行为发生变化,测试结果也应该发生变化。
  • 结构不敏感——如果代码结构发生变化,测试结果不应该改变。
  • 快速——测试应该快速运行,并在几秒钟内提供反馈。
  • 可写性——相对于被测代码的成本,测试的编写成本应该很低。
  • 可读性——测试应该对读者来说易于理解,并能激发编写特定测试的动机。
  • 特异性——如果测试失败,失败的原因应该显而易见。
  • 预测性——如果所有测试都通过,那么被测代码应该适合生产。
  • 鼓舞人心——通过测试应该能增强信心。

在接下来的章节中,我将解释其中之一(结构不敏感性)如何影响开发成本。

易于重构

所用测试方法的最后一个优点是易于重构。单元测试可以允许或阻止重构,这取决于它们的编写方式。决定这一点的主要特性是结构不敏感性(Kent Beck 通过一个类的示例来解释这一点)。采用“按方法按类”方法编写的测试在类的内部结构方面可以是(也应该是)结构不敏感的,但当程序员遵循创建代表一个概念并只做一件事的类的实践时,问题就出现了。在这种情况下,类和方法变得非常小。例如,“星际漂流者”核心应用程序层方法的平均代码行数略低于2行,最常见的值是1。对于如此小的方法,大部分功能都是通过各种类的多个对象的协作实现的。在重构时,大部分重构都涉及对类行为及其公共API的更改(将行为从一个类移动到另一个类),即使整个应用程序/模块的行为没有改变。每当发生这种情况时,测试都需要调整。这使得它们脆弱,并有效地对结构敏感。这也使得它们维护成本高昂,容易出错,因为每次更改都可能给测试本身引入错误或无意中更改其语义。通过在边界级别进行测试,我只要对平台适配层可见的行为不变,就可以自由地调整类的职责(在类之间移动方法、引入新类、删除类)。

结构(不)敏感性的分布

为了使测试易于(且便宜地)维护,需要尽可能地保持其结构不敏感。我已表明,编写“按方法按类”的测试并不一定意味着结构不敏感。测试可以针对每个类的内部结构是结构不敏感的,但仍可能针对整个应用程序的内部结构是结构敏感的。这意味着通过“外部API”测试整个应用程序似乎可以产生最结构不敏感的测试。然而,现实往往并非如此简单,而且通常无法(或只是不方便)完全通过“外部API”测试代码的某些部分。在这种情况下,需要编写一些结构敏感的测试。为了保持维护成本低,此类测试的数量应很少,并且它们验证的行为不应经常改变(在引入它们之前应进行快速的成本/效益分析)。图5显示了在一个项目中结构敏感和结构不敏感测试的假设成本最优分布,我猜它遵循某种幂律曲线。

图5. 遵循假设幂律曲线的结构敏感性测试分布。

星际漂流者很好地遵循了这种分布,具体数字如下:

  • 结构不敏感——18个测试
  • 结构敏感,很少变化——5个测试
  • 结构敏感,经常变化——0个测试

星际漂流者中唯一结构敏感的测试是验证重力行为,这通过“外部API”验证起来很不方便。积极的一面是,所验证算法的行为根本不需要改变。

测试的结构

到目前为止,我一直在谈论测试的属性,但尚未展示任何示例。为了深入探讨细节,首先需要揭示选择测试结构背后的基本原理。实现这一目标的第一步是认识到,每个程序的行为都可以从外部描述为状态机(不一定是有限状态机)。有时,状态是应用程序内部的(例如文本编辑器),有时是外部的(例如文件系统工具,其中状态主要存储在文件系统本身中)。每个状态机都可以通过一组状态以及它们之间的转换来描述。自动化测试,根据定义,需要验证在给定初始条件的情况下,状态之间的转换是否发生。单个测试构成了一个或多个此类转换的场景,在通过“外部API”进行测试的情况下,这些场景等同于应用程序实现的用例。由于状态图中通常有许多可能的路径,因此也有许多可能的用例需要测试,如图6所示。我从Kevlin Henney 的一次鼓舞人心的演讲中获得了这种理解,但对其进行了扩展,以涵盖整个应用程序场景。我也喜欢将测试视为用例,因为它让我更容易发现它们。

图6. 有限状态机执行的用例抽象可视化。

图7显示了描述《星际漂流者》游戏的真实状态图,其中两个箭头表示两个编码为测试的示例场景。

图7. 星际漂流者的示例测试场景。

红色箭头表示飞船坠毁在行星上的场景,实现于OnePlanetSpaceUseCases -> presenter_displaysYouLoose_whenCraftCrashesIntoPlanet。该场景包括AimingLaunchingFailed状态之间的转换,验证转换是否按正确顺序发生。绿色箭头表示飞船成功穿越太空的场景,实现于EmptySpaceUseCases -> presenter_displaysYouWin_whenCraftReachesRightEdgeOfSpace。该场景包括AimingLaunchingSuccess状态之间的转换,再次验证转换是否按正确顺序发生。为了使场景尽可能地接近真实用例,我确保了Presenter类的public方法表示用户操作(aimingStarted, aimingFinished, playAgain, playNext, ...),并且GUI和Scheduler类都遵循“谦逊对象”模式,通过所需接口(Presenter.UIPresenter.Scheduler)由Presenter控制。

短场景与长场景

将测试视为用例场景时,就出现了场景长度的问题。较长的场景会减少测试数量,但会使每个测试的“特异性”降低(参见好测试的特征)。同样,解决此问题的最恰当方法似乎是进行成本/效益分析,以平衡测试数量和其“特异性”。涵盖许多转换的长场景可能更适合“ happy path”,而短场景则更适合错误条件(仅涵盖一次转换)。请记住,在创建错误场景时,您仍然希望每个测试执行一个错误条件,但这并不意味着每个测试只能有一个“assert”语句。长场景有两个缺点。首先,可能难以有意义地命名它们。其次,当被测代码的行为发生变化时,由于不可避免的冗余,多个场景将同时失败。长场景的优点是所需的设置较少,因此编写和执行速度可能更快。

命名与结构

我遵循“什么_展现什么行为_在什么条件下”的命名约定(例如,presenter_displaysYouLoose_whenCraftCrashesIntoPlanet)。这读起来很流畅,并且允许在IDE的“文件大纲”视图中进行合理的排序,如图8所示。

图8. IDE的“文件大纲”视图显示测试方法。

我还将测试(用例场景)分组到以“UseCases”为后缀的有意义名称的类中(见图9)。

图9. “项目浏览器”视图中按类分组的所有场景。

分组让我可以快速找到所需的特定场景,而使用“UseCases”使我更容易思考所有可能的场景。我通常从表示一组用例的类名开始,然后尝试通过在类的正文中将它们的名称作为注释来找到所有可能的用例,如下所示。稍后,我才将每个场景名扩展为一个测试方法。

class EmptySpaceUseCases {

	//presenter_createsSpaceCraftAndRefreshesView_whenInitialized
	//presenter_schedulesViewRefreshes_whenActivated
	//presenter_schedulesViewRefreshesWithSpeed_whenAimingStarted
	//presenter_schedulesViewRefreshPlaysLaunchSoundAndMovesCraft_whenAimingFinished
	....
}

在为《星际漂流者》编写独立场景时,我通常遵循既定的模式:

  • GIVEN——在此,我设置夹具。
  • THEN(可选)——我可能会验证夹具是否正确(有时可能有用)。
  • WHEN——在此,我触发从“状态1”到“状态2”的转换。
  • THEN——在此,我测试预期行为是否发生,不希望的行为是否没有发生,以及被测代码与外部世界之间的契约不变量是否仍然成立。
  • WHEN(可选)——在此,我触发从“状态2”到“状态3”的转换。
  • THEN(可选)——等等。

下面的例子展示了这一操作

@Test
public void presenter_displaysYouLoose_whenCraftCrashesIntoPlanet() 

以下是GIVEN部分,我在此初始化被测对象。我总是在测试方法中完成此操作,或将其包装在工厂方法中。我从不将此初始化代码放入@Before方法中,因为这是初始化被测代码的依赖项(本例中是测试替身)的地方。在此示例中,我还清除了假GUI的状态,因为我在单独的场景中测试了它。

	Presenter presenter = new Presenter(this.view, this.spaceFactory, this.scheduler,2);
	this.view.clearAll(); // clear recorded view refreshes so far

以下是第一次WHEN,从“欢迎屏幕显示”状态到“未开始”的转换。它后面没有THEN部分,因为我用不同的测试验证了这次转换。这是一个权衡的例子。如果THEN部分很小(少量断言语句)或者可以提取到一个有意义的辅助方法中,那么将其包含进来并减少测试数量更有意义,否则为转换创建单独的测试更有意义。

	presenter.start();

这里是两个WHEN部分,它们将我们带入“瞄准”状态,然后进入“已发射”状态。

	presenter.aimingStarted(initialCraftPosition.getX() + initialSpeed * speedFactor,
			initialCraftPosition.getY());
	presenter.aimingFinished(initialCraftPosition.getX() + initialSpeed * speedFactor,
			initialCraftPosition.getY());

接下来是第一个THEN部分。我检查被测代码的外部可见不变量是否为真(Presenter和GUI之间的契约不变量,例如“Spacecraft is never null.”)。换句话说,它检查我们不希望发生的事情确实没有发生。这有点冗余,因为我稍后也会调用此方法,但这有助于更早地发现问题。

	this.scheduler.assertThatInvariantsHoldTrue();

这是最后一个WHEN部分,它增加足够的时间使飞船坠毁在行星上。

	this.scheduler.run(oneHundredAndEightTimes);

以下是最后的THEN部分,它验证了适当的GUI调度器方法是否被调用了适当的次数,或者根本没有被调用(同样,我想要确保我不想发生的事情确实没有发生),并且飞船处于适当的状态(它变成了一个火球)。为了提高可读性,我在测试替身(GUI调度器)中引入了辅助方法,并且我用static变量替换了一些数字常量(例如,“private final static int oneTime = 1”)。

	this.view.assertThatAimingEnabledWasCalled(oneTime);
	this.view.assertThatAimingDisabledWasCalled(oneTime);

	this.view.assertThatRefreshWasCalled(oneHundredAndEightTimes);

	this.scheduler.assertThatInvariantsHoldTrue();
	this.scheduler.assertThatCancelWasCalled(oneTime);
	this.scheduler.assertThatSheduleWasCalled(oneTime);

	FakeUI.RefreshRecord record = this.view.refreshCalled.get(oneHundredAndEightTimes-1); 
	record.assertThatNumberOfPlanetsIs(1);
	record.assertThatCraftPositionIs(finalCraftPositionToRight);
	record.assertThatCraftSpeedIs(Speed.zero());
	record.assertThatCraftNameIs("fireball");
	record.assertThatScoreIs(0);
	record.assertThatLevelNumberIs(2);
	Body planet = record.planets.get(0);
	assertEquals("rocky", planet.getName());
	assertEquals(21.599, planet.getAngle(), 0.001);
	assertEquals(0, planet.getPhaseIndex());

	this.view.assertThatLaunchWasPlayed(oneTime);
	this.view.assertThatExplosionWasPlayed(oneTime);
	this.view.assertThatSuccessWasShown(zeroTimes);
	this.view.assertThatFailureWasShown(oneTime);
	this.view.assertThatRefreshWithSpeedWasCalled(zeroTimes);
	this.view.assertThatInvariantsHoldTrue();
	}

在编写测试场景时,我尝试思考所做检查的成本/效益,并努力不检查我不在意的事情。例如,在星际漂流者的例子中,我检查刷新是否被调用了特定次数,或者我确实验证了GUI从未被空参数调用,但我不验证传递给GUI的行星顺序或分数计算的正确性,因为这些对我来说不重要。未定义行为是降低开发成本的一种方式。

扩展

在前面的章节中,我描述了通过“外部API”对一个相对较小的应用程序进行端到端单元测试的实验。不难猜测,这种方法对于实现数百个用例的大型应用程序来说会彻底失败。为了应对这种复杂性,需要采用模块化方法,然后每个模块都需要单独测试,只用少量用例对模块间交互进行冒烟测试。为了实现这一点,模块化需要以特定的方式完成。

应用程序通常遵循经典的层次分解,每个层包含功能的一部分,需要与其他层集成才能提供应用程序支持的用例的完整实现,如图10所示。这种分解存在两个问题:

首先,没有一个模块完全实现任何用例。这意味着此类模块的测试只能覆盖用例的部分,并且不能保证在集成后软件能无缝地实现这些用例。

其次,当特定用例的行为发生变化时,模块间接口也经常发生变化,这与模块作为包含和隔离变化的实体这一理念相悖。这阻碍了开发这些模块的团队利用模块化来提高独立性和生产力,因为需要不断进行团队间协调。

图10. “横向”模块化(分层)。彩色箭头表示每个用例的执行流。

为了克服这些问题,模块应粗粒度并力求“垂直”。这意味着它们应包含多个相关用例从客户端/GUI到数据存储再返回的整个执行流,如图11所示。模块间接口应小而通用,并且相对稳定,以便每个模块都可以由单独的团队开发,而无需不断协调。此外,如果“谦逊对象”模式用于实现客户端/GUI和数据存储/IO,那么测试用例场景将紧密代表需求/故事等描述的应用程序使用场景。

图11. “垂直”模块。彩色箭头表示每个用例的执行流。

大规模测试

为了在多模块应用程序中测试整个模块,如图12所示的特定项目结构可能会有所帮助(使用Java约定)。

图12. 建议的项目结构(使用Java约定)。

假设应用程序中有两个模块,应用程序代码应该分成三个包:一个顶级包“app”,一个包含Module1实现的“app.mod1”包,以及一个包含Module2实现的“app.mod2”包。这遵循了“按功能划分包”的方法,它允许使用“包私有”范围来控制构成特定模块的类和方法的可见性。在实际应用程序中,这些模块可以例如实现客户管理用例、订单处理用例、支付处理用例等等。

模块应通过 GxE(暴露)和 GxR(所需)接口与其 GUI 通信,并通过 Dx 接口与其数据存储通信。模块应通过一系列接口相互通信:I1R 是 Module 1 所需的模块间接口,I2R 是 Module 2 暴露的模块间接口,AE 是由适配器暴露的接口,该适配器实现了 I1R 并调用 I2E,从而有效地解耦了两个模块,并在必要时实现了“有界上下文”模式(等效数据类型的转换)。所有所需接口都应使用“分离接口”模式定义(参阅Presenter.UI 作为示例)。

app”包是一个顶级包,包含应用程序引导和粘合代码。它需要“知道”如何实例化所有模块,将它们连接在一起并使其全部运行。它不应包含任何领域逻辑。它不应向其他包暴露任何接口,因此其他包不应依赖它。它应封装应用程序使用的任何第三方框架。它的所有类通常都应为“包私有”。驻留在此包中的自动化测试应仅为冒烟测试(数量应非常少),验证所有模块和适配器是否协同工作,但不应尝试测试任何实际的应用程序逻辑。

模块包(“app.mod1”或“app.mod2”)应包含实际的应用程序逻辑。这些模块中的大多数类应为“包私有”。只有定义模块接口的类应为public。这些包应包含很少的自动化测试,用于验证通过模块接口难以验证的包私有细节(对于《星际漂流者》来说,只有重力算法是以这种方式验证的)。理想情况下,这些算法不应经常更改。

每个模块包都应该有一个相关的测试包(“*.uc”),其中包含所有针对该模块的结构不敏感测试场景。这种分离使得更容易发现模块实现细节泄露到模块接口中。它还强制测试更注重行为,因为从另一个包访问模块实现的内部细节很困难。这在开始时可能感觉是不必要的限制,但从长远来看,它允许进行廉价的重构。另一个优点是,可以轻松选择只运行当前正在处理的模块的测试(可组合测试)。

保持模块“垂直”并控制模块间接口(Ix 接口)带来了另一个优势。如果添加功能时需要经常更改模块间的接口,则表明模块太小而无法包含有用的功能,或者模块之间的边界放置不正确。模块间更改的频率应显著低于模块内更改(数量级差异)。通常认为,具有适当模块化的良好设计可以封装更改。模块间接口的更改,发生时应遵循以下模式:

  • 最频繁——向方法添加参数(或向作为方法参数传递的对象添加字段);或向“void”方法添加返回值;
  • 不时地——将方法参数(或返回值)命名为更复杂的类型;
  • 几乎从不——添加或删除方法以改变模块之间的交互模式。

最后一个建议是,每个模块最好由一个对象实例化,该对象隐藏内部复杂性并封装模块的行为,以便测试能够完全结构不敏感。

如何获得结构不敏感测试?

通常不可能从一开始就获得结构不敏感的测试,但这种状态可以逐步实现。它始于一个实验阶段,带有一些粗糙的、可废弃的测试。在架构初具雏形但尚未实现任何实质性功能之前,您就开始编写模块测试。然后,您不断重构(测试提供即时反馈),同时向模块添加新功能(如果喜欢,可以遵循TDD)。随着时间的推移,您还会完善模块的接口,保持较低的边界面积比。图13以图形方式展示了这一过程。

图13. 渐进改进方法。

那么,单元测试是浪费吗?

根据詹姆斯·科普利恩的说法

在大多数业务中,唯一具有商业价值的测试是那些源自业务需求的测试。大多数单元测试都源自程序员对功能应如何工作的幻想:他们的希望、刻板印象,或者有时是对事情应如何发展的愿望。这些测试没有可证明的价值。

在我看来,大多数以每个方法每个类单独测试用例形式进行的单元测试,投资回报率很低。如果程序员遵循良好实践,设计小型类,那么大部分应用程序逻辑将通过各种类对象之间的交互来实现。在这种情况下,单元测试在应用程序或模块内部结构方面变得结构敏感,从而阻碍了重构。此外,频繁更改的自动化测试不太可靠,因为每次修改都有非零的概率给测试本身引入错误。因此,我认为通过将测试场景与实际应用程序用例紧密结合来测试整个模块,是一个最佳平衡点(提供最高的投资回报),介于单独测试每个类(这似乎是浪费)和测试整个应用程序(这可能困难且缓慢)之间。

附录A:命名事物

命名事物本身就是一项困难但至关重要的任务,与生物学或医学不同,软件开发领域使用的术语非常有限。测试子领域也不例外。在谈论测试时,总是会对使用什么术语感到困惑。某个特定的测试套件应该被称为单元测试(那么什么是单元?)、集成测试(什么东西被集成?)、系统测试(什么构成了系统?)吗?我个人更喜欢一种抽象于基于代码结构命名法的方法,这种方法更侧重于特定测试套件运行的位置。这让我可以区分出三个测试套件:

  • 开发人员测试——无论抽象级别如何,只要能在开发人员机器上运行并在合理的时间内(最好在几秒钟内)提供反馈以提供有效反馈的任何类型的测试。这些测试是验证一个方法的行为还是整个系统的行为并不重要,只要它们在开发人员机器上运行并且速度快。
  • 实验室测试——任何需要设置整个系统以进行端到端、性能、压力测试等的测试,这些测试运行缓慢(通常需要通宵),无法提供即时反馈。它们可能需要带有专用硬件和硬件存根的物理实验室,或者完全在云端运行的虚拟实验室,但这不会改变它们的性质。
  • 生产测试——通常是在将更改推送到生产环境时执行的验收测试,同时使更改易于回滚。被测试的更改是否对所有用户启用或仅对选定组启用并不重要,只要在用户不喜欢时可以轻松停用它。

历史

  • 2021年10月13日:初始版本
© . All rights reserved.