网站搜索

Python 的单元测试:为您的代码编写单元测试


Python 标准库附带了一个名为 unittest 的测试框架,您可以使用该框架为代码编写自动化测试。 unittest 包采用面向对象的方法,其中测试用例派生自基类,该基类具有多种有用的方法。

该框架支持许多功能,可帮助您为代码编写一致的单元测试。这些功能包括测试用例、固定装置、测试套件和测试发现功能。

在本教程中,您将学习如何:

  • 使用 TestCase 类编写 unittest 测试
  • 探索 TestCase 提供的 assert 方法
  • 命令行使用unittest
  • 使用 TestSuite 类对测试用例进行分组
  • 创建装置来处理设置拆卸逻辑

为了充分利用本教程,您应该熟悉一些重要的 Python 概念,例如面向对象编程、继承和断言。对代码测试有很好的理解是一个优势。

测试你的Python代码

代码测试或软件测试是现代软件开发周期的基本组成部分。通过代码测试,您可以验证给定的软件项目是否按预期工作并满足其要求。测试增强了代码质量和稳健性。

您将在应用程序或项目的开发阶段进行代码测试。您将编写测试来隔离代码部分并验证其正确性。编写良好的电池或测试套件也可以作为手头项目的文档。

您会发现有关测试的几种不同的概念和技术。其中大多数超出了本教程的范围。然而,单元测试是一个重要且相关的概念。单元测试是对单个软件单元进行的测试。单元测试旨在验证测试单元是否按设计工作。

单元通常是程序的一小部分,它接受一些输入并产生输出。函数、方法和其他可调用对象是您需要测试的单元的良好示例。

在 Python 中,有多种工具可以帮助您编写、组织、运行和自动化单元测试。在 Python 标准库中,您将找到其中两个工具:

    doctest
    unittest

Python 的 doctest 模块是一个轻量级测试框架,可提供快速、直接的测试自动化。它可以从项目的文档和代码的文档字符串中读取测试用例。该框架随 Python 解释器一起提供,作为电池包含理念的一部分。

unittest 包也是一个测试框架。但是,它提供了比 doctest 更完整的解决方案。在以下部分中,您将学习并使用 unittest 为您的 Python 代码创建合适的单元测试。

了解 Python 的 unittest

unittest 包提供了一个受 JUnit 启发的单元测试框架,JUnit 是 Java 语言的单元测试框架。 unittest 框架可直接在标准库中使用,因此您无需安装任何东西即可使用此工具。

该框架使用面向对象的方法,并支持一些有助于测试创建、组织、准备和自动化的基本概念:

  • 测试用例:单个测试单元。它检查给定输入集的输出。
  • 测试套件:测试用例、测试套件或两者的集合。它们被分组并作为一个整体执行。
  • 测试装置:设置测试环境所需的一组操作。它还包括测试运行后的拆卸过程。
  • 测试运行程序:处理测试执行并将结果传达给用户的组件。

在以下部分中,您将深入使用 unittest 包来创建测试用例、测试套件、固定装置,当然还有运行测试。

使用 TestCase 类组织您的测试

unittest 包定义了 TestCase 类,该类主要用于编写单元测试。要开始编写测试用例,您只需导入该类并对其进行子类化。然后,您将添加名称以 test 开头的方法。这些方法将使用不同的输入测试给定的代码单元并检查预期结果。

这是一个测试内置 abs() 函数的快速测试用例:

import unittest

class TestAbsFunction(unittest.TestCase):
    def test_positive_number(self):
        self.assertEqual(abs(10), 10)

    def test_negative_number(self):
        self.assertEqual(abs(-10), 10)

    def test_zero(self):
        self.assertEqual(abs(0), 0)

abs() 函数接受一个数字作为参数并返回其绝对值。在此测试用例中,您有三种测试方法。每种方法都会检查特定的输入和输出组合。

要创建测试用例,您需要对 TestCase 类进行子类化并添加三个方法。第一个方法检查当您传递正数时 abs() 是否返回正确的值。第二种方法用负数检查预期行为。最后,当您使用 0 作为参数时,第三个方法检查 abs() 的返回值。

请注意,要检查条件,请使用 .assertEqual() 方法,该方法是您的类从 TestCase 继承的。稍后将详细介绍这些类型的方法。现在,您几乎已准备好使用 unittest 编写并运行您的第一个测试用例。

创建测试用例

