使用 Python 的 pytest 测试你的代码,第二部分

测试函数并不难,但是你如何测试用户输入和输出呢?

在我的上一篇文章中,我开始研究 “pytest”,这是一个用于测试 Python 程序的框架,它真正改变了我看待测试的方式。这是我第一次真正感觉到测试是我可以并且应该定期做的事情;pytest 让事情变得如此简单和直接。

我在上一篇文章中没有涵盖的主要主题之一是用户输入和输出。你如何测试期望从文件或用户那里获取输入的程序?而且,你如何测试应该在屏幕上显示某些内容的程序?

因此,在本文中,我将介绍如何在各种情况下测试输入和输出,从而允许你测试与外部世界交互的程序。我不仅尝试解释你能做什么,还展示它如何融入到更广泛的测试背景中,特别是 pytest。

用户输入

假设你有一个函数,它要求用户输入一个整数,然后返回该整数的值,翻倍。你可以想象这个函数会像这样


def double():
    x = input("Enter an integer: ")
    return int(x) * 2

你如何使用 pytest 测试这个函数?如果该函数接受一个参数,答案将很容易。但在这种情况下,该函数要求用户进行交互式输入。这有点难处理。毕竟,在你的测试中,你如何假装向用户请求输入呢?

在大多数编程语言中,用户输入来自一个称为标准输入(或 stdin)的源。在 Python 中,sys.stdin 是一个只读文件对象,你可以从中获取用户的输入。

因此,如果你想测试上面 “double” 函数,你可以(应该)用另一个文件替换 sys.stdin。然而,这有两个问题。首先,你真的不想开始在磁盘上打开文件。其次,你真的想在你的测试中替换 sys.stdin 的值吗?这将影响不仅仅是一个测试。

解决方案分为两部分。首先,你可以使用 pytest “monkey patching” 功能,在测试期间临时为系统对象分配一个值。此功能要求你使用名为 monkeypatch 的参数定义你的测试函数。pytest 系统注意到你已经使用该参数定义了它,然后不仅设置了 monkeypatch 局部变量,还设置它让你临时设置属性名称。

理论上,那么,你可以像这样定义你的测试


def test_double(monkeypatch):
    monkeypatch.setattr('sys.stdin', open('/etc/passwd'))
    print(double())

换句话说,这告诉 pytest 你想打开 /etc/passwd 并将其内容馈送到 pytest。这有很多问题,首先是 /etc/passwd 包含多行,并且它的每一行都是非数字的。因此,该函数在甚至调用(无用的)print 之前就崩溃并退出并出现错误。

但这里还有另一个问题,我上面提到过。如果可以避免,你真的不想在测试期间打开文件。因此,我的测试工具箱中的一个伟大工具是 Python 的 StringIO 类。StringIO 的美妙之处在于它的简洁性。它实现了 “文件” 对象的 API,但仅存在于内存中,并且实际上是一个字符串。如果你可以创建一个 StringIO 实例,你可以将其传递给 monkeypatch.setattr 的调用,从而使你的测试完全符合你的要求。

以下是如何做到这一点


from io import StringIO
from double import double

number_inputs = StringIO('1234\n')

def test_double(monkeypatch):
    monkeypatch.setattr('sys.stdin', number_inputs)
    assert double() == 2468

你首先创建一个 StringIO 对象,其中包含你想从用户那里模拟的输入。请注意,它必须包含一个换行符 (\n) 以确保你将看到用户输入的结尾而不会挂起。

你将其分配给一个全局变量,这意味着你将能够从你的测试函数中访问它。然后,你将断言添加到你的测试函数中,说明结果应该是 2468。果然,它有效。

我使用过这种技术来模拟更长的文件,并且我对速度和灵活性非常满意。只需记住,输入 “文件” 中的每一行都应以换行符结尾。我发现创建一个带有三引号字符串的 StringIO 效果很好,这让我可以包含换行符并以更自然的文件方式编写。

你可以使用 monkeypatch 来模拟对各种其他对象的调用。我没有太多机会这样做,但你可以想象各种与网络相关的对象,你实际上不想在测试时使用它们。通过在测试期间 monkey-patch 这些对象,你可以假装连接到远程服务器,而实际上你只是获得预先编程的文本返回。

异常

如果你使用字符串调用 test_double 函数会发生什么?你可能也应该测试一下


