在Jupyter笔记本中对Python代码进行单元测试

956 阅读7分钟

我们中的大多数人都同意我们应该写单元测试,而且我们中的许多人实际上也写了。这对于生产代码、库代码,或者如果你赞成测试驱动开发,在整个开发过程中,尤其应该如此。

通常情况下,使用Python的Jupyter笔记本用于数据探索,因此用户可能不会选择(或需要)为他们的笔记本代码编写单元测试,因为他们通常可能会在笔记本中查看每个单元的结果,然后得出结论,继续前进。然而,根据我的经验,笔记本的典型情况是,笔记本中的代码很快就超出了数据探索的范围,对进一步的工作有帮助。或者,也许笔记本本身产生的结果是有用的,需要定期运行。也许代码需要维护并与外部数据源集成。那么,确保笔记本中的代码能够被测试和验证就变得很重要。

在这种情况下,我们对笔记本代码的单元测试有哪些选择呢?在这篇文章中,我将介绍在Jupyter笔记本中对Python代码进行单元测试的几种选择。

也许就不做了?

Jupyter笔记本单元测试的第一个选择是根本不做。这并不是说不要对你的代码进行单元测试,而是把它_从_笔记本中提取到单独的Python模块中,再导入到你的笔记本中。这些代码应该以你通常的单元测试方式进行测试,无论是用unittest,pytest,doctest ,还是用其他单元测试框架。本文不会详细介绍所有这些框架,但对于Python开发者来说,一个很好的选择是不在他们的Jupyter笔记本内进行测试,而是使用Python代码已有的丰富的测试框架,并在开发过程中尽快将代码转移到外部模块。

OK,所以你可以在笔记本中进行测试

如果你最终决定要把你的代码留在Jupyter笔记本里,实际上有一些单元测试选项。在回顾其中几个选项之前,让我们先设置一个在Jupyter笔记本中可能遇到的代码例子。假设你的笔记本从一个API中获取了一些数据,从中计算出一些结果,然后生成一些图形和其他数据摘要,并将其保存在其他地方。也许有一个函数可以产生适当的API URL,我们想对这个函数进行单元测试。这个函数有一些逻辑,根据报告的日期来改变URL的格式。这里有一个调试过的版本。

import datetime
import dateutil

def make_url(date):
    """Return the url for our API call based on date."""

    if isinstance(date, str):
        date = dateutil.parser.parse(date).date()
    elif not isinstance(date, datetime.date):
        raise ValueError("must be a date")
    if date >= datetime.date(2020, 1, 1):
        return f"https://api.example.com/v2/{date.year}/{date.month}/{date.day}"
    else:
        return f"https://api.example.com/v1/{date:%Y-%m-%d}"

用unittest进行单元测试

通常情况下,当我们用unittest测试时,我们要么把测试方法放在一个单独的测试模块中,要么就把这些方法混在主模块中。然后我们需要执行unittest.main 方法,可能是作为__main__ guard里面的默认方法。我们基本上可以在我们的Jupyter笔记本中做同样的事情。我们可以制作一个unitest.TestCase ,执行我们想要的测试,然后在任何单元中执行单元测试。如果你想让笔记本的执行在错误时失败,测试的结果甚至可以被检查或断言为不包括失败。你只需要保存unittest.main 方法的输出并检查它是否有错误。

import unittest

class TestUrl(unittest.TestCase):
    def test_make_url_v2(self):
        date = datetime.date(2020, 1, 1)
        self.assertEqual(make_url(date), "https://api.example.com/v2/2020/1/1")
        
    def test_make_url_v1(self):
        date = datetime.date(2019, 12, 31)
        self.assertEqual(make_url(date), "https://api.example.com/v1/2019-12-31")

        
res = unittest.main(argv=[''], verbosity=3, exit=False)

# if we want our notebook to stop processing due to failures, we need a cell itself to fail
assert len(res.result.failures) == 0
test_make_url_v1 (__main__.TestUrl) ... ok
test_make_url_v2 (__main__.TestUrl) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

这被证明是相当直接的,如果你不介意在你的笔记本中混入代码和测试,它可以正常工作。

用doctest进行单元测试

在你的代码中包含测试的另一种方法是使用doctest。Doctest使用特殊格式的代码文档,包括我们的测试和预期结果。下面是一个更新的方法,包含了这个特殊的代码文档,包括了正向和反向的测试案例。这是一个在一个地方测试和记录代码的简单方法,经常会在python模块中使用,主卫士只是运行doct测试,像这样。

if __name__ == __main__:
    doctest.testmod()

因为我们是在笔记本中,所以我们只需将这个添加到我们的代码定义的下面的单元格中,也会有效果。首先,这是我们更新的make_url方法,上面有doctest的注释。

