Python 单元测试入门指南

更新于 2026-01-13

Zhe Ming Chng 2022-06-21

单元测试是一种软件测试方法,它关注代码中最小的可测试部分(称为“单元”),并验证这些单元是否按预期正确运行。通过单元测试,我们可以验证代码的每个部分(包括那些对用户不可见的辅助函数)是否都能如预期那样正常工作。

其核心思想是:我们独立地检查程序中的每一个小模块,以确保它们能正常运作。这与回归测试和集成测试形成对比——后者用于验证程序的不同部分能否协同工作并按预期运行。

在本文中,你将学习如何使用 Python 中两个流行的单元测试框架来实现单元测试:内置的 PyUnit 框架和 PyTest 框架。

完成本教程后,你将掌握以下内容:

  • Python 中的单元测试库,例如 PyUnit 和 PyTest
  • 如何通过单元测试验证函数的预期行为

概览

本教程分为五个部分,具体如下:

  1. 什么是单元测试?为什么它们很重要?
  2. 什么是测试驱动开发(TDD)?
  3. 使用 Python 内置的 PyUnit 框架
  4. 使用 PyTest 库
  5. 实战:单元测试的应用

什么是单元测试?为什么它们很重要?

回想一下你在学校做数学题时的情景:你需要完成多个算术步骤,然后将结果组合起来得到最终答案。你会如何检查每一步的计算是否正确,有没有粗心犯错或抄写错误呢?

现在,把这种思路延伸到代码中!我们当然不希望每次都手动逐行检查代码以静态验证其正确性。那么,该如何编写一个测试,来确保下面这段代码确实返回了矩形的面积呢?

def calculate_area_rectangle(width, height):
    return width * height

我们可以用几个测试用例运行这段代码,看看它是否返回了预期的输出。

这就是单元测试的核心思想!单元测试是一种检查代码单一组件(通常以函数形式模块化)的测试,用于确保该组件按预期执行。

单元测试是回归测试的重要组成部分,可以确保在对代码进行修改后,原有功能仍然按预期工作,从而保障代码的稳定性。当我们修改代码后,可以运行之前编写的单元测试,以确保代码库其他部分的现有功能未受此次修改的影响。

单元测试的另一个关键优势是能够帮助我们轻松隔离错误。想象一下,当你运行整个项目时,出现了一连串错误。你该如何调试代码?

这时,单元测试就派上用场了。我们可以分析单元测试的输出,查看代码的哪个组件抛出了错误,并从此处开始调试。虽然单元测试并不能总能直接定位到 bug,但它为我们提供了一个更便捷的起点,之后我们再通过集成测试来检查各组件之间的协作情况。

在本文的后续部分,我们将通过测试以下 Rectangle 类中的函数,来演示如何进行单元测试:

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 中实现它们!


测试驱动开发(Test Driven Development, TDD)

测试在良好的软件开发中如此重要,以至于还有一种基于测试的软件开发流程——测试驱动开发(TDD)。Robert C. Martin 提出了 TDD 的三条规则:

  1. 除非是为了让一个失败的单元测试通过,否则不允许编写任何生产代码。
  2. 不允许编写超出使测试失败所需的单元测试代码(编译失败也算作失败)。
  3. 不允许编写超出使当前失败的单元测试通过所需的生产代码。

TDD 的核心思想是:围绕我们创建的一组单元测试来开展软件开发,这使得单元测试成为 TDD 软件开发流程的核心。通过这种方式,你可以确保为开发的每个组件都编写了对应的测试。

TDD 还倾向于编写更小的测试——即更具体、每次只测试少量组件的测试。这有助于追踪错误,而且由于单次运行中涉及的组件较少,小型测试也更容易阅读和理解。

这并不意味着你必须在所有项目中使用 TDD。但你可以考虑将其作为一种同时开发代码和测试的方法。


使用 Python 内置的 PyUnit 框架

你可能会问:既然 Python 和其他语言都提供了 assert 关键字,为什么还需要单元测试框架呢?单元测试框架可以帮助我们自动化测试过程,并允许我们对同一个函数使用不同参数运行多个测试、检查预期异常等。

PyUnit 是 Python 的内置单元测试框架,也是 Java 中 JUnit 测试框架在 Python 中的对应实现。要开始编写测试文件,我们需要先导入 unittest 库以使用 PyUnit:

import unittest

然后,我们可以开始编写第一个单元测试。在 PyUnit 中,单元测试被组织为 unittest.TestCase 类的子类,我们可以重写 runTest() 方法,在其中使用 unittest.TestCase 中的各种断言函数来执行自己的单元测试:

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 中不同的断言函数还能让我们更好地测试各种行为,例如 self.assertRaises(exception)。这使我们能够检查某段代码是否产生了预期的异常。

要运行单元测试,我们在程序中调用 unittest.main()

...
unittest.main()

由于此案例中的代码返回了预期输出,因此测试成功运行,输出如下:

.
----------------------------------------------------------------------
Ran 1 test in 0.003s

OK

完整代码如下:

import unittest

# 待测试的代码
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

