在Jupyter notebooks中進(jìn)行單元測(cè)試
我們都知道開發(fā)過(guò)程中應(yīng)該編寫單元測(cè)試,實(shí)際上我們中的許多人都這樣做。對(duì)于生產(chǎn)代碼,庫(kù)代碼,或者歸因于測(cè)試驅(qū)動(dòng)的開發(fā)過(guò)程,這一點(diǎn)尤其正確。
通常,Jupyter notebooks用于數(shù)據(jù)探究,因此用戶可能不選擇(或不需要)為其代碼編寫單元測(cè)試,因?yàn)楫?dāng)他們?cè)贘upyter中運(yùn)行時(shí),通常會(huì)查看每個(gè)單元格的結(jié)果,然后得出結(jié)論,之后繼續(xù)。但是,以我的經(jīng)驗(yàn)來(lái)看,Jupyter通常會(huì)發(fā)生的情況是,Jupyter中的代碼很快就超出了數(shù)據(jù)探究的范圍,對(duì)于進(jìn)一步的工作很有用?;蛘撸琂upyter本身可能會(huì)產(chǎn)生有用的結(jié)果,需要定期運(yùn)行。也許需要維護(hù)代碼并將其與外部數(shù)據(jù)源集成。然后,確??梢詼y(cè)試和驗(yàn)證notebook中的代碼就變得很重要。
在這種情況下,我們有哪些選擇對(duì)Jupyter代碼來(lái)進(jìn)行單元測(cè)試?在本文中,我將介紹在Jupyter notebooks中對(duì)Python代碼進(jìn)行單元測(cè)試的幾個(gè)選項(xiàng)。
也許只是不做?
Jupyter notebook 單元測(cè)試的第一個(gè)選擇是根本不做。這樣,我并不是說(shuō)不要對(duì)代碼進(jìn)行單元測(cè)試,而是將其從notebook 中提取到單獨(dú)的Python模塊中,然后再將其重新導(dǎo)入notebook 中。應(yīng)該使用通常對(duì)單元代碼進(jìn)行單元測(cè)試的方式來(lái)測(cè)試該代碼,無(wú)論是使用unittest,pytest,doctest還是其他單元測(cè)試框架。本文不會(huì)詳細(xì)介紹所有這些框架,但是對(duì)于python開發(fā)人員來(lái)說(shuō),一個(gè)不錯(cuò)的選擇是不在其Jupyter notebook本中進(jìn)行測(cè)試,而是使用多種可用于Python代碼的測(cè)試框架,并在開發(fā)過(guò)程中盡快將代碼移至外部模塊。
在notebook中進(jìn)行測(cè)試
如果最終決定要將代碼保留在Jupyter notebook中,則實(shí)際上有一些單元測(cè)試選項(xiàng)。在復(fù)習(xí)其中的一些內(nèi)容之前,讓我們先設(shè)置一個(gè)在Jupyter notebook中可能會(huì)遇到的代碼示例。假設(shè)您的notebook從API中提取了一些數(shù)據(jù),從中計(jì)算出一些結(jié)果,然后生成了一些圖表和其他數(shù)據(jù)摘要,這些摘要會(huì)一直保存在其他地方。也許有一個(gè)函數(shù)可以產(chǎn)生正確的API URL,我們想對(duì)該函數(shù)進(jìn)行單元測(cè)試。此功能具有一些邏輯,可以根據(jù)報(bào)告的日期更改URL格式。這是經(jīng)過(guò)調(diào)試的版本。
- 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進(jìn)行單元測(cè)試
通常,當(dāng)我們使用unittest進(jìn)行測(cè)試時(shí),我們會(huì)將測(cè)試方法放在單獨(dú)的測(cè)試模塊中,或者可能將這些方法混入主模塊中。然后,我們需要執(zhí)行unittest.main方法,可能是__main__防護(hù)中的默認(rèn)方法。我們基本上可以在Jupyter notebook中執(zhí)行相同的操作。我們可以創(chuàng)建一個(gè)unitest.TestCase類,執(zhí)行所需的測(cè)試,然后僅在任何單元格中執(zhí)行單元測(cè)試。您只需要保存unittest.main方法的輸出并檢查是否有錯(cuò)誤。
- 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
事實(shí)證明,這非常簡(jiǎn)單,如果您不介意在notebook中混合使用代碼和進(jìn)行測(cè)試,那么效果很好。
使用doctest進(jìn)行單元測(cè)試
在代碼中包含測(cè)試的另一種方法是使用doctest。Doctest使用特殊格式的代碼文檔,其中包括我們的測(cè)試和預(yù)期結(jié)果。下面是包含此特殊代碼文檔的更新方法,包括正例和負(fù)例。這是一種在一個(gè)地方測(cè)試和記錄代碼的簡(jiǎn)單方法,通常會(huì)在python模塊中使用,main頭文件將僅在其中運(yùn)行doct測(cè)試,如下所示:
- if __name__ == __main__:
- doctest.testmod()
由于我們?cè)趎otebook中,因此只需將其添加到定義了代碼的單元格中,它也將起作用。首先,這是我們更新的帶有doctest注釋的make_url方法。
- 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進(jìn)行單元測(cè)試
testbook項(xiàng)目是notebook 單元測(cè)試的另一種方式。它允許您從notebook 外部以純Python代碼方式引用notebook 。這使您可以在單獨(dú)的Python模塊中使用任何您喜歡的測(cè)試框架(例如pytest或unittest)。您可能會(huì)遇到這樣的情況:允許用戶修改和更新notebook代碼是保持代碼更新并為最終用戶提供靈活性的最佳方法。但是您可能希望仍單獨(dú)對(duì)代碼進(jìn)行測(cè)試和驗(yàn)證。Testbook使其成為一個(gè)選項(xiàng)。
首先,您必須將其安裝在您的環(huán)境中:
- pip install testbook
或者在你的notebook中:
- %pip install testbook
現(xiàn)在,在一個(gè)單獨(dú)的python文件中,您可以導(dǎo)入notebook代碼并在那里進(jìn)行測(cè)試。在該文件中,您將創(chuàng)建類似于以下代碼的代碼,然后使用您更喜歡實(shí)際執(zhí)行單元測(cè)試的任何單元測(cè)試框架。您可以在Python文件中創(chuàng)建以下代碼(例如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 make_url(date) == "https://api.example.com/v2/2020/1/1"
在這種情況下,您現(xiàn)在可以使用任何單元測(cè)試框架來(lái)運(yùn)行測(cè)試。例如,使用pytest,您只需運(yùn)行以下命令:
- pytest jupyter_unit_tests.py
這可以作為正常的單元測(cè)試,并且測(cè)試應(yīng)該通過(guò)。但是,在撰寫本文時(shí),我意識(shí)到testbook代碼對(duì)將單元測(cè)試中的參數(shù)傳遞回notebook內(nèi)核進(jìn)行測(cè)試的支持有限。這些參數(shù)是JSON序列化的,并且當(dāng)前代碼知道如何處理各種Python類型。但是,它不會(huì)將日期時(shí)間作為對(duì)象傳遞,而是作為字符串傳遞。由于我們的代碼嘗試將字符串解析為日期(在我對(duì)其進(jìn)行修改之后),因此它可以工作。換句話說(shuō),上面的單元測(cè)試不是將datetime.date傳遞給make_url方法,而是傳遞一個(gè)字符串(2020-01-02),然后將其解析為一個(gè)日期。您如何將日期從單元測(cè)試傳遞到notebook代碼中?您有以下幾種選擇。首先,您可以在notebook中創(chuàng)建一個(gè)日期對(duì)象,僅用于測(cè)試目的,然后在單元測(cè)試中引用它。
- testdate1 = datetime.date(2020,1,1) # for unit test
然后,您可以編寫單元測(cè)試以在測(cè)試中使用該變量。
第二種選擇是將Python代碼寫入notebook,然后在單元測(cè)試中重新引用它。這兩個(gè)選項(xiàng)都顯示在外部單元測(cè)試的最終版本中。只需將其保存在jupyter_unit_tests.py上,然后使用您喜歡的單元測(cè)試框架來(lái)運(yùn)行它。
- 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"
總結(jié)
因此,無(wú)論您是單元測(cè)試的純粹主義者還是只想在notebooks中添加一些單元測(cè)試,您都可以考慮以上幾種選擇。不要讓notebooks的使用妨礙您在測(cè)試代碼方面做正確的事情。
























