第 7 章 测试策略
到目前为止,在本书中我们一直在讨论 pytest 的机制——软件测试的 “如何编写测试” 部分——包括编写测试函数、使用夹具和实现参数化测试。 在本章中,我们将使用您迄今为止学到的有关 pytest 的所有知识来为 Cards 项目创建测试策略——软件测试的 “编写什么测试” 部分。
我们将从定义测试套件的目标开始。然后我们将了解 Cards 的软件架构如何影响我们的测试策略以及测试需求的影响。 然后我们可以开始选择要测试的功能并确定其优先级。 一旦我们知道哪些功能需要测试,我们就可以生成所需测试用例的列表。 所有这些有条不紊的计划确实不需要很长时间,并且将有助于生成一个相当不错的初始测试套件。
尽管这并不是对整个软件测试策略的全面审视(这本身就是一本书),但研究单个项目的可能测试策略可以帮助您确定适合自己项目的最佳测试策略。
确定测试范围
不同的项目有不同的测试目标和要求。心脏监测系统、空中交通管制系统和智能制动系统等关键系统需要在各个级别进行详尽的测试。然后还有制作 GIF 动画的工具。大多数软件都介于两者之间。
我们几乎总是想测试用户可见功能的行为。然而,在确定需要进行多少测试时,我们还需要考虑很多其他问题:
-
安全是一个问题吗?如果您保存任何机密信息,这一点尤其重要。
-
性能?交互需要很快吗?多快?
-
负载?你能处理很多人提出的很多要求吗?你期待需要吗?如果是这样,您应该进行测试。
-
输入验证?对于任何接受用户输入的系统,我们应该在对其采取行动之前验证数据。
Cards 项目适合个人或小型团队使用。即便如此,在现实中,上述所有问题都适用于该项目,尤其是随着项目的发展。那么,对于初始测试套件,我们应该做多少测试呢?以下是一个合理的起点:
-
测试用户可见功能的行为。
-
推迟当前设计的安全、性能和负载测试。 当前的设计是将数据库存储在用户主目录中。 当/如果移动到与多个用户共享的位置时,这些问题肯定会更加重要。
-
当 Cards 是一个单用户应用程序时,输入验证也就不那么重要了。不过,我也不希望在使用应用程序时出现堆栈跟踪,所以我们应该测试古怪的输入,至少在 CLI 层级。
所有项目都需要进行功能或特性测试。然而,即使只进行功能测试,我们也需要决定哪些功能需要测试,以及测试的优先级。然后,针对每个功能,我们需要确定测试用例。
使用有条不紊的方法可以让这一切变得简单明了。下面我们就以 Cards 项目为例,对所有这些进行说明。首先,我们将确定功能的优先级,然后生成测试用例。但首先,让我们来看看项目的软件架构会如何影响您所选择的测试策略。
考虑软件体系结构
在确定测试策略时,应用程序的设置方式(其软件架构)是一个重要的考虑因素。 软件架构涉及项目软件的组织方式、可用的 API、接口是什么、代码复杂性所在、模块化等等。 关于测试,我们需要知道需要测试系统的多少部分以及入口点是什么。
举一个简单的例子,假设我们正在测试一个模块中存在的代码,该代码旨在在命令行上使用,除了打印输出之外没有交互组件,并且没有 API。 而且,它不是用 Python 编写的。 那时我们别无选择。 我们唯一的选择是将其作为黑匣子进行测试。 我们将让测试代码使用不同的参数和状态调用它并观察输出。
如果代码是用 Python 编写的并且是可导入的,并且我们可以通过调用模块内的函数来测试它的不同部分,那么我们就有选择。 我们仍然可以像以前一样测试它,作为一个黑匣子。 但如果我们愿意的话,我们也可以单独测试里面的功能。
这个概念具有很好的扩展性。 如果被测软件被设计成一个包含很多子模块的 Python 包,我们仍然可以在 CLI 级别进行测试,或者我们可以放大一点测试模块,或者我们可以进一步放大测试模块内的功能 。 再扩大规模,我们就有了更大的系统,这些系统被设计为交互子系统,每个子系统可能具有多个包和模块。
所有这些都在很多方面影响我们的测试策略:
-
我们应该在哪个层面进行测试?顶级用户界面?下层?子系统?所有级别?
-
不同级别的测试有多容易?用户界面测试通常是最困难的,但也更容易与客户功能相联系。单个功能的测试可能更容易实施,但更难与客户需求挂钩。
-
谁负责不同层次的测试?如果您提供的是子系统,您是否只负责该子系统?是否由其他人负责系统测试?如果是这样,选择很简单:测试自己的子系统。不过,最好至少能参与了解系统级的测试内容。
让我们稍微简化一下。 假设您和您的团队负责整个工作,并且您的软件是分层构建的。 你在顶部有一个逻辑超薄的 UI,调用 API 层,并调用系统中的任何其他内容。 其余的代码可能是一个巨大的单个文件或精心设计的子系统和模块。
然后,您基本上可以针对 API 进行系统测试,并对 UI 进行一些最低限度的测试,以确保它正确调用 API。 然后,您可以在 UI 级别进行一些高级测试作为系统测试,并将测试工作重点放在 API 上。
这个简化的系统就是我们的卡片系统。 Cards 项目分三层实现:(1) CLI 位于 cli.py 中,(2) API 位于 api.py 中,(3) 数据库层位于 db.py 中。
CLI 在 cli.py 中实现。 它依赖于两个第三方软件包:Typer 是一个用于构建 CLI 的工具,Rich 可以做很多很棒的富文本终端功能,但我们只是将它用于漂亮的表格。 CLI 有意尽可能精简,几乎所有逻辑都传递给 API。
与底层数据库的交互是在 db.py 中处理的。 它有一个第三方依赖项 TinyDB,它是底层数据库。 它也尽可能薄。
cli.py 和 db.py 都尽可能精简,原因如下:
-
通过 API 测试大部分系统和逻辑。
-
第三方依赖项被隔离到单个文件中。
隔离第三方软件包有几个好处。如果由于这些依赖包的接口变化而需要更改任何内容,这些更改将被隔离到一个文件中。这甚至可能包括将依赖包换成其他东西。例如,如果我们想尝试不同的数据库后端,我们可以使用 db.py 创建一个测试套件作为入口,然后更改数据库,并在 db.py 中进行必要的适配器修改。
在 Cards 中,保持 cli.py 薄的主要原因是允许大部分测试针对 API。对于 db.py,主要原因是允许对我们对任何底层数据库的期望进行隔离测试。
这与测试策略有什么关系?有几个方面:
-
因为 CLI 的逻辑很薄弱,所以我们可以通过 API 测试几乎所有内容。
-
对 CLI 进行足够的测试以验证它调用正确的 API 入口点就足够了。
-
由于数据库交互与 db.py 隔离,因此如果我们认为有必要,我们可以在该层添加子系统测试。
即使我们通过 API 进行测试,我们也希望将测试工作集中在可见的最终用户行为上,而不是迷失在测试实施中。 因此,这是一个可行的 Cards 测试策略:
-
测试用户可访问的功能——在 CLI 中可见的功能。
-
通过 API 而不是通过 CLI 测试这些功能。
-
对 CLI 进行足够的测试,以验证其是否正确连接到 API。
这似乎是一个不错的起点。我们现在可以推迟对数据库的单独测试。接下来,让我们看一下用户可见的功能来决定测试什么。
评估要测试的功能
在创建要测试的案例之前,我们首先需要评估要测试的功能。 当您有很多功能和特性需要测试时,您必须优先考虑开发测试的顺序。 至少对顺序有一个大概的了解会有所帮助。
我通常根据以下因素优先考虑要测试的功能:
-
最近——最近修复、重构或以其他方式修改的新功能、新代码区域、新功能
-
核心——您产品的独特销售主张(USPs)。为了让产品有用,必须继续工作的基本功能
-
风险——应用程序中带来更多风险的区域,例如对客户很重要但开发团队不经常使用的区域或使用第三方代码的部分 您不太信任
-
问题——经常出现故障或经常收到缺陷报告的功能
-
专业知识——少数人能够理解的功能或算法
Cards 具有有限的功能集。以下是最终用户可见的功能:
$ cards --help
Usage: cards [OPTIONS] COMMAND [ARGS]...
Cards is a small command line task tracking application.
Options:
--help Show this message and exit.
Commands:
add Add a card to db.
config List the path to the Cards db.
count Return number of cards in db.
delete Remove card in db with given id.
finish Set a card state to 'done'.
list List cards in db.
start Set a card state to 'in prog'.
update Modify a card in db with given id with new info.
version Return version of cards application
因为我们将 Cards 项目视为需要测试的遗留系统,所以其中一些标准比其他标准更有帮助:
-
Core
-
add、count、delete、finish、list、start 和 update 似乎都是核心功能。
-
config 和 version 似乎不太重要。
-
-
Risk
-
第三方软件包是用于 CLI 的 Typer 和用于数据库的 TinyDB。 围绕我们对这些组件的使用进行一些集中测试是谨慎的做法。 当我们测试 CLI 时,我们会测试 Typer 的使用情况。 我们对 TinyDB 的使用将在所有其他测试中进行真正的测试,并且由于 db.py 隔离了我们与 TinyDB 的交互,因此如果需要,我们可以创建专注于该层的测试。
-
由于功能集很小,我们将实际测试所有 Cards 项目。 然而,即使是这种对功能的快速分析也可以帮助我们制定策略:
-
彻底测试核心功能。
-
使用至少一个测试用例测试非核心功能。
-
隔离测试 CLI。
现在让我们采用这个计划并生成测试用例。
创建测试用例
与确定测试策略的目标和范围一样,如果采取有条不紊的方法,生成测试用例也会更容易。为了生成一组初始测试用例,这些标准将很有帮助:
-
从一个不平凡的 “快乐路径” 测试用例开始。
-
然后查看代表的测试用例
-
有趣的输入集,
-
有趣的起始状态,
-
有趣的最终状态,或
-
所有可能的错误状态。
-
其中一些测试用例会重叠。 如果测试用例满足上述标准之一以上,那就没问题了。 让我们通过一些卡片功能来掌握它的窍门。
对于 count,一个令人满意的路径测试用例可能是:"对于空数据库,count 返回 0"。不过,我也认为这是一个微不足道的例子。它似乎测试不了什么。如果 count 被硬编码为返回 0 呢?因此,对于一个体面的、非琐碎的、快乐路径的示例,我们可以这样说:
-
对于包含三个元素的数据库,
count
返回 3。
有哪些有趣的输入集? 没有任何输入集。 count 不带任何参数。
有哪些有趣的起始状态? 我会说:
-
空数据库
-
一行数据
-
多行数据
有趣的结局状态? 没有任何。 count 不会修改数据库。
错误状态? 我也想不到。
因此,对于 count,我们有这些测试用例:
-
从空数据库中计数
-
计数一行数据项
-
计数多行数据项
因为最后一个测试满足了我们的快乐路径测试用例,所以我们可以只保留这三个测试用例。
实际上,由其他标准生成的测试用例中的一个通常就能满足快乐路径的要求。那么,我们为什么要特别考虑非三维快乐路径测试用例呢?原因有几个。首先,如果我们很匆忙,我们可以只创建非三乘、快乐路径测试用例,为我们正在测试的每个功能创建一个测试用例。这并不是一个全面的测试套件。不过,它能以最少的工作量测试系统的大部分功能。我曾多次从这里开始,在开发过程中建立了更多的测试用例。
从 "快乐路径" 开始测试的第二个原因是,它能让我们更容易地考虑其他标准。如果一开始就考虑所有可能出错的情况,可能会忘记测试正确的情况。
现在让我们来看看添加和删除。
对于添加(add),下面是帮助文本:
$ cards add --help
Usage: cards add [OPTIONS] SUMMARY...
Add a card to db.
Arguments:
SUMMARY... [required]
Options:
-o, --owner TEXT
--help Show this message and exit.
在非空数据库中添加一张卡片可能是一个非难处理的好办法。摘要是必需的,而传入的所有者是可选的。因此,我们既要测试单独的摘要,也要测试摘要加所有者。如果不输入摘要呢?这属于错误条件。所有者的空文本也是如此。如果我们添加的卡片的摘要和所有者与已存在的卡片相匹配怎么办?应该允许还是拒绝将其作为错误状态?这个问题凸显了在开发过程中编写测试的价值,或者至少是在行为和应用程序接口还没有发展到可以在不影响现有用户的情况下轻易改变之前编写测试的价值。行为应该是什么?Cards 应用程序允许重复。但无论哪种答案都是合理的。不过,我们还是应该进行测试。
以下是我们用于添加(add)的测试用例:
-
添加到空数据库,并附有摘要
-
添加到非空数据库,并带有摘要
-
添加一张包含摘要和所有者集的卡片
-
添加一张缺少摘要的卡片
-
添加重复卡片
现在要删除(delete),下面帮助文本:
cards delete --help
Usage: cards delete [OPTIONS] CARD_ID
Remove card in db with given id.
Arguments:
CARD_ID [required]
Options:
--help Show this message and exit.
对于一个非复杂的快乐路径测试用例,让我们从不止一张卡开始,然后删除一张。唯一的输入是卡片 ID。有趣的选项可能是存在的 ID 和不存在的 ID。有趣的起始状态可能是空的、有要删除的卡片的非空状态,以及没有卡片的非空状态。结束状态最终会作为一个有用的标准出现,因为删除操作会让我们从非空状态变为空状态。对于错误条件,我认为只有删除不存在的卡片这一种。
以下是删除(delete)的测试案例:
-
从具有多个数据的数据库中删除一个
-
删除最后一张 card
-
删除不存在的 card
到目前为止,我们已经有了添加(add)、删除(delete)和计数(count)的测试用例。让我们一起来看看开始(start)和结束(finish)。因为这些函数会改变单张卡片的状态,所以查看卡片状态比查看数据库状态更有趣。卡片的可能状态有 "待办事项(todo)"、"进行中(in prog)" 和 "已完成(done)"。所有状态看起来都很有趣。就像删除一样,你需要输入一个你想要开始或完成的卡片的 ID。我们应该测试现有的 ID 和不存在的 ID。这就为我们带来了这些新的测试用例:
-
从 “todo”、“in prog” 和 “done” 状态开始
-
启动无效ID
-
从 “todo”、“in prog” 和 “done” 状态完成
-
完成无效ID
我们还剩下更新(update)、列表(list)、配置(config)和版本(version)。如果你想练习这种技巧,我建议你在继续阅读之前先自己尝试一下,看看你的列表是否与我的不同。
下面是我对剩余功能的总结:
-
更新卡的所有者
-
更新卡片摘要
-
同时更新卡的所有者和摘要
-
更新不存在的卡
-
来自空数据库的列表
-
来自非空数据库的列表
-
config 返回正确的数据库路径
-
version 返回正确的版本
这是一组相当不错的测试用例。 请注意,这些不是详细的测试描述。 当我们实现测试用例时,可能会出现关于正确行为到底是什么的问题。 那太棒了。 这些问题通常会引发沟通、设计清晰度和 API 完整性。 它们还可以帮助确定文档中的漏洞。
最初的测试用例列表也不完整。 当我们完成测试编写时,我们不可避免地会提出更多的测试用例。 如果您正在与团队合作,这也是获得团队反馈的好时机。 此阶段测试用例的非正式性质允许对行为进行讨论,而不会迷失在代码的细节中。
可能仍然缺少一些完成测试写作所需的信息。 例如,如果预计会出现异常,那么具体的异常是什么? 缺少信息是可以接受的,尤其是在正在测试的代码的 API 尚未最终确定的情况下。 如果您在此阶段与团队中的领域专家讨论测试用例列表,那么当您在编写测试时遇到具体问题时,他们将准备好回答有关具体问题的问题。
在检查要测试的功能并生成初始测试用例列表的计划工作之后,您可能想直接开始编写测试。 然而,停下来写下我们迄今为止所做的工作是个好主意。
编写测试策略
在本章的前面部分,我们决定大部分测试将通过 API 进行。CLI 的测试足以确保它能正确调用 API。我们将暂时放弃数据库测试。如果我们想在迁移到新数据库包时拥有一套有用的测试,我们可以稍后再进行测试。
即使对我们的测试策略进行了这样的快速总结,一旦我们开始测试,还是很容易忘记细节。因此,我非常喜欢把测试策略写下来,以便日后参考。如果您与团队一起工作,即使只有两个人,写下来也尤为重要。
以下是当前的 Cards 测试策略:
-
测试可通过最终用户界面(CLI)访问的行为和功能。
-
尽可能通过 API 测试这些功能。
-
对 CLI 进行足够的测试,以验证所有功能是否正确调用 API。
-
彻底测试以下核心功能:添加(add)、计数(count)、删除(delete)、完成(finish)、列出(list)、启动(start)和更新(update)。
-
包括对配置(config)和版本(version)的粗略测试。
-
通过针对 db.py 的子系统测试来测试我们对 TinyDB 的使用。
此外,我们不会在此列出,但如果您要在文档或内部维基或其他文件中与团队共享策略,请务必包含初始测试用例列表。
我们知道,随着测试的进行,我们可能会扩展最初的策略。每当我们觉得需要改变时,就是与团队讨论改变的大好时机。
花时间写下要测试的功能、最初的测试用例列表和测试策略是前期工作,但当我们通过实施测试(这是下一步工作)很快就能收回成本。
回顾
在本章中,我们探讨了为 "卡片" 项目开发初始测试套件和测试策略的问题。我们首先查看了系统架构,并决定应在哪一层进行测试。然后,我们研究了要测试的功能,并根据以下因素排定了优先顺序:
-
最近——最近修复、重构或以其他方式修改的新功能、新代码区域、新功能
-
核心——您产品的独特销售主张(USP)。 为了让产品有用,必须继续工作的基本功能
-
风险——应用程序中带来更多风险的区域,例如对客户很重要但开发团队不经常使用的区域或使用第三方代码的部分 您不太信任
-
有问题——经常出现故障或经常收到缺陷报告的功能
-
专业知识——少数人能够理解的功能或算法
然后,对于每个功能,我们使用以下标准列出了测试用例:
-
从一个不平凡的快乐路径测试用例开始。
-
然后查看代表的测试用例
-
有趣的输入集,
-
有趣的起始状态,
-
有趣的最终状态,或
-
所有可能的错误状态。
-
最后,我们写下了我们正在测试的功能、初始测试用例列表以及总体测试策略,以便我们以后讨论和参考。
练习
在编写自动化测试时,以下是常见的错误:
-
只编写快乐路径测试用例
-
花太多时间思考事情会如何出错
-
忽略行为如何根据系统或组件状态发生变化
与许多复杂的活动一样,编写一个全面而高效的测试套件最难的部分在于开始工作和获取测试用例的初始列表。本章所涉及的方法应反复练习,使其成为你的第二天性。
这些策略最棒的地方在于,你可以在任何项目中进行练习。通过这些练习可以帮助你学习如何思考行为。哪怕是在两到三个你使用过但没有构建的项目上进行练习,也会在你需要为自己的软件提出测试用例时对你有所帮助。
-
选择一个您熟悉的软件项目。 这可能是您编写或帮助编写的内容,也可能是您经常使用的某些软件。
-
描述一两个用户可访问的功能。
-
写下这些功能的测试用例。 有哪些有趣的起始状态? 是否存在可能的错误情况? 结束状态重要吗? 您应该尝试什么输入?
-
如果该项目是您自己的项目,或者是可通过 pip 安装的 Python 包,请尝试编写这些测试用例。