# 基于 unittest 模块的测试
class TestGetAreaRectangle(unittest.TestCase):
    def runTest(self):
        rectangle = Rectangle(2, 3)
        self.assertEqual(rectangle.get_area(), 6, "incorrect area")

# 运行测试
unittest.main()

注意:在上面的例子中,业务逻辑类 Rectangle 和测试代码 TestGetAreaRectangle 放在了同一个文件中。在实际项目中,你可能会将它们放在不同的文件中,并在测试代码中导入业务逻辑。这有助于更好地管理代码。

我们还可以在一个 unittest.TestCase 的子类中嵌套多个单元测试,方法是将新子类中的方法命名为以 “test” 开头,例如:

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): 
        """期望在面积为负数时返回 -1 以表示错误"""
        rectangle = Rectangle(-1, 2)
        self.assertEqual(rectangle.get_area(), -1, "incorrect negative output")

运行上述代码将产生我们的第一个错误:

F.
======================================================================
FAIL: test_negative_case (__main__.TestGetAreaRectangle)
expect -1 as output to denote error when looking at negative area
----------------------------------------------------------------------
Traceback (most recent call last):
  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(如输出中高亮所示),以及标准错误信息,因为 get_area() 并未像我们在测试中预期的那样返回 -1

unittest 中定义了许多不同类型的断言函数。例如,我们可以使用 TestCase 类中的:

def test_geq(self):
    """测试值是否大于或等于某个特定目标"""
    self.assertGreaterEqual(self.rectangle.get_area(), -1)

我们甚至可以检查在执行过程中是否抛出了特定异常:

def test_assert_raises(self): 
    """使用 assertRaises 检测在运行特定代码块时是否抛出了预期的错误"""
    with self.assertRaises(ZeroDivisionError):
        a = 1 / 0

现在,我们来看看如何构建更复杂的测试。如果我们在每次测试前都需要运行一些设置代码怎么办?我们可以重写 unittest.TestCase 中的 setUp 方法。

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): 
        """期望在面积为负数时返回 -1 以表示错误"""
        self.rectangle.set_width(-1)
        self.rectangle.set_height(2)
        self.assertEqual(self.rectangle.get_area(), -1, "incorrect negative output")

在上面的代码示例中,我们重写了 unittest.TestCasesetUp() 方法,用自己的 setUp() 方法初始化了一个 Rectangle 对象。这个 setUp() 方法会在每个单元测试之前运行,有助于避免当多个测试依赖同一段设置代码时的重复。

同样,我们也可以重写 tearDown() 方法,用于在每个测试之后执行清理代码。

如果希望某个方法在整个 TestCase 类中只运行一次(而不是每个测试都运行一次),我们可以使用 setUpClass 方法,如下所示:

