Python 的单元测试:为您的代码编写单元测试
Python 标准库附带了一个名为 unittest
的测试框架,您可以使用该框架为代码编写自动化测试。 unittest
包采用面向对象的方法,其中测试用例派生自基类,该基类具有多种有用的方法。
该框架支持许多功能,可帮助您为代码编写一致的单元测试。这些功能包括测试用例、固定装置、测试套件和测试发现功能。
在本教程中,您将学习如何:
- 使用
TestCase
类编写unittest
测试 - 探索
TestCase
提供的 assert 方法 - 从命令行使用
unittest
- 使用
TestSuite
类对测试用例进行分组 - 创建装置来处理设置和拆卸逻辑
为了充分利用本教程,您应该熟悉一些重要的 Python 概念,例如面向对象编程、继承和断言。对代码测试有很好的理解是一个优势。
测试你的Python代码
代码测试或软件测试是现代软件开发周期的基本组成部分。通过代码测试,您可以验证给定的软件项目是否按预期工作并满足其要求。测试增强了代码质量和稳健性。
您将在应用程序或项目的开发阶段进行代码测试。您将编写测试来隔离代码部分并验证其正确性。编写良好的电池或测试套件也可以作为手头项目的文档。
您会发现有关测试的几种不同的概念和技术。其中大多数超出了本教程的范围。然而,单元测试是一个重要且相关的概念。单元测试是对单个软件单元进行的测试。单元测试旨在验证测试单元是否按设计工作。
单元通常是程序的一小部分,它接受一些输入并产生输出。函数、方法和其他可调用对象是您需要测试的单元的良好示例。
在 Python 中,有多种工具可以帮助您编写、组织、运行和自动化单元测试。在 Python 标准库中,您将找到其中两个工具:
doctest
unittest
Python 的 doctest 模块是一个轻量级测试框架,可提供快速、直接的测试自动化。它可以从项目的文档和代码的文档字符串中读取测试用例。该框架随 Python 解释器一起提供,作为电池包含理念的一部分。
注意:要深入了解 doctest
,请查看 Python 的 doctest:立即记录和测试您的代码教程。
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
编写测试之前,您需要一些代码来测试。假设您需要获取一个人的年龄,处理该信息并显示他们当前的生命阶段。例如,如果此人的年龄是:
- 在
0
和9
之间(两者都包含),该函数应返回"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()
方法来检查函数的输出是否等于预期值。
注意:TestCase
的 .assert*()
方法是您在测试代码时通常执行的常见断言的便捷快捷方式。您将在探索可用的断言方法部分了解有关这些方法的更多信息。
请注意,上面的测试检查了 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
运行测试:
- 使测试模块可执行
- 使用
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
消息。
注意:您将在从命令行使用 unittest
部分了解 unittest
命令行界面。
在其他参数中,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。