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