class TestGetAreaRectangleWithSetUp(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.rectangle = Rectangle(0, 0)

上述代码在整个 TestCase 类中只运行一次,而不是像 setUp 那样在每次测试时都运行。(这类似于 JUnit 中的 @BeforeClass 注解。)

为了帮助我们组织测试并选择要运行的测试集,我们可以将测试用例聚合到**测试套件(test suites)**中,这样可以将应一起执行的测试分组到一个对象中:

# 将 TestGetAreaRectangle 中的所有单元测试加载到一个测试套件中
calculate_area_suite = unittest.TestLoader().loadTestsFromTestCase(TestGetAreaRectangleWithSetUp)

这里,我们还介绍了另一种在 PyUnit 中运行测试的方式:使用 unittest.TextTestRunner 类,它允许我们运行特定的测试套件。

runner = unittest.TextTestRunner()
runner.run(calculate_area_suite)

这会给出与从命令行运行文件并调用 unittest.main() 相同的输出。

综合以上内容,完整的单元测试脚本如下所示:

class TestGetAreaRectangleWithSetUp(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        # 此方法在整个类中只运行一次,而不是像 setUp() 那样每个测试都运行
        cls.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):
        """测试值是否大于或等于某个特定目标"""
        self.assertGreaterEqual(self.rectangle.get_area(), -1)

    def test_assert_raises(self): 
        """使用 assertRaises 检测在运行特定代码块时是否抛出了预期的错误"""
        with self.assertRaises(ZeroDivisionError):
            a = 1 / 0

以上只是 PyUnit 功能的冰山一角。我们还可以编写匹配正则表达式的异常消息测试,或者编写仅运行一次的 setUp/tearDown 方法(例如 setUpClass)。


使用 PyTest

PyTest 是 unittest 模块的替代方案。要开始使用 PyTest,首先需要安装它,可通过以下命令安装:

pip install pytest

编写测试时,只需编写以 “test” 为前缀命名的函数,PyTest 的测试发现机制就能自动找到你的测试,例如:

def test_normal_case():
    rectangle = Rectangle(2, 3)
    assert rectangle.get_area() == 6, "incorrect area"

你会注意到,PyTest 使用 Python 内置的 assert 关键字,而不是像 PyUnit 那样使用自己的一套断言函数。这可能更方便一些,因为我们无需查找各种不同的断言函数。

完整代码如下:

# 待测试的代码
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 执行的测试函数
def test_normal_case():
    rectangle = Rectangle(2, 3)
    assert rectangle.get_area() == 6, "incorrect area"

将上述代码保存为 test_file.py 文件后,可以通过以下命令运行 PyTest 单元测试:

python -m pytest test_file.py

输出如下:

=================== 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” 开头,例如:

class TestGetAreaRectangle:
    def test_normal_case(self):
        rectangle = Rectangle(2, 3)
        assert rectangle.get_area() == 6, "incorrect area"
    def test_negative_case(self): 
        """期望在面积为负数时返回 -1 以表示错误"""
        rectangle = Rectangle(-1, 2)
        assert rectangle.get_area() == -1, "incorrect negative output"

使用 PyTest 运行上述代码将产生以下输出:

=================== 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 ================

完整代码如下:

# 待测试的代码
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 执行的测试函数
class TestGetAreaRectangle:
    def test_normal_case(self):
        rectangle = Rectangle(2, 3)
        assert rectangle.get_area() == 6, "incorrect area"
    def test_negative_case(self):
        """期望在面积为负数时返回 -1 以表示错误"""
        rectangle = Rectangle(-1, 2)
        assert rectangle.get_area() == -1, "incorrect negative output"

要为测试实现 setup 和 teardown 代码,PyTest 提供了极其灵活的 fixture 系统。Fixture 是具有返回值的函数。PyTest 的 fixture 系统允许在类、模块、包或会话之间共享 fixture,并且 fixture 可以将其他 fixture 作为参数调用。

下面是一个 PyTest fixture 系统的简单介绍:

@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 的所有引用都指向相同的返回值(如果返回值是引用类型,这一点尤为重要)。

完整代码如下:

import pytest

# 待测试的代码
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 也提供了大量其他功能,可帮助你构建更全面、更高级的单元测试。


实战:单元测试的应用

现在,我们来看一个单元测试的实际应用案例。在这个例子中,我们将测试一个从 Yahoo Finance 获取股票数据的函数(使用 pandas_datareader),并使用 PyUnit 实现:

import pandas_datareader.data as web

def get_stock_data(ticker):
    """从 stooq 拉取数据"""
    df = web.DataReader(ticker, "yahoo")
    return df

该函数通过爬取 Yahoo Finance 网站获取特定股票代码的数据,并返回一个 pandas DataFrame。这个过程可能以多种方式失败。例如,数据读取器可能无法返回任何内容(如果 Yahoo Finance 宕机),或者返回的 DataFrame 缺少某些列,或者列中存在缺失数据(如果数据源网站结构调整)。因此,我们应该提供多个测试函数,以检查多种失败模式:

import datetime
import unittest

import pandas as pd
import pandas_datareader.data as web

def get_stock_data(ticker):
    """从 stooq 拉取数据"""
    df = web.DataReader(ticker, 'yahoo')
    return df

class TestGetStockData(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        """由于这是一个耗时操作,我们希望每个 TestCase 只拉取一次数据"""
        cls.df = get_stock_data('^DJI')

    def test_columns_present(self):
        """确保预期的列都存在"""
        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):
        """确保数据行数大于零"""
        self.assertNotEqual(len(self.df.index), 0)

    def test_high_low(self):
        """确保 high 和 low 确实是同一行中的最高价和最低价"""
        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_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 的等效版本:

import pytest
import datetime
import pandas as pd
import pandas_datareader.data as web

def get_stock_data(ticker):
    """从 stooq 拉取数据"""
    df = web.DataReader(ticker, 'yahoo')
    return df

# scope="class" 表示 fixture 在类中最后一个测试结束后才 teardown,避免重复执行此步骤。
@pytest.fixture(scope="class")
def stock_df():
    # 由于这是一个耗时操作,我们希望每个 TestCase 只拉取一次数据
    df = get_stock_data('^DJI')
    return df

class TestGetStockData:

    def test_columns_present(self, stock_df):
        # 确保预期的列都存在
        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):
        # 确保数据行数大于零
        assert len(stock_df.index) != 0

    def test_most_recent_within_week(self, stock_df):
        # 确保最近的数据是在过去一周内收集的
        most_recent_date = pd.to_datetime(stock_df.index[-1])
        assert (datetime.datetime.today() - most_recent_date).days <= 7

编写单元测试可能看起来耗时且繁琐,但它们可以成为任何 CI 流水线的关键组成部分,并且是及早发现 bug 的宝贵工具——在 bug 向下游传播并变得代价高昂之前就将其捕获。


总结

在本文中,你了解了什么是单元测试,以及如何使用 Python 中两个流行的库(PyUnit 和 PyTest)进行单元测试。你还学习了如何配置单元测试,并看到了单元测试在数据科学流水线中的一个实际用例。

具体来说,你学到了:

  • 什么是单元测试,以及为什么它很有用
  • 单元测试如何融入测试驱动开发(TDD)流程
  • 如何在 Python 中使用 PyUnit 和 PyTest 进行单元测试