测试 101:测试简介
这篇文章最初发表于Siv Scripts
编程就是编写代码来解决问题。软件工程是运用结构化流程解决问题的实践。作为工程师,我们希望拥有一个可以根据需要更改、扩展和重构的代码库。测试可以确保我们的程序按预期运行,并且对代码库的更改不会破坏现有功能。
在我上一份工作中,我和一位高级工程师合作构建了一个基于微服务的后端,以取代我们现有的 Django 单体应用。这是一个全新的项目,我们被鼓励尝试新事物。当时我正在阅读《用 pytest 进行 Python 测试》,并说服了高级工程师允许我将pytest引入到我们的项目中。这对我来说是一个意外的收获,因为它迫使我主导编写了最初的测试集,我们将其作为所有服务的模板。
这段经历强化了《程序员修炼之道》中强调的原则。测试内容、测试方式以及测试时间都务实;我们应该利用工具和技术,尽可能高效地测试代码。测试需要简单易行,没有障碍;一旦测试感觉像是一件苦差事,程序员就不会去做……软件质量就会因此下滑。
我们害怕深入代码,因为要么没有测试,要么现有的测试太脆弱,以至于我们不得不一边写代码一边重写测试。这不符合软件工程的本质。测试应该支持重构,而不是妨碍我们修改代码库。我们应该把时间花在编写业务逻辑上,而不是花在测试上。
测试就像民间传说,最佳实践和技术在团队合作中一代一代地传承下来。如果你是这个行业的新手,正在努力钻研测试,那么很难找到入门的方法。感觉市面上有很多相互矛盾的建议,而这恰恰是事实。测试比其他任何软件工程学科都更具主观性。人们总是在争论测试什么、如何测试,尤其是何时测试。
这是系列文章的第一篇,详细介绍了我如何在代码库中添加测试的思路。在这篇文章中,我将对测试的世界进行一个大致的介绍,以便我们为以后的文章提供一个通用的词汇。
目录
什么是测试
编写代码时,我们需要运行它来确保它按照我们的预期运行。测试就像是与代码之间的一种契约:给定一个值,我们期望它返回一个确定的结果。
运行测试可以被认为是一种反馈机制,它告诉我们程序是否按预期运行:
虽然测试通过并不能证明没有 bug,但它们确实表明代码正在按照测试定义的方式运行。相反,测试失败则表明存在问题。我们需要了解测试失败的原因,以便根据需要修改代码和/或测试。
测试的属性
1. 快速
测试让我们确信代码按预期运行。较慢的反馈循环会阻碍开发,因为我们需要更长的时间才能确定更改是否正确。如果我们的工作流程受到缓慢测试的困扰,我们就不会经常运行它们。这会导致后续问题。
2.确定性
测试应该是确定性的,也就是说,相同的输入总是会产生相同的输出。如果测试是非确定性的,我们就必须找到一种方法来解释测试中的随机行为。
虽然生产环境中肯定存在非确定性代码(例如机器学习和人工智能),但我们应该尽量使所有非概率代码尽可能具有确定性。除非我们的程序需要,否则做额外的工作毫无意义。
3.自动化
我们可以通过运行程序来确认它是否正常工作。这可以是手动在REPL中运行命令,也可以是刷新网页;在这两种情况下,我们都在查看程序是否按预期运行。虽然手动测试对于小型项目来说还算可以,但随着项目复杂度的增加,手动测试变得难以管理。
通过自动化测试套件,我们可以快速验证程序是否按需运行。一些开发人员甚至会在文件保存时触发测试运行。
正式定义
让我们回顾一下一些定义,以便我们以后有一个共同的词汇。
被测系统 (SUT)是当前正在测试的实体。它可以是一行代码、一个方法或整个程序。
验收标准是指我们执行的检查,用于验收系统的输出。验收标准的特异性和范围取决于测试内容:医疗设备和航空航天要求测试具有针对性,因为这类测试的容错率要低得多。
如果亚马逊给出了错误的建议,那也不算世界末日。但如果 IBM 的 Watson 给出了错误的手术建议,那就可能危及生命。
测试是指将输入输入到被测系统中并根据验收标准验证输出的过程:
- 如果输出正常,我们的测试就通过了。
- 如果输出不正常,我们的测试就失败了,我们必须进行调试。
希望测试失败能为我们提供足够的上下文信息,以便找到需要查找的位置。
测试的好处
经过深思熟虑的测试策略与全面的测试用例相结合可带来以下好处:
自信地修改代码
如果程序执行了任何有趣的操作,那么它就会在函数、类和模块之间产生交互。这意味着一行代码的修改就可能以意想不到的方式破坏程序。测试让我们对代码充满信心。通过在修改代码后运行测试,我们可以确认这些更改没有破坏测试定义的现有功能。
相比之下,修改没有测试的代码库则是一项挑战。我们根本无法知道代码是否按预期运行。我们只能凭感觉编程,这相当冒险。
尽早发现错误
漏洞会造成损失。具体损失多少取决于你何时发现它们。
软件开发生命周期(SDLC)越深入,修复 bug 的成本就越高。 《软件 bug 的真实成本》一文深入探讨了这个问题。
改进系统设计
这一点有点争议,但我认为在编写代码时考虑测试可以改进系统设计。全面的测试套件表明开发人员确实对问题进行了深入的思考。编写测试迫使你使用自己的 API;这有望带来更好的界面。
所有项目都有时间限制,人们很容易养成走捷径的习惯,这会增加模块之间的耦合度,从而导致复杂的相互依赖关系。我们必须意识到如何解决“意大利面条式代码”的问题。
知道必须测试代码会迫使我们编写模块化代码。如果某些代码测试起来很麻烦,或许我们可以实现更好的接口。花时间编写测试迫使我们保持专注;在从用户的角度看待问题之前,我们会深吸一口气。
一旦您使用依赖注入等模式编写了可测试的代码,您就会看到添加结构如何使我们更容易验证我们的代码是否按照我们的期望运行。
黑盒与白盒
测试大致可分为两大类:黑盒测试和白盒测试。
黑盒测试是指测试人员无法看到被测试项目内部运作的测试技术。
白盒测试是一种可以让测试人员看到被测试产品内部运作的技术。
作为开发人员,我们会进行白盒测试。我们在白盒中编写代码,并且知道如何对其进行彻底的测试。这并不是说不需要黑盒测试,我们仍然应该安排更高级别的人员进行测试;靠近代码可能会导致测试出现盲点。
测试金字塔
自动化测试金字塔提供了如何构建测试策略的指导。它告诉我们,我们应该编写大量快速且成本低廉的单元测试,以及少量缓慢且成本高昂的端到端测试。
测试金字塔并非硬性规定,但它提供了一个思考测试策略的好起点。一个好的经验法则是,在每个层级上编写尽可能多的测试,直到对系统有信心为止。我们应该在编写代码的同时编写测试,并不断迭代,最终找到适合我们项目的测试策略。
单元测试
单元测试是专注于测试系统特定部分的低级测试。它们编写成本低且运行速度快。测试失败应该提供足够的上下文信息来查明错误来源。这些测试通常由开发人员在软件开发生命周期(SDLC) 的实施阶段编写。
单元测试应该独立且隔离;与外部组件的交互不仅会增加测试范围,还会增加测试运行的时间。正如我们将在后续文章中看到的那样,用测试替身替换依赖项可以实现快速运行的确定性测试。
我们的单元测试应该有多大?就像编程中的其他一切一样,这取决于我们想要做什么。以行为单元为单位进行思考,使我们能够围绕逻辑代码块编写测试。
测试金字塔建议在我们的测试套件中包含大量的单元测试。这些测试让我们确信程序能够按预期运行。编写新代码或修改现有代码可能需要我们重写一些测试。这是标准做法,我们的测试套件会随着代码库的扩展而增长。
尽量意识到我们的测试套件正在变得越来越复杂。记住,测试我们生产代码的代码也是生产代码。花时间重构你的测试,以确保它们高效且有效。
单元测试示例
假设我们有以下函数,它接受一个单词列表并返回最常见的单词以及该单词出现的次数:
def find_top_word(words)
# Return most common word & occurrences
word_counter = Counter(words)
return word_counter.most_common(1)[0]
我们可以通过创建一个列表、find_top_word
在该列表上运行该函数并将该函数的结果与我们期望的值进行比较来测试该函数:
def test_find_top_word():
words = ["foo", "bar", "bat", "baz", "foo", "baz", "foo"]
result = find_top_word(words)
assert result[0] == "foo"
assert result[1] == 3
如果我们想更改 的实现find_top_words
,我们可以毫无顾虑地去做。我们的测试确保 的功能find_top_word
不会因为测试失败而改变。
集成测试
每个复杂的应用程序都包含内部和外部组件,它们协同工作以完成一些有趣的任务。与专注于单个组件的单元测试不同,集成测试将系统的各个部分组合在一起,并将它们作为一个整体进行测试。集成测试也可以指在应用程序服务边界进行的测试,例如,当它涉及到数据库、文件系统或外部 API 时。
这些测试通常由开发人员编写,但并非必须如此。顾名思义,集成测试比单元测试的范围更大,运行时间也更长。这意味着测试失败需要进行一些调查:我们知道测试中的某个组件无法正常工作,但需要找到故障的确切位置。这与单元测试相反,单元测试的范围较小,可以准确指出失败的位置。
我们应该尝试在类似生产的环境中运行集成测试;这最大限度地减少了由于配置差异而导致测试失败的可能性。
集成测试示例
假设我们有以下函数,它接受一个 URL 和一个元组(word, occurrences)
。我们的函数创建一个记录并将其保存到数据库:
def save_to_db(url, top_word):
record = TopWord()
record.url = url
record.word = top_word[0]
record.num_occurrences = top_word[1]
db.session.add(record)
db.session.commit()
return record
我们通过传入已知信息来测试此函数;该函数应该将我们输入的信息保存到数据库中。我们的测试代码从数据库中提取新保存的记录,并确认其字段与我们传入的输入匹配。
def test_save_to_db():
url = "http://test_url.com"
most_common_word_details = ("Python", 42)
word = save_to_db(url, most_common_word_details)
inserted_record = TopWord.query.get(word.id)
assert inserted_record.url == "http://test_url.com"
assert inserted_record.word == "Python"
assert inserted_record.num_occurrences == 42
请注意,我们手动执行此类测试是为了确认一切按预期运行。自动化测试可以节省我们每次修改代码时重复检查功能的麻烦。
端到端
端到端测试用于检查系统是否满足我们定义的业务需求。一种常见的测试是追踪系统中与用户体验相同的路径。例如,我们可以测试一个新用户的工作流程:模拟创建帐户、“点击”激活邮件中的链接、首次登录以及与 Web 应用程序的教程模式弹出窗口进行交互。
我们可以利用像Selenium这样的浏览器自动化工具,通过用户界面 (UI) 进行端到端测试。但这会在 UI 和测试之间建立依赖关系,导致测试变得脆弱:前端的变更会导致测试也随之更改。这种做法不可持续,因为要么前端变得死板,要么测试无法运行。
更好的解决方案是测试皮下层,即用户界面正下方的层。对于 Web 应用程序来说,这相当于测试 REST API,包括发送 JSON 和获取 JSON 数据。
我们的皮下测试是我们与前端的契约;我们的前端开发人员可以将它们用作 REST API 的规范。像swagger-meqa这样的基于 OpenAPI 规范构建的工具可以帮助我们自动化这一过程。我们还可以使用Postman等功能齐全的工具来测试、调试和验证我们的 API。
端到端测试被认为是黑盒测试,因为我们无需了解任何实现细节即可进行测试。这也意味着测试失败并不能提供任何错误信息;我们需要使用日志来追踪错误并诊断系统故障。
端到端测试示例
这里我们使用Flask Test 客户端对我们的 REST API 进行皮下测试。后台会发生很多事情,我们收到的结果(HTTP 状态码)会告诉我们测试是通过还是失败。
def test_end_to_end():
client = app.test_client()
body = {"url": "https://www.python.org"}
response = client.post("/top-word", json=body)
assert response.status_code == HTTPStatus.OK
资源
- Martin Fowler Wiki:TestPyramid | UnitTest | IntegrationTest | EndToEndTest
- Google 测试博客:拒绝更多端到端测试
- 自动化熊猫:测试金字塔
结构化测试
每个测试用例可以分为以下几个阶段:
- 将被测系统(SUT)设置为测试用例所需的环境(先决条件)
- 执行我们想要在 SUT 上测试的操作
- 验证预期结果是否发生(后置条件)
- 拆除 SUT,将环境恢复到我们发现的状态
有两种广泛使用的构建测试框架:Arrange-Act-Assert 和 Given-When-Then。
安排-行动-断言 (AAA)
AAA 模式是分离测试不同部分的抽象:
- 安排所有必要的先决条件
- 根据 SUT采取行动
- 断言我们的后置条件得到满足
安排-行动-断言示例
def test_find_top_word():
# Arrange
words = ["foo", "bar", "bat", "baz", "foo", "baz", "foo"]
# Act
result = find_top_word(words)
# Assert
assert result[0] == "foo"
assert result[1] == 3
清晰地划分各个阶段,让我们能够发现测试方法是否试图同时测试太多内容。“安排-执行-断言”是我编写测试时使用的模式。
给定-何时-则 (GWT)
GWT 提供了一个有用的抽象来分离测试的不同阶段:
- 给定一组先决条件
- 当我们在 SUT 上执行操作时
- 那么我们的后置条件应该如下
GWT 广泛应用于行为驱动开发(BDD)。
给定-何时-然后示例
def test_find_top_word():
# Given a list of word
words = ["foo", "bar", "bat", "baz", "foo", "baz", "foo"]
# When we run the function over the list
result = find_top_word(words)
# Then we should see `foo` occurring 3 times
assert result[0] == "foo"
assert result[1] == 3
资源
- Gerard Meszaros:四阶段测试
- 马丁·福勒维基:GivenWhenThen
- C2 Wiki:安排行动断言
- James Cooke:为 Python 开发人员安排 Act Assert 模式
- 自动化熊猫:行为驱动的 Python
测试什么
为了证明我们的程序是正确的,我们必须针对所有可能的输入值组合进行测试。这种详尽的测试并不实用,因此我们需要采用一些测试策略,以便筛选出最容易出错的测试用例。
经验丰富的开发人员能够在编写代码解决业务问题和编写测试以确保正确性并防止回归之间取得平衡。找到这种平衡并了解需要测试的内容,与其说是科学,不如说是一门艺术。幸运的是,我们可以遵循一些经验法则来确保测试的全面性。
功能要求
我们希望确保所有相关需求都已实现。我们的测试用例应该足够详细,以检查业务需求。如果构建的东西不符合您设定的标准,那么它就毫无意义。
基础路径测试
我们必须至少测试每个语句一次。如果语句包含条件(if
或while
),则必须改变测试方式,以确保测试到条件的所有分支。例如,假设我们有以下代码:
if x > 18:
# statement1
elif 18 >= x >= 35:
# statement2
else:
# statement3
为了确保我们满足上述条件的所有分支,我们需要编写以下测试:
x < 18
18 <= x <= 35
x > 35
等价划分
两个测试用例如果输出相同,则称其为等效测试用例。我们只需要其中一个测试用例即可覆盖此类错误。
边界分析
“计算机科学中有 2 个难题:缓存失效、命名事物和偏差 1 错误。”
这是编程中最古老的笑话之一,但它背后却蕴含着许多道理。我们常常搞不清楚究竟需要的是 a<
还是 a <=
。这就是为什么我们应该始终测试边界条件。以下是示例:
if x > 18:
# statement1
else:
# statement2
为了确保彻底测试上述代码片段的边界条件,我们需要针对x=17
、x=18
和编写测试用例x=19
。请注意,如果我们的边界条件包含复合条件,编写测试用例会变得更加复杂。
不良数据类别
这是指下列任何一种情况:
- 数据太少(或没有数据)
- 数据过多
- 无效数据
- 数据大小错误
- 未初始化的数据
数据流测试
专注于跟踪程序的控制流,重点是探索与数据对象状态相关的事件序列。例如,如果我们尝试访问已被删除的变量,就会出现错误。我们可以使用数据流测试来为其他测试尚未测试的变量提供额外的测试用例。
错误猜测
过去的经验可以帮助我们洞察代码库中可能导致错误的部分。记录以前的错误可以提高将来避免再次犯相同错误的可能性。
回顾
我所说的“开发人员测试的艺术”指的是弄清楚要测试什么并高效地进行测试。提高测试水平的唯一方法是编写测试,制定更好的测试策略,并学习不同的测试技术。就像软件开发一样,你对某件事了解得越多,你就会做得越好。
何时编写测试
虽然关于何时编写测试有很多有趣的讨论,但我觉得这偏离了测试的本质。何时编写测试并不重要,重要的是编写测试本身。
如果您有兴趣探索这个主题,我推荐以下链接:
- RubyOnRails 创始人 David Heinemeier Hansson (DHH)在 2014 年 RailsConf 上批评 TDD
- TDD 已死? DHH、Martin Fowler 和 Kent Beck 的讨论
- TDD: Ted M. Young 的学术调查
结论
这篇文章我们对测试的世界进行了广泛的介绍。既然我们已经达成了共识,我们可以在以后的文章中更深入地探讨测试。
其他资源
- 《代码大全》第 22 章:开发人员测试
- 代码简洁性:测试哲学
- Katy Huff:Python 测试和持续集成
- 测试和代码播客