str_inputs = StringIO('abcd\n')
def test_double_str(monkeypatch):
    monkeypatch.setattr('sys.stdin', str_inputs)
    assert double() == 'abcdabcd'

看起来很棒,对吧?实际上,不太好


E   ValueError: invalid literal for int() with base 10: 'abcd'

测试失败了,因为该函数以异常退出。这没关系;毕竟,如果用户给出的输入不是数字,该函数应该引发异常。但是,如果能够指定和测试它,岂不是更好?

问题是,你如何测试异常?你不能完全像你可能想的那样使用通常的 assert 语句。毕竟,你以某种方式需要捕获异常,你不能简单地说


assert double() == ValueError

那是因为异常不是返回的值。它们是通过不同的机制引发的。

幸运的是,pytest 为此提供了一个很好的解决方案,尽管语法与你之前看到的略有不同。它使用 with 语句,许多 Python 开发人员都认识到它通常用于确保在你写入文件时刷新并关闭文件。with 语句打开一个代码块,如果在该代码块期间发生异常,则 “上下文管理器”——即 with 运行的对象——有机会处理异常。pytest 利用了这一点,使用了 pytest.raises 上下文管理器,你可以通过以下方式使用它


def test_double_str(monkeypatch):
    with pytest.raises(ValueError):
        monkeypatch.setattr('sys.stdin', str_inputs)
        result = double()

请注意,你不需要在此处使用 assert 语句,因为 pytest.raises 实际上就是 assert 语句。而且,你必须指示你尝试捕获的错误类型 (ValueError),这意味着你期望接收的内容。

如果你想捕获(或断言)与引发的异常相关的某些内容,你可以使用 with 语句的 as 部分。例如


def test_double_str(monkeypatch):
    with pytest.raises(ValueError) as e:
        monkeypatch.setattr('sys.stdin', str_inputs)
        results = double()
    assert str(e.value) == "invalid literal for int()
     ↪with base 10: 'abcd'"

现在你可以确定,不仅引发了 ValueError 异常,而且还引发了什么消息。

输出

我通常建议人们不要在他们的函数中使用 print。毕竟,我想从函数中获得一些返回值;我真的不想在屏幕上显示任何东西。但在某些时候,你确实需要向用户显示内容。你如何测试这个呢?

pytest 解决方案是通过 capsys 插件。与 monkeypatch 类似,你将 capsys 声明为你的测试函数的参数。然后你运行你的函数,允许它产生其输出。然后你在 capsys 上调用 readouterr 函数,该函数返回一个包含 stdout 输出和其错误消息对应物 stderr 的两个字符串的元组。然后你可以在这些字符串上运行断言。

例如,让我们假设这个函数


def hello(name):
    print(f"Hello, {name}!")

你可以通过以下方式测试它


def test_hello(capsys):
    hello('world')
    captured_stdout, captured_stderr = capsys.readouterr()
    assert captured_stdout == 'Hello, world!'

但是等等!这个测试实际上失败了


E   AssertionError: assert 'Hello, world!\n' == 'Hello, world!'
E     - Hello, world!
E     ?              -
E     + Hello, world!

你看到问题了吗?由 print 写入的输出包含一个尾随换行符 (\n) 字符。但是测试没有检查这一点。因此,你可以检查尾随换行符,或者你可以对 stdout 使用 str.strip


def test_hello(capsys):
    hello('world')
    captured_stdout, captured_stderr = capsys.readouterr()
    assert captured_stdout.strip() == 'Hello, world!'

总结

pytest 继续给我留下深刻印象,作为一个测试框架,在很大程度上是因为它以对我作为开发人员来说感觉自然的方式结合了许多小的、简单的想法。它在很大程度上提高了我在一般开发和教学中对测试的使用。“每周 Python 练习” 订阅服务现在包括测试,我感觉它因此得到了很大的改进。

在我的下一篇文章中,我计划第三次也是最后一次审视 pytest,探索它可以与(和帮助)编写健壮且有用的程序的其他一些方式。

资源

Reuven Lerner 在世界各地的公司教授 Python、数据科学和 Git。你可以订阅他的免费每周 “更好的开发者” 电子邮件列表,并从他的书籍和课程中学习,网址为 http://lerner.co.il。Reuven 与他的妻子和孩子们住在以色列的莫迪因。

加载 Disqus 评论