单元测试是一种测试软件的方法,它关注代码中最小的可测试单元,即“单元”,并测试其是否正常运行。通过进行单元测试,我们可以验证代码的每个部分,包括可能未向用户公开的辅助函数,是否按预期正确工作。
其理念是独立地检查我们程序的每个小部分,以确保它能够正常工作。这与回归测试和集成测试形成对比,后者测试程序的各个部分能否良好地协同工作并按预期运行。
在本文中,您将了解如何使用两个流行的单元测试框架在 Python 中实现单元测试:内置的 PyUnit 框架和 PyTest 框架。
完成本教程后,您将了解:
- Python 中的单元测试库,如 PyUnit 和 PyTest
- 通过使用单元测试来检查预期的函数行为
开始您的项目,阅读我的新书《Python for Machine Learning》,其中包含分步教程和所有示例的Python源代码文件。
让我们开始吧!
Python 单元测试入门
照片由 Bee Naturalles 拍摄。部分权利保留。
概述
本教程分为五个部分;它们是:
- 什么是单元测试,为什么它们很重要?
- 什么是测试驱动开发 (TDD)?
- 使用 Python 内置的 PyUnit 框架
- 使用 PyTest 库
- 单元测试实战
什么是单元测试,为什么它们很重要?
还记得上学时做数学题吗?在将不同的算术运算组合起来得到正确答案之前,您需要完成这些运算。想象一下,您如何检查以确保每一步的计算都正确,并且没有犯任何粗心的错误或写错任何内容。
现在,将这个想法扩展到代码!我们不希望不断地审视我们的代码来静态验证其正确性,那么如何创建一个测试来确保以下代码片段实际上返回了矩形的面积呢?
1 2 |
def calculate_area_rectangle(width, height): return width * height |
我们可以用几个测试示例运行代码,看看它是否返回预期的输出。
这就是单元测试的理念!单元测试是一种测试,它检查代码的单个组件,通常是模块化的函数,并确保其行为符合预期。
单元测试是回归测试的重要组成部分,以确保在修改代码后代码仍然按预期运行,并有助于确保代码的稳定性。在修改代码后,我们可以运行我们之前创建的单元测试,以确保代码库其他部分中的现有功能没有受到我们更改的影响。
单元测试的另一个关键好处是它们有助于轻松隔离错误。想象一下运行整个项目并收到一串错误。我们如何调试代码?
这就是单元测试的用武之地。我们可以分析单元测试的输出,看看我们代码的任何组件是否抛出了错误,并从那里开始调试。这并不是说单元测试总能帮助我们找到 bug,但它为我们提供了一个更方便的起点,然后再开始查看集成测试中组件的集成。
在文章的其余部分,我们将通过测试此 Rectangle 类中的函数来展示如何进行单元测试。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Rectangle: def __init__(self, width, height): self.width = width self.height = height def get_area(self): return self.width * self.height def set_width(self, width): self.width = width def set_height(self, height): self.height = height |
现在我们已经了解了单元测试的重要性,让我们探讨一下如何在开发流程中实际使用单元测试以及如何在 Python 中实现它们!
测试驱动开发
测试对于良好的软件开发至关重要,以至于甚至有一个基于测试的软件开发流程,即测试驱动开发 (TDD)。Robert C. Martin 提出的 TDD 的三个规则是:
- 除非是为了让失败的单元测试通过,否则不允许编写任何生产代码。
- 不允许编写超出使单元测试失败所需的量的单元测试,而编译失败也算失败。
- 不允许编写超出使一个失败的单元测试通过所需的量的生产代码。
TDD 的关键思想是我们围绕我们创建的一组单元测试来构建我们的软件开发,这使得单元测试成为 TDD 软件开发流程的核心。这样,您就可以确保您开发的每个组件都有一个测试。
TDD 也倾向于编写更小的测试,这意味着测试更具体,一次测试的组件更少。这有助于追踪错误,并且由于单次运行涉及的组件较少,因此更小的测试也更容易阅读和理解。
这并不意味着您必须在项目中使用 TDD。但您可以将其视为一种同时开发您的代码和测试的方法。
想开始学习机器学习 Python 吗?
立即参加我为期7天的免费电子邮件速成课程(附示例代码)。
点击注册,同时获得该课程的免费PDF电子书版本。
使用 Python 内置 PyUnit 框架
您可能想知道,为什么我们需要单元测试框架,而 Python 和其他语言提供了 assert
关键字?单元测试框架有助于自动化测试过程,并允许我们对同一函数使用不同的参数运行多个测试,检查预期的异常等等。
PyUnit 是 Python 的内置单元测试框架,也是 Python 对 Java 相应 JUnit 测试框架的版本。要开始构建测试文件,我们需要导入 unittest
库来使用 PyUnit。
1 |
import unittest |
然后,我们可以开始编写我们的第一个单元测试。PyUnit 中的单元测试被构建为 unittest.TestCase
类的子类,我们可以重写 runTest()
方法来执行我们自己的单元测试,这些测试使用 unittest.TestCase
中的不同 assert 函数来检查条件。
1 2 3 4 |
class TestGetAreaRectangle(unittest.TestCase): def runTest(self): rectangle = Rectangle(2, 3) self.assertEqual(rectangle.get_area(), 6, "incorrect area") |
这就是我们的第一个单元测试!它检查 rectangle.get_area()
方法是否返回宽度为 2、长度为 3 的矩形的正确面积。我们使用 self.assertEqual
而不是简单地使用 assert
,是因为 unittest
库允许运行器累积所有测试用例并生成报告。
使用 unittest.TestCase
中的不同 assert 函数,我们还可以更好地测试不同的行为,例如 self.assertRaises(exception)
。这允许我们检查某段代码是否产生了预期的异常。
要运行单元测试,我们需要在程序中调用 unittest.main()
。
1 2 |
... unittest.main() |
由于代码在此情况下返回了预期的输出,因此返回测试成功运行,输出如下:
1 2 3 4 5 |
. ---------------------------------------------------------------------- Ran 1 test in 0.003s 好的 |
完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import unittest # Our code to be tested class Rectangle: def __init__(self, width, height): self.width = width self.height = height def get_area(self): return self.width * self.height def set_width(self, width): self.width = width def set_height(self, height): self.height = height # The test based on unittest module class TestGetAreaRectangle(unittest.TestCase): def runTest(self): rectangle = Rectangle(2, 3) self.assertEqual(rectangle.get_area(), 6, "incorrect area") # run the test unittest.main() |
注意:虽然在上面,我们将业务逻辑 Rectangle
类和我们的测试代码 TestGetAreaRectangle
放在了一起。实际上,您可以将它们放在不同的文件中,然后将业务逻辑 import
到您的测试代码中。这可以帮助您更好地管理代码。
我们也可以在一个 unittest.TestCase
的子类中嵌套多个单元测试,通过将新子类中的方法命名为带有“test
”前缀,例如:
1 2 3 4 5 6 7 8 9 |
class TestGetAreaRectangle(unittest.TestCase): def test_normal_case(self): rectangle = Rectangle(2, 3) self.assertEqual(rectangle.get_area(), 6, "incorrect area") def test_negative_case(self): """expect -1 as output to denote error when looking at negative area""" rectangle = Rectangle(-1, 2) self.assertEqual(rectangle.get_area(), -1, "incorrect negative output") |
运行这个会给我们带来第一个错误:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
F. ====================================================================== FAIL: test_negative_case (__main__.TestGetAreaRectangle) expect -1 as output to denote error when looking at negative area ---------------------------------------------------------------------- 回溯(最近一次调用) File "<ipython-input-96-59b1047bb08a>", line 9, in test_negative_case self.assertEqual(rectangle.get_area(), -1, "incorrect negative output") AssertionError: -2 != -1 : incorrect negative output ---------------------------------------------------------------------- Ran 2 tests in 0.003s FAILED (failures=1) |
我们可以看到失败的单元测试,即 test_negative_case
,正如输出中所突出显示的那样,以及 stderr 消息,因为 get_area()
没有像我们在测试中预期的那样返回 -1。
unittest 中定义了许多不同种类的 assert 函数。例如,我们可以使用 TestCase 类。
1 2 3 |
def test_geq(self): """tests if value is greater than or equal to a particular target""" self.assertGreaterEqual(self.rectangle.get_area(), -1) |
我们甚至可以检查在执行期间是否抛出了特定的异常。
1 2 3 4 |
def test_assert_raises(self): """using assertRaises to detect if an expected error is raised when running a particular block of code""" with self.assertRaises(ZeroDivisionError): a = 1 / 0 |
现在,我们来看如何构建我们的测试。如果我们有一些需要在运行每个测试之前执行的代码怎么办?嗯,我们可以重写 unittest.TestCase 中的 setUp 方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class TestGetAreaRectangleWithSetUp(unittest.TestCase): def setUp(self): self.rectangle = Rectangle(0, 0) def test_normal_case(self): self.rectangle.set_width(2) self.rectangle.set_height(3) self.assertEqual(self.rectangle.get_area(), 6, "incorrect area") def test_negative_case(self): """expect -1 as output to denote error when looking at negative area""" self.rectangle.set_width(-1) self.rectangle.set_height(2) self.assertEqual(self.rectangle.get_area(), -1, "incorrect negative output") |
在上面的代码示例中,我们重写了 unittest.TestCase
中的 setUp()
方法,并使用我们自己的 setUp()
方法来初始化一个 Rectangle
对象。此 setUp()
方法在每个单元测试之前运行,有助于避免代码重复,当多个测试依赖于相同的代码来设置测试时。这类似于 JUnit 中的 @Before
装饰器。
同样,还有一个 tearDown()
方法,我们也可以重写它来在每个测试后执行代码。
为了只在每个 TestCase 类中运行一次该方法,我们也可以使用 setUpClass 方法,如下所示:
1 2 3 4 |
class TestGetAreaRectangleWithSetUp(unittest.TestCase): @classmethod def setUpClass(self): self.rectangle = Rectangle(0, 0) |
上面的代码只在每个 TestCase 中运行一次,而不是像 setUp 那样在每次测试运行时运行。
为了帮助我们组织测试并选择要运行的测试集,我们可以将测试用例聚合到测试套件中,这些测试套件有助于将应该一起执行的测试分组到单个对象中。
1 2 3 4 |
... # loads all unit tests from TestGetAreaRectangle into a test suite calculate_area_suite = unittest.TestLoader() \ .loadTestsFromTestCase(TestGetAreaRectangleWithSetUp) |
在这里,我们还介绍了使用 unittest.TextTestRunner
类在 PyUnit 中运行测试的另一种方法,该方法允许我们运行特定的测试套件。
1 2 |
runner = unittest.TextTestRunner() runner.run(calculate_area_suite) |
这会产生与从命令行运行文件并调用 unittest.main()
相同的输出。
总而言之,这就是单元测试的完整脚本的样子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class TestGetAreaRectangleWithSetUp(unittest.TestCase): @classmethod def setUpClass(self): #this method is only run once for the entire class rather than being run for each test which is done for setUp() self.rectangle = Rectangle(0, 0) def test_normal_case(self): self.rectangle.set_width(2) self.rectangle.set_height(3) self.assertEqual(self.rectangle.get_area(), 6, "incorrect area") def test_geq(self): """tests if value is greater than or equal to a particular target""" self.assertGreaterEqual(self.rectangle.get_area(), -1) def test_assert_raises(self): """using assertRaises to detect if an expected error is raised when running a particular block of code""" with self.assertRaises(ZeroDivisionError): a = 1 / 0 |
这仅仅是 PyUnit 可以做什么的冰山一角。我们还可以编写查找与正则表达式匹配的异常消息的测试,或者只运行一次的 setUp
/tearDown
方法(例如 setUpClass
)。
使用 PyTest
PyTest 是内置 unittest 模块的替代方案。要开始使用 PyTest,您首先需要安装它,您可以使用以下命令进行安装:
1 |
pip install pytest |
要编写测试,您只需要编写名称以“test
”为前缀的函数,PyTest 的测试发现机制就能找到您的测试,例如:
1 2 3 |
def test_normal_case(self): rectangle = Rectangle(2, 3) assert rectangle.get_area() == 6, "incorrect area" |
您会注意到,PyTest 使用 Python 的内置 assert
关键字,而不是像 PyUnit 那样的一组自己的 assert 函数,这可能更方便一些,因为我们可以避免查找不同的 assert 函数。
完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
# Our code to be tested class Rectangle: def __init__(self, width, height): self.width = width self.height = height def get_area(self): return self.width * self.height def set_width(self, width): self.width = width def set_height(self, height): self.height = height # The test function to be executed by PyTest def test_normal_case(): rectangle = Rectangle(2, 3) assert rectangle.get_area() == 6, "incorrect area" |
将此保存到文件 test_file.py
中后,我们可以通过以下方式运行 PyTest 单元测试:
1 |
python -m pytest test_file.py |
这将给我们带来以下输出:
1 2 3 4 5 6 7 8 9 |
=================== test session starts ==================== platform darwin -- Python 3.9.9, pytest-7.0.1, pluggy-1.0.0 rootdir: /Users/MLM plugins: anyio-3.4.0, typeguard-2.13.2 collected 1 item test_file.py . [100%] ==================== 1 passed in 0.01s ===================== |
您可能会注意到,在 PyUnit 中,我们需要通过运行器或调用 unittest.main()
来调用测试例程。但在 PyTest 中,我们只需将文件传递给模块。PyTest 模块将收集所有以 test
前缀定义的函数,并逐一调用它们。然后它将验证 assert
语句是否引发了任何异常。允许测试与业务逻辑一起存在可能更方便。
PyTest 也支持将函数分组到类中,但类名应以“Test
”前缀(大写 T)开头,例如:
1 2 3 4 5 6 7 8 |
class TestGetAreaRectangle: def test_normal_case(self): rectangle = Rectangle(2, 3) assert rectangle.get_area() == 6, "incorrect area" def test_negative_case(self): """expect -1 as output to denote error when looking at negative area""" rectangle = Rectangle(-1, 2) assert rectangle.get_area() == -1, "incorrect negative output" |
使用 PyTest 运行此将产生以下输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
=================== test session starts ==================== platform darwin -- Python 3.9.9, pytest-7.0.1, pluggy-1.0.0 rootdir: /Users/MLM plugins: anyio-3.4.0, typeguard-2.13.2 collected 2 items test_code.py .F [100%] ========================= FAILURES ========================= _________ TestGetAreaRectangle.test_negative_case __________ self = <test_code.TestGetAreaRectangle object at 0x10f5b3fd0> def test_negative_case(self) """expect -1 as output to denote error when looking at negative area""" rectangle = Rectangle(-1, 2) > assert rectangle.get_area() == -1, "incorrect negative output" E AssertionError: incorrect negative output E assert -2 == -1 E + where -2 = <bound method Rectangle.get_area of <test_code.Rectangle object at 0x10f5b3df0>>() E + where <bound method Rectangle.get_area of <test_code.Rectangle object at 0x10f5b3df0>> = <test_code.Rectangle object at 0x10f5b3df0>.get_area unittest5.py:24: AssertionError ================= short test summary info ================== FAILED test_code.py::TestGetAreaRectangle::test_negative_case =============== 1 failed, 1 passed in 0.12s ================ |
完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
# Our code to be tested class Rectangle: def __init__(self, width, height): self.width = width self.height = height def get_area(self): return self.width * self.height def set_width(self, width): self.width = width def set_height(self, height): self.height = height # The test functions to be executed by PyTest class TestGetAreaRectangle: def test_normal_case(self): rectangle = Rectangle(2, 3) assert rectangle.get_area() == 6, "incorrect area" def test_negative_case(self): """expect -1 as output to denote error when looking at negative area""" rectangle = Rectangle(-1, 2) assert rectangle.get_area() == -1, "incorrect negative output" |
为了实现测试的设置和拆卸代码,PyTest 拥有一个极其灵活的 fixture 系统,其中 fixture 是具有返回值的函数。PyTest 的 fixture 系统允许跨类、模块、包或会话共享 fixture,并且 fixture 可以将其他 fixture 作为参数调用。
这里我们包含了对 PyTest fixture 系统的一个简单介绍。
1 2 3 4 5 6 7 8 9 |
@pytest.fixture def rectangle(): return Rectangle(0, 0) def test_negative_case(rectangle): print (rectangle.width) rectangle.set_width(-1) rectangle.set_height(2) assert rectangle.get_area() == -1, "incorrect negative output" |
以上代码将 Rectangle 引入为 fixture,PyTest 会将 test_negative_case
的参数列表中的 rectangle 与 fixture 匹配,并为 test_negative_case
提供 rectangle 函数的独立输出集。它会为每个其他测试执行此操作。但是,请注意,fixture 可以被一个测试请求多次,并且每次测试只运行一次 fixture,结果会被缓存。这意味着在单个测试的运行过程中,对该 fixture 的所有引用都指向相同的返回值(如果返回值是引用类型,这一点很重要)。
完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import pytest # Our code to be tested class Rectangle: def __init__(self, width, height): self.width = width self.height = height def get_area(self): return self.width * self.height def set_width(self, width): self.width = width def set_height(self, height): self.height = height @pytest.fixture def rectangle(): return Rectangle(0, 0) def test_negative_case(rectangle): print (rectangle.width) rectangle.set_width(-1) rectangle.set_height(2) assert rectangle.get_area() == -1, "incorrect negative output" |
与 PyUnit 类似,PyTest 还有许多其他功能,可以让你构建更全面、更高级的单元测试。
单元测试实战
现在,我们将探讨单元测试实战。在我们的示例中,我们将测试一个使用 pandas_datareader
从 Yahoo Finance 获取股票数据的函数,并使用 PyUnit 来实现。
1 2 3 4 5 6 |
import pandas_datareader.data as web def get_stock_data(ticker): """pull data from stooq""" df = web.DataReader(ticker, "yahoo") return df |
此函数通过爬取 Yahoo Finance 网站来获取特定股票代码的股票数据,并返回 pandas DataFrame。这可能以多种方式失败。例如,数据读取器可能无法返回任何内容(如果 Yahoo Finance 宕机),或者返回一个缺少列或列中缺少数据的 DataFrame(如果源网站更改了其结构)。因此,我们应该提供多个测试函数来检查多种失败模式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
import datetime import unittest import pandas as pd import pandas_datareader.data as web def get_stock_data(ticker): """pull data from stooq""" df = web.DataReader(ticker, "yahoo") return df class TestGetStockData(unittest.TestCase): @classmethod def setUpClass(self): """We only want to pull this data once for each TestCase since it is an expensive operation""" self.df = get_stock_data('^DJI') def test_columns_present(self): """ensures that the expected columns are all present""" self.assertIn("Open", self.df.columns) self.assertIn("High", self.df.columns) self.assertIn("Low", self.df.columns) self.assertIn("Close", self.df.columns) self.assertIn("Volume", self.df.columns) def test_non_empty(self): """ensures that there is more than one row of data""" self.assertNotEqual(len(self.df.index), 0) def test_high_low(self): """ensure high and low are the highest and lowest in the same row""" ohlc = self.df[["Open","High","Low","Close"]] highest = ohlc.max(axis=1) lowest = ohlc.min(axis=1) self.assertTrue(ohlc.le(highest, axis=0).all(axis=None)) self.assertTrue(ohlc.ge(lowest, axis=0).all(axis=None)) def test_most_recent_within_week(self): """most recent data was collected within the last week""" most_recent_date = pd.to_datetime(self.df.index[-1]) self.assertLessEqual((datetime.datetime.today() - most_recent_date).days, 7) unittest.main() |
我们上面的一系列单元测试检查了是否存在某些列(test_columns_present
),DataFrame 是否非空(test_non_empty
),“high”和“low”列是否确实是同一行的最高和最低值(test_high_low
),以及 DataFrame 中最近的数据是否在过去 7 天内(test_most_recent_within_week
)。
试想一下,你正在做一个消耗股市数据的机器学习项目。拥有一个单元测试框架可以帮助你识别数据预处理是否如预期般工作。
通过这些单元测试,我们可以识别出函数输出是否存在实质性变化,这可以成为持续集成(CI)流程的一部分。我们还可以根据对该函数的依赖功能附加其他单元测试。
为了完整起见,这里有一个 PyTest 的等效版本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
import pytest # scope="class" 仅在类的最后一个测试结束时才销毁 fixture,因此我们避免了重复运行此步骤。 @pytest.fixture(scope="class") def stock_df(): # We only want to pull this data once for each TestCase since it is an expensive operation df = get_stock_data('^DJI') return df class TestGetStockData: def test_columns_present(self, stock_df): # ensures that the expected columns are all present assert "Open" in stock_df.columns assert "High" in stock_df.columns assert "Low" in stock_df.columns assert "Close" in stock_df.columns assert "Volume" in stock_df.columns def test_non_empty(self, stock_df): # ensures that there is more than one row of data assert len(stock_df.index) != 0 def test_most_recent_within_week(self, stock_df): # most recent data was collected within the last week most_recent_date = pd.to_datetime(stock_df.index[0]) assert (datetime.datetime.today() - most_recent_date).days <= 7 |
构建单元测试可能看起来耗时且乏味,但它们可以是任何 CI 管道的关键组成部分,并且是尽早捕捉 bug 的宝贵工具,防止它们进一步进入管道并变得更昂贵来解决。
如果你喜欢它,那么你应该对它进行测试。
— Google 的软件工程
进一步阅读
如果您想深入了解,本节提供了更多关于该主题的资源。
库
- unittest 模块(以及 assert 方法列表),https://docs.pythonlang.cn/3/library/unittest.html
- PyTest,https://pytest.cn/en/7.0.x/
文章
- 测试驱动开发(TDD),https://www.ibm.com/garage/method/practices/code/practice_test_driven_development/
- Python 单元测试框架,http://pyunit.sourceforge.net/pyunit.html
书籍
- Google 软件工程,作者 Titus Winters、Tom Manshreck 和 Hyrum Wright https://www.amazon.com/dp/1492082791
- 编程实践,作者 Brian Kernighan 和 Rob Pike(第 5 章和第 6 章),https://www.amazon.com/dp/020161586X
总结
在本帖中,你了解了单元测试是什么,以及如何使用 Python 中两个流行的库进行单元测试(PyUnit、PyTest)。你还学会了如何配置单元测试,并看到了数据科学管道中单元测试的用例示例。
具体来说,你学到了:
- 单元测试是什么,以及它为什么有用
- 单元测试如何融入测试驱动开发管道
- 如何使用 PyUnit 和 PyTest 在 Python 中进行单元测试
你好,
我正在处理一个简单的移动平均交易策略,我正在尝试应用单元测试来验证高、低、收盘、开盘、成交量。请帮我解决测试用例的问题。
你好 Dev…以下资源可能会有所帮助
https://www.digitalocean.com/community/tutorials/python-unittest-unit-test-example
谢谢您,先生!
不客气,hakan!我们感谢您的支持!