def make_url(date):
    """Return the url for our API call based on date.
    >>> make_url("1/1/2020")
    'https://api.example.com/v2/2020/1/1'
    
    >>> make_url("1-1-x1")
    Traceback (most recent call last):
        ...
    dateutil.parser._parser.ParserError: Unknown string format: 1-1-x1
    
    >>> make_url("1/1/20001")
    Traceback (most recent call last):
        ...
    dateutil.parser._parser.ParserError: year 20001 is out of range: 1/1/20001
    
    >>> make_url(datetime.date(2020,1,1))
    'https://api.example.com/v2/2020/1/1'
    
    >>> make_url(datetime.date(2019,12,31))
    'https://api.example.com/v1/2019-12-31'
    """
    if isinstance(date, str):
        date = dateutil.parser.parse(date).date()
    elif not isinstance(date, datetime.date):
        raise ValueError("must be a date")
    if date >= datetime.date(2020, 1, 1):
        return f"https://api.example.com/v2/{date.year}/{date.month}/{date.day}"
    else:
        return f"https://api.example.com/v1/{date:%Y-%m-%d}"

import doctest
doctest.testmod()
TestResults(failed=0, attempted=5)

用testbook进行单元测试

testbook项目是对笔记本单元测试的一种不同方式。它允许你在笔记本之外用纯 Python 代码引用你的笔记本。这允许你在单独的Python模块中使用任何你喜欢的测试框架(例如,pytest ,或unittest )。你可能会遇到这样的情况:允许用户修改和更新笔记本代码是保持代码更新的最好方式,也是允许终端用户灵活使用的最好方式。但你可能更希望这些代码仍然被单独测试和验证。Testbook使这成为一种选择。

首先,你必须在你的环境中安装它。

pip install testbook

或在你的笔记本中

%pip install testbook

现在,在一个单独的python文件中,你可以导入你的笔记本代码并在那里进行测试。在该文件中,你将创建像下面这样的代码,然后你将使用你喜欢的任何单元测试框架来实际执行该单元测试。你可能会在一个Python文件中创建以下代码(比如jupyter_unit_tests.py)。

import datetime
import testbook

@testbook.testbook('./jupyter_unit_tests.ipynb', execute=True)
def test_make_url(tb):
    func = tb.ref("make_url")
    date = datetime.date(2020, 1, 2)
    assert func(date) == "https://api.example.com/v2/2020/1/1"

在这种情况下,你现在可以用任何单元测试框架运行测试。例如,用pytest,你只需运行以下代码。

pytest jupyter_unit_tests.py

这可以作为一个正常的单元测试,测试应该通过。然而,在开发这篇文章时,我意识到testbook 代码对单元测试中的参数传回笔记本内核进行测试的支持有限。这些参数是JSON序列化的,目前的代码知道如何处理大量的Python类型。但是它并没有把日期时间作为一个对象来传递,比如说,而是作为一个字符串。由于我们的代码试图将字符串解析为日期(在我修改之后),所以它可以工作。换句话说,上面的单元测试不是向make_url 方法传入一个datetime.date ,而是传入一个字符串(2020-01-02),然后被解析成一个日期。你怎么能从单元测试中传入一个日期到笔记本代码中呢?你有几个选择。首先,你可以在你的笔记本中制作一个日期对象,只是为了测试的目的,然后在单元测试中引用它。

testdate1 = datetime.date(2020,1,1)  # for unit test

然后,你可以写你的单元测试,在测试中使用该变量。

第二个选择是将Python代码注入到笔记本中,然后在单元测试中引用它。这两种选择都显示在外部单元测试的最终版本中。只要把它保存在jupyter_unit_tests.py ,然后用你最喜欢的单元测试框架运行它。

import datetime

import testbook

@testbook.testbook('./jupyter_unit_tests.ipynb', execute=True)
def test_make_url(tb):
    f = tb.ref("make_url")
    d = "2020-01-02"
    assert f(d) == "https://api.example.com/v2/2020/1/2"

    # note that this is actually converted to a string
    d = datetime.date(2020, 1, 2)
    assert f(d) == "https://api.example.com/v2/2020/1/2"

    # this one will be testing the date functionality
    d2 = tb.ref("testdate1")
    assert f(d2) == "https://api.example.com/v2/2020/1/1"

    # this one will inject similar code as above, then use it
    tb.inject("d3 = datetime.date(2020, 2, 3)")
    d3 = tb.ref("d3")
    assert f(d3) == "https://api.example.com/v2/2020/2/3"

总结

因此,无论你是一个单元测试的纯粹主义者,还是你只是想在你的笔记本中洒下一些单元测试,都有几个选项供你考虑。不要让你对笔记本的使用妨碍你在测试代码方面做正确的事情。

The postUnit testing Python code in Jupyter notebooksappeared first onwrighters.io.