使用 Python 的 pytest 测试你的代码
不测试你的代码?pytest 消除了任何借口。
软件开发者不仅仅编写软件;他们也使用软件。因此,他们是第一批认识到并理解软件是复杂的并且不可避免地包含 bug 的人。
但是,仅仅因为 bug 是不可避免的,并不意味着开发者可以或应该尝试阻止它们。因此,在过去的几十年里,软件测试得到了快速发展。测试不再被视为软件开发中可有可无或“锦上添花”的部分;它被认为是绝对必要的——软件开发过程的一部分。在许多情况下,我在各公司教授的 Python 课程中的学员本身并不是开发者,而是测试人员——他们的全职工作是编写测试以确保公司的软件是健壮的。
我必须承认,即使我已经编写软件很长时间了,我在测试方面也很少能做得像我希望的那样好。当然,当我在开发大型、复杂的应用程序时,我会编写测试,但这总是感觉有点负担。我知道这对我有好处,将来会节省大量时间,会使软件更健壮,维护更容易,但实际上,如果我只想尽快发布我的程序,为什么要测试呢?而且,我多年来使用过的各种测试框架从来没有给我留下非常深刻或易于使用的印象。
因此,在过去的几年里,我一直处于一种停滞不前的状态。我想进行更多测试,但测试很烦人,所以我没有进行测试,这使得测试看起来更像是一种负担,因为它不是我常规流程的一部分。
这一切最近都为我改变了,这要归功于我(我承认比其他人晚得多)发现了 Python 的 pytest 库。事实证明 pytest 易于使用、易于操作且易于集成到我的工作中。部分原因是 pytest 放弃了 Python “只有一种方法可以做到” 的理念,让开发者在选择如何编写测试方面具有很大的灵活性和自由度。
因此,在本文中,我将介绍 pytest,展示如何立即开始将其集成到您的开发过程中。我计划在我的下一篇文章中对此进行扩展,并描述您可能需要使用的一些更高级的 pytest 功能。
Pytest 基础pytest 背后的理念是,如果您想测试一个函数,您将编写一个单独的函数来测试它。实际上,您可能需要编写多个测试函数,但这只是锦上添花。
例如,假设您有以下对数字求和的函数
def mysum(numbers):
output = 0
for one_number in numbers:
output += one_number
return output
您如何测试这个函数?(是的,我忽略了“测试驱动开发”的测试模式,在这种模式中,您首先编写测试,然后再编写代码。您当然可以使用 pytest 进行 TDD,但这不是我现在的重点。)
我将此函数定义放在 mysum.py 中。接下来,我可以创建名为 test_mysum.py 的文件,放在同一目录下。然后,当我在当前目录下运行 pytest
时,它将运行所有以 test_
开头的文件。test_mysum.py 可能是什么样的?让我们从一些简单的东西开始
from mysum import mysum
def test_sum_integers():
assert mysum([0,1,2,3,4]) == 10
正如您所看到的,我的测试文件 test_mysum.py 非常简短。但它包含一个实际的测试,并且它也指出了如何编写测试。
首先,您必须导入您要测试的文件。这可以是简单的 “import XYZ” 语句,或者您可以使用 “from X import Y” 从模块中有选择地导入名称。无论哪种方式,您都需要导入您要测试的函数和类。
测试本身被编写为名称以 test_
开头的 Python 函数。(是的,这意味着测试是在名称以 test_
开头的文件中编写的,然后在这些文件中编写名称以 test_
开头的函数。)
在简单的情况下,这些测试函数不接受任何参数。这些函数由 pytest
调用,测试的关键是 assert
语句。通常,Python 中的 assert
语句会评估一个表达式。如果表达式返回 True,则断言被记录为成功,否则将被忽略。
因此,在这些示例测试函数的情况下,我基本上是说“如果我使用一个参数,列表 [0,1,2,3,4] 调用该函数,我希望得到整数 10 作为结果”。
我如何运行我的测试?我进入我的文件所在的目录,然后输入
pytest
果然,pytest 注意到有一个文件匹配 “test_*” 模式,它会运行该文件。在一些初始样板代码指示我的系统配置之后,我得到以下输出
collected 1 item
test_mysum.py . [100%]
================1 passed in 0.02 seconds=====================
换句话说,有一个文件 (test_mysum.py)。它包含一个测试函数,用点 (.) 表示。并且,100% 的测试都成功运行——这意味着,我断言的是实际返回的结果。
但是当然,仅仅用这种东西进行测试是不够的。我应该用一个空列表调用它,以确保我得到返回值 0。所以,让我们添加另一个测试。现在 test_mysum.py 看起来像这样
from mysum import mysum
def test_sum_integers():
assert mysum([0,1,2,3,4]) == 10
def test_sum_nothing():
assert mysum([]) == 0
当我运行测试时,我得到
collected 2 items
test_mysum.py .. [100%]
================= 2 passed in 0.10 seconds ==================
让我们添加另一个测试,看看如果我用一些浮点数调用它会发生什么
from mysum import mysum
def test_sum_integers():
assert mysum([0,1,2,3,4]) == 10
def test_sum_floats():
assert mysum([0.1,1.2,2.3,3.4,4.5]) == 11.5
def test_sum_nothing():
assert mysum([]) == 0
现在,当我测试时,我得到
collected 3 items
test_mysum.py ... [100%]
=================== 3 passed in 0.06 seconds ================
果然,到目前为止,我做的测试非常棒。
我应该注意到,虽然我在这里的每个函数中只使用了一个 assert
语句,但您绝对可以使用多个。我更喜欢尽可能保持每个测试函数的专注,因此,我尽可能少地使用 assert
语句。
如果测试失败了怎么办?让我们故意引入一个会失败的测试来试一试。在 test_mysum.py 中,我添加了
def test_one_and_one_are_three():
assert mysum([1,1]) == 3
当我运行测试时,我得到以下输出
test_mysum.py ...F [100%]
========================== FAILURES ==========================
____________________ test_one_and_one_are_three ____________
def test_one_and_one_are_three():
> assert mysum([1,1]) == 3
E assert 2 == 3
E + where 2 = mysum([1, 1])
test_mysum.py:15: AssertionError
================== 1 failed, 3 passed in 0.30 seconds ========
首先,您可以看到在 test_mysum.py 中运行了四个测试。前三个测试成功运行,并用点表示。但第四个测试失败了。“失败” 在这里意味着 assert
语句声称会有一个答案 (3),但运行该函数产生了不同的答案 (2)。pytest 不仅指示发生了失败,而且还指示了错误发生在哪个测试函数以及哪一行。这使您可以找出问题所在。
在失败的情况下,当然,有两种可能性:原始代码是错误的,或者您的测试是错误的。不要忘记测试也是代码,这意味着它们也可能出现问题!但是,如果您编写的测试干净且清晰(在编写代码之前或编写代码的同时),我发现大多数测试将是简单明了的,这使得测试不太可能出现问题,并且更容易识别 bug 的位置。
您甚至可以使用 -v
选项从 pytest 获取更详细的输出
test_mysum.py::test_sum_integers PASSED [ 25%]
test_mysum.py::test_sum_floats PASSED [ 50%]
test_mysum.py::test_sum_nothing PASSED [ 75%]
test_mysum.py::test_one_and_one_are_three FAILED [100%]
============================= FAILURES =======================
_______________________ test_one_and_one_are_three ___________
def test_one_and_one_are_three():
> assert mysum([1,1]) == 3
E assert 2 == 3
E + where 2 = mysum([1, 1])
test_mysum.py:15: AssertionError
================== 1 failed, 3 passed in 0.22 seconds =======
现在您可以清楚地看到哪些测试通过和失败,以及失败发生在哪里。
参数化测试到目前为止创建的成功测试 (test_sum_nothing
、test_sum_integers
和 test_sum_floats
) 都很棒且有用。但是,如果您像我一样,您可能会想知道为什么您需要三个单独的测试函数来检查这三个相似但不完全相同的调用。pytest 的人也同意,他们建议使用“参数化测试”。这里的想法是,您只需定义一次测试,但告诉 pytest 要提供哪些输入和输出。
您可以通过将 Python 装饰器应用于测试函数来做到这一点。装饰器将接受两个参数:一个字符串,其中包含以逗号分隔的名称,表示您要传递给测试的参数,以及一个双元素元组列表,描述输入和输出。例如,给定所有这些测试
def test_sum_integers():
assert mysum([0,1,2,3,4]) == 10
def test_sum_floats():
assert mysum([0.1,1.2,2.3,3.4,4.5]) == 11.5
def test_sum_nothing():
assert mysum([]) == 0
您可以将它们全部替换为一个测试
import pytest
@pytest.mark.parametrize('numbers,output', [
([], 0),
([10, 20, 30], 60),
([0.1, 1.2, 2.3, 3.4, 4.5], 11.5)])
def test_mysum(numbers, output):
assert mysum(numbers) == output
虽然这与之前的效果相同,但它看起来确实有点复杂。让我们分解一下
- 首先,您需要导入
pytest
,以便您可以访问装饰器。 - 然后您使用
@pytest.mark.parametrize
作为装饰器。请注意,如果您像我一样,更喜欢拼写为 “parameterize”,您会收到一条错误消息,责骂您拼写错误。 - 第一个装饰器参数是一个字符串,其中包含以逗号分隔的您要传递的变量的名称。您始终需要至少两个:一个用于输入,一个用于输出。如果您的函数接受两个输入,您将需要定义三个参数(两个输入和一个输出)。
- 第二个参数是一个双元素元组列表。每个元组描述一个测试运行。每个元组元素都将分配给一个测试函数参数。
- 最后,测试函数现在需要接受两个参数,其名称与装饰器参数中定义的名称相同。
完成这些设置后,您现在可以运行测试,您将得到以下输出
test_mysum.py::test_mysum[numbers0-0] PASSED [33%]
test_mysum.py::test_mysum[numbers1-60] PASSED [66%]
test_mysum.py::test_mysum[numbers2-11.5] PASSED [100%]
======================== 3 passed in 0.12 seconds ============
如果您在想,“哇,这看起来很像来自三个独立测试的输出”——嗯,没错。
总结关于 pytest 还有很多要说的,但我在这里写的内容涵盖了您在日常工作中遇到的大多数情况。下次,我计划涵盖其他几个主题,包括如何处理异常、用户输入和输出以及检查代码覆盖率。
资源pytest 网站位于 https://pytest.cn/en/latest。
关于该主题的一本优秀书籍是 Brian Okken 的 Python Testing with pytest,由 Pragmatic Programmers 出版。他还拥有许多其他关于 pytest 和代码测试的资源,点击此处。