在使用 unittest 编写测试之前,您需要一些代码来测试。假设您需要获取一个人的年龄,处理该信息并显示他们当前的生命阶段。例如,如果此人的年龄是:

  • 09 之间(两者都包含),该函数应返回 "Child"
  • 大于 9 且小于或等于 18,该函数应返回 "Adolescent"
  • 大于 18 且小于或等于 65,该函数应返回 "Adult"
  • 大于 65 且小于或等于 150,该函数应返回“黄金时代”
  • 负数或大于150,该函数应返回“无效年龄”

在这种情况下,您可以编写如下函数:

def categorize_by_age(age):
    if 0 <= age <= 9:
        return "Child"
    elif 9 < age <= 18:
        return "Adolescent"
    elif 18 < age <= 65:
        return "Adult"
    elif 65 < age <= 150:
        return "Golden age"
    else:
        return f"Invalid age: {age}"

此函数应返回具有不同年龄值的正确结果。为了确保函数正常工作,您可以编写一些unittest测试。

按照上一节中的模式,您将从子类化 TestCase 开始,并添加一些方法来帮助您测试不同的输入值和相应的结果:

import unittest

from age import categorize_by_age

class TestCategorizeByAge(unittest.TestCase):
    def test_child(self):
        self.assertEqual(categorize_by_age(5), "Child")

    def test_adolescent(self):
        self.assertEqual(categorize_by_age(15), "Adolescent")

    def test_adult(self):
        self.assertEqual(categorize_by_age(30), "Adult")

    def test_golden_age(self):
        self.assertEqual(categorize_by_age(70), "Golden age")

    def test_negative_age(self):
        self.assertEqual(categorize_by_age(-1), "Invalid age: -1")

    def test_too_old(self):
        self.assertEqual(categorize_by_age(151), "Invalid age: 151")

在此示例中,您使用描述性名称 TestCategorizeByAge 创建 TestCase 的子类。请注意,类名以 Test 开头,这是一种广泛使用的约定,可以使任何阅读代码的人立即清楚该类的用途。

另请注意,包含文件名为 test_age.py。默认情况下,unittest 支持基于测试模块名称的测试发现。默认命名模式是 test*.py。这里,星号 (*) 代表任意字符序列,因此,如果您想利用默认的测试发现配置,建议使用 test 启动模块。

然后,您定义六个方法。每种方法都会测试输入值和预期结果。这些方法使用父类中的 .assertEqual() 方法来检查函数的输出是否等于预期值。

请注意,上面的测试检查了 categorize_by_age() 函数中的每个可能的分支。但是,它们不涵盖输入年龄为区间下限或上限的边界情况。为了确保函数在这些情况下按预期响应,您可以添加以下测试:

# ...

class TestCategorizeByAge(unittest.TestCase):
    # ...

    def test_boundary_child_adolescent(self):
        self.assertEqual(categorize_by_age(9), "Child")
        self.assertEqual(categorize_by_age(10), "Adolescent")

    def test_boundary_adolescent_adult(self):
        self.assertEqual(categorize_by_age(18), "Adolescent")
        self.assertEqual(categorize_by_age(19), "Adult")

    def test_boundary_adult_golden_age(self):
        self.assertEqual(categorize_by_age(65), "Adult")
        self.assertEqual(categorize_by_age(66), "Golden age")

这些测试方法各自有两个断言。第一个断言检查年龄间隔的上限,第二个断言检查下一个年龄间隔的下限。

在测试方法中使用多个断言可以帮助您减少样板代码。例如,如果您使用单个断言来编写这些测试,那么您将必须编写六个测试方法,而不是仅仅三个。每种方法都需要一个唯一的名称,这可能是一个挑战。

一般来说,在测试方法中使用多个断言具有以下优点:

  • 效率:一次测试中的多个断言可以减少重复代码。在每个测试都有设置和拆卸要求的情况下,它还可以使测试运行得更快。
  • 上下文测试:可能需要多个断言来检查函数在特定上下文中的行为是否正确。
  • 方便:与编写多个单断言测试相比,测试中的多个断言编写起来更加简单且不那么繁琐。

该方法也有其缺点:

  • 清晰度和隔离:当包含多个断言的测试失败时,很难立即识别哪个断言导致了失败。这可能会违背您的调试过程。
  • 破坏风险:当测试中的早期断言失败时,后续断言将不会执行。这可能会隐藏其他问题。
  • 测试目的模糊:当测试有多个断言时,它可能会变得不那么集中。这可能会使测试更难理解。

测试用例就位后,您就可以运行它们并查看您的 categorize_by_age() 函数是否按预期工作。

运行 unittest 测试

编写测试后,您需要一种运行它们的方法。您将至少有两种标准方法来使用 unittest 运行测试:

  1. 使测试模块可执行
  2. 使用unittest的命令行界面

要使测试模块在 unittest 中可执行,您可以将以下代码添加到模块末尾:

# ...

if __name__ == "__main__":
    unittest.main()

unittest 中的 main() 函数允许您加载并运行一组测试。您还可以使用此函数使测试模块可执行。添加这些代码行后,您可以将模块作为常规 Python 脚本运行:

$ python test_age.py
.........
----------------------------------------------------------------------
Ran 9 tests in 0.000s

OK

此命令运行 test_age.py 中的测试。在输出中,每个点都代表通过测试。然后,您可以快速总结运行测试的数量和执行时间。所有测试均已通过,因此您在输出末尾会得到一个 OK 消息。

在其他参数中,main() 函数采用 verbosity 参数。使用此参数,您可以调整输出的详细程度,它具有三个可能的值:

  • 0 表示安静
  • 1 为正常
  • 2 详细信息

继续更新对 main() 的调用,如以下代码片段所示:

# ...

if __name__ == "__main__":
    unittest.main(verbosity=2)

在突出显示的行中,将详细级别设置为 2。此更新使 unittest 在运行测试模块时生成更详细的输出:

$ python test_age.py
test_adolescent (__main__.TestCategorizeByAge.test_adolescent) ... ok
test_adult (__main__.TestCategorizeByAge.test_adult) ... ok
test_boundary_adolescent_adult (__main__.TestCategorizeByAge.test_boundary_adolescent_adult) ... ok
test_boundary_adult_golden_age (__main__.TestCategorizeByAge.test_boundary_adult_golden_age) ... ok
test_boundary_child_adolescent (__main__.TestCategorizeByAge.test_boundary_child_adolescent) ... ok
test_child (__main__.TestCategorizeByAge.test_child) ... ok
test_golden_age (__main__.TestCategorizeByAge.test_golden_age) ... ok
test_negative_age (__main__.TestCategorizeByAge.test_negative_age) ... ok
test_too_old (__main__.TestCategorizeByAge.test_too_old) ... ok

----------------------------------------------------------------------
Ran 9 tests in 0.000s

OK

这个输出更加详细。它显示了测试及其结果。最后照例对试运行进行了总结。

如果您想让详细输出更具描述性,那么您可以将文档字符串添加到测试中,如以下代码片段所示:

# ...

class TestCategorizeByAge(unittest.TestCase):
    def test_child(self):
        """Test for 'Child'"""
        self.assertEqual(categorize_by_age(5), "Child")

    def test_adolescent(self):
        """Test for 'Adolescent'"""
        self.assertEqual(categorize_by_age(15), "Adolescent")

    def test_adult(self):
        """Test for 'Adult'"""
        self.assertEqual(categorize_by_age(30), "Adult")

    def test_golden_age(self):
        """Test for 'Golden age'"""
        self.assertEqual(categorize_by_age(70), "Golden age")

    def test_negative_age(self):
        """Test for negative age"""
        self.assertEqual(categorize_by_age(-1), "Invalid age: -1")

    def test_too_old(self):
        """Test for too old"""
        self.assertEqual(categorize_by_age(151), "Invalid age: 151")

    def test_boundary_child_adolescent(self):
        """Test for boundary between 'Child' and 'Adolescent'"""
        self.assertEqual(categorize_by_age(9), "Child")
        self.assertEqual(categorize_by_age(10), "Adolescent")

    def test_boundary_adolescent_adult(self):
        """Test for boundary between 'Adolescent' and 'Adult'"""
        self.assertEqual(categorize_by_age(18), "Adolescent")
        self.assertEqual(categorize_by_age(19), "Adult")

    def test_boundary_adult_golden_age(self):
        """Test for boundary between 'Adult' and 'Golden age'"""
        self.assertEqual(categorize_by_age(65), "Adult")
        self.assertEqual(categorize_by_age(66), "Golden age")

# ...

在此测试方法的更新中,您添加了人类可读的文档字符串。现在,当您以 2 的详细程度运行测试时,您将获得以下输出:

$ python test_age.py
test_adolescent (__main__.TestCategorizeByAge.test_adolescent)
Test for 'Adolescent' ... ok
test_adult (__main__.TestCategorizeByAge.test_adult)
Test for 'Adult' ... ok
test_boundary_adolescent_adult (__main__.TestCategorizeByAge.test_boundary_adolescent_adult)
Test for boundary between 'Adolescent' and 'Adult' ... ok
test_boundary_adult_golden_age (__main__.TestCategorizeByAge.test_boundary_adult_golden_age)
Test for boundary between 'Adult' and 'Golden age' ... ok
test_boundary_child_adolescent (__main__.TestCategorizeByAge.test_boundary_child_adolescent)
Test for boundary between 'Child' and 'Adolescent' ... ok
test_child (__main__.TestCategorizeByAge.test_child)
Test for 'Child' ... ok
test_golden_age (__main__.TestCategorizeByAge.test_golden_age)
Test for 'Golden age' ... ok
test_negative_age (__main__.TestCategorizeByAge.test_negative_age)
Test for negative age ... ok
test_too_old (__main__.TestCategorizeByAge.test_too_old)
Test for too old ... ok

----------------------------------------------------------------------
Ran 9 tests in 0.000s

OK

现在,除了奇怪的函数名称之外,unittest 使用文档字符串来提高输出的可读性,这非常棒。

跳过测试

unittest 框架还支持跳过单个测试方法甚至整个测试用例类。跳过测试允许您暂时绕过测试用例,而无需将其从测试套件中永久删除。

以下是一些您可能需要跳过测试的常见情况:

  • 不完整的特征:当您有不完整的特征时,您需要跳过相关测试以避免漏报。
  • 外部服务:当您的某些测试依赖于不可用的外部服务或资源时,您可能需要跳过它们,直到服务恢复。
  • 条件执行:当您的测试依赖于特定条件(例如平台或 Python 版本)时,您可以根据动态条件有条件地跳过测试。
  • 已知失败:当您的代码存在已知错误时,您可以参考错误报告来跳过失败的测试。
  • 性能注意事项:当您的测试非常耗时或占用资源时,您可能希望在常规开发周期中跳过它们以加快测试过程。
  • 已弃用的功能:当您有尚未删除的已弃用功能时,您可以跳过其相关测试,直到该功能被完全删除。

以下装饰器将帮助您实现在测试运行过程中跳过测试的目标:

@unittest.skip(reason)

跳过装饰测试

@unittest.skipIf(condition, reason)

如果 condition 为 true,则跳过修饰测试

@unittest.skipUnless(condition, reason)

除非 condition 为 true,否则跳过修饰测试

在这些装饰器中,reason 参数应该描述为什么测试将被跳过。考虑以下玩具示例,该示例显示了装饰器的工作原理:

import sys
import unittest

class SkipTestExample(unittest.TestCase):
    @unittest.skip("Unconditionally skipped test")
    def test_unimportant(self):
        self.fail("The test should be skipped")

    @unittest.skipIf(sys.version_info < (3, 12), "Requires Python >= 3.12")
    def test_using_calendar_constants(self):
        import calendar

        self.assertEqual(calendar.Month(10), calendar.OCTOBER)

    @unittest.skipUnless(sys.platform.startswith("win"), "Requires Windows")
    def test_windows_support(self):
        from ctypes import WinDLL, windll

        self.assertIsInstance(windll.kernel32, WinDLL)

if __name__ == "__main__":
    unittest.main(verbosity=2)

在此示例测试模块中,您具有三种测试方法。第一个永远不会运行,因为您在其上使用了 @skip 装饰器。第二个测试方法仅在您使用的 Python 版本等于或大于 3.12 时运行。最后,如果您使用的是 Windows 机器,则运行最后一个测试方法。

第一个代码片段应在 Windows 上使用,第二个代码片段适用于 Linux + macOS:

以下是使用 Python 3.11 的 Windows 上的输出结果:

PS> python skip_tests.py
test_unimportant (__main__.SkipTestExample.test_unimportant) ... skipped 'Unconditionally skipped test'
test_using_calendar_constants (__main__.SkipTestExample.test_using_calendar_constants) ... skipped 'Requires Python >= 3.12'
test_windows_support (__main__.SkipTestExample.test_windows_support) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.156s

OK (skipped=2)

在这种情况下,唯一运行的测试是最后一个测试,因为您使用的是 Windows。第一个测试因 @skip 装饰器而未运行,第二个测试因 Python 版本低于 3.12 而未运行。

以下是使用 Python 3.12 在 macOS 或 Linux 中进行的测试的输出:

$ python skip_tests.py
test_unimportant (__main__.SkipTestExample.test_unimportant) ... skipped 'Unconditionally skipped test'
test_using_calendar_constants (__main__.SkipTestExample.test_using_calendar_constants) ... ok
test_windows_support (__main__.SkipTestExample.test_windows_support) ... skipped 'Requires Windows'

----------------------------------------------------------------------
Ran 3 tests in 0.002s

OK (skipped=2)

在此测试运行中,第一个测试未运行。第二个测试运行是因为 Python 版本是预期的。最终测试未运行,因为当前平台不是 Windows。