Python 入门指南(七)(2)https://developer.aliyun.com/article/1507459
命令模式
命令模式在必须执行的操作和调用这些操作的对象之间增加了一个抽象级别,通常是在以后的某个时间。在命令模式中,客户端代码创建一个可以在以后执行的Command
对象。这个对象知道一个接收者对象,在命令在其上执行时管理自己的内部状态。Command
对象实现了一个特定的接口(通常有一个execute
或do_action
方法,并且还跟踪执行操作所需的任何参数。最后,一个或多个Invoker
对象在正确的时间执行命令。
这是 UML 图:
命令模式的一个常见示例是对图形窗口的操作。通常,操作可以通过菜单栏上的菜单项、键盘快捷键、工具栏图标或上下文菜单来调用。这些都是Invoker
对象的示例。实际发生的操作,如Exit
、Save
或Copy
,是CommandInterface
的实现。接收退出的 GUI 窗口,接收保存的文档,接收复制命令的ClipboardManager
,都是可能的Receivers
的示例。
让我们实现一个简单的命令模式,为Save
和Exit
操作提供命令。我们将从一些适度的接收者类开始,它们本身具有以下代码:
import sys class Window: def exit(self): sys.exit(0) class Document: def __init__(self, filename): self.filename = filename self.contents = "This file cannot be modified" def save(self): with open(self.filename, 'w') as file: file.write(self.contents)
这些模拟类模拟了在工作环境中可能会做更多工作的对象。窗口需要处理鼠标移动和键盘事件,文档需要处理字符插入、删除和选择。但是对于我们的示例,这两个类将做我们需要的事情。
现在让我们定义一些调用者类。这些将模拟可能发生的工具栏、菜单和键盘事件;同样,它们实际上并没有连接到任何东西,但我们可以看到它们如何与命令、接收者和客户端代码解耦在以下代码片段中:
class ToolbarButton: def __init__(self, name, iconname): self.name = name self.iconname = iconname def click(self): self.command.execute() class MenuItem: def __init__(self, menu_name, menuitem_name): self.menu = menu_name self.item = menuitem_name def click(self): self.command.execute() class KeyboardShortcut: def __init__(self, key, modifier): self.key = key self.modifier = modifier def keypress(self): self.command.execute()
注意各种操作方法如何调用其各自命令的execute
方法?这段代码没有显示command
属性被设置在每个对象上。它们可以传递到__init__
函数中,但因为它们可能会被更改(例如,使用可自定义的键绑定编辑器),所以更合理的是在对象之后设置属性。
现在,让我们使用以下代码连接命令本身:
class SaveCommand: def __init__(self, document): self.document = document def execute(self): self.document.save() class ExitCommand: def __init__(self, window): self.window = window def execute(self): self.window.exit()
这些命令很简单;它们演示了基本模式,但重要的是要注意,如果必要,我们可以存储状态和其他信息。例如,如果我们有一个插入字符的命令,我们可以维护当前正在插入的字符的状态。
现在我们所要做的就是连接一些客户端和测试代码,使命令生效。对于基本测试,我们只需在脚本的末尾包含以下代码:
window = Window() document = Document("a_document.txt") save = SaveCommand(document) exit = ExitCommand(window) save_button = ToolbarButton('save', 'save.png') save_button.command = save save_keystroke = KeyboardShortcut("s", "ctrl") save_keystroke.command = save exit_menu = MenuItem("File", "Exit") exit_menu.command = exit
首先,我们创建两个接收者和两个命令。然后,我们创建几个可用的调用者,并在每个调用者上设置正确的命令。为了测试,我们可以使用python3 -i filename.py
并运行诸如exit_menu.click()
的代码,这将结束程序,或者save_keystroke.keystroke()
,这将保存虚拟文件。
不幸的是,前面的例子并不像 Python。它们有很多“样板代码”(不完成任何任务,只提供模式结构),而且Command
类彼此之间都非常相似。也许我们可以创建一个通用的命令对象,以函数作为回调?
事实上,为什么要麻烦呢?我们可以为每个命令使用函数或方法对象吗?我们可以编写一个函数,直接将其用作命令,而不是具有execute()
方法的对象。以下是 Python 中命令模式的常见范例:
import sys class Window: def exit(self): sys.exit(0) class MenuItem: def click(self): self.command() window = Window() menu_item = MenuItem() menu_item.command = window.exit
现在看起来更像 Python 了。乍一看,它看起来好像我们完全删除了命令模式,并且紧密连接了menu_item
和Window
类。但是如果我们仔细观察,我们会发现根本没有紧密耦合。任何可调用对象都可以设置为MenuItem
上的命令,就像以前一样。而Window.exit
方法可以附加到任何调用者上。命令模式的大部分灵活性都得到了保留。我们为可读性牺牲了完全解耦,但在我看来,以及许多 Python 程序员看来,这段代码比完全抽象的版本更易维护。
当然,由于我们可以向任何对象添加__call__
方法,我们不限于函数。当被调用的方法不必维护状态时,前面的例子是一个有用的快捷方式,但在更高级的用法中,我们也可以使用以下代码:
class Document: def __init__(self, filename): self.filename = filename self.contents = "This file cannot be modified" def save(self): with open(self.filename, "w") as file: file.write(self.contents) class KeyboardShortcut: def keypress(self): self.command() class SaveCommand: def __init__(self, document): self.document = document def __call__(self): self.document.save() document = Document("a_file.txt") shortcut = KeyboardShortcut() save_command = SaveCommand(document) shortcut.command = save_command
在这里,我们有一个看起来像第一个命令模式的东西,但更符合习惯。正如你所看到的,让调用者调用一个可调用对象而不是具有执行方法的command
对象并没有限制我们的任何方式。事实上,这给了我们更多的灵活性。当适用时,我们可以直接链接到函数,但是当情况需要时,我们可以构建一个完整的可调用command
对象。
命令模式通常扩展为支持可撤销的命令。例如,文本程序可能将每个插入操作包装在一个单独的命令中,不仅有一个execute
方法,还有一个undo
方法,用于删除该插入操作。图形程序可能将每个绘图操作(矩形、线条、自由像素等)包装在一个命令中,该命令具有一个undo
方法,用于将像素重置为其原始状态。在这种情况下,命令模式的解耦显然更有用,因为每个操作都必须保持足够的状态以便在以后的某个日期撤消该操作。
抽象工厂模式
抽象工厂模式通常在我们有多种可能的系统实现取决于某些配置或平台问题时使用。调用代码从抽象工厂请求对象,不知道将返回什么类的对象。返回的底层实现可能取决于各种因素,如当前区域设置、操作系统或本地配置。
抽象工厂模式的常见示例包括操作系统无关的工具包、数据库后端和特定国家的格式化程序或计算器的代码。操作系统无关的 GUI 工具包可能使用抽象工厂模式,在 Windows 下返回一组 WinForm 小部件,在 Mac 下返回一组 Cocoa 小部件,在 Gnome 下返回一组 GTK 小部件,在 KDE 下返回一组 QT 小部件。Django 提供了一个抽象工厂,根据当前站点的配置设置,返回一组用于与特定数据库后端(MySQL、PostgreSQL、SQLite 等)交互的对象关系类。如果应用程序需要部署到多个地方,每个地方可以通过仅更改一个配置变量来使用不同的数据库后端。不同的国家有不同的零售商品税、小计和总计计算系统;抽象工厂可以返回特定的税收计算对象。
抽象工厂模式的 UML 类图很难理解,没有具体的示例,因此让我们改变一下,首先创建一个具体的示例。在我们的示例中,我们将创建一组取决于特定区域设置的格式化程序,帮助我们格式化日期和货币。将有一个选择特定工厂的抽象工厂类,以及一些示例具体工厂,一个用于法国,一个用于美国。这些工厂将为日期和时间创建格式化程序对象,可以查询以格式化特定值。如下图所示:
将这个图像与之前更简单的文本进行比较,可以看出图片并不总是价值千言万语,尤其是考虑到我们甚至没有在这里允许工厂选择代码。
当然,在 Python 中,我们不必实现任何接口类,因此我们可以丢弃DateFormatter
、CurrencyFormatter
和FormatterFactory
。这些格式化类本身非常简单,但冗长,如下所示:
class FranceDateFormatter: def format_date(self, y, m, d): y, m, d = (str(x) for x in (y, m, d)) y = "20" + y if len(y) == 2 else y m = "0" + m if len(m) == 1 else m d = "0" + d if len(d) == 1 else d return "{0}/{1}/{2}".format(d, m, y) class USADateFormatter: def format_date(self, y, m, d): y, m, d = (str(x) for x in (y, m, d)) y = "20" + y if len(y) == 2 else y m = "0" + m if len(m) == 1 else m d = "0" + d if len(d) == 1 else d return "{0}-{1}-{2}".format(m, d, y) class FranceCurrencyFormatter: def format_currency(self, base, cents): base, cents = (str(x) for x in (base, cents)) if len(cents) == 0: cents = "00" elif len(cents) == 1: cents = "0" + cents digits = [] for i, c in enumerate(reversed(base)): if i and not i % 3: digits.append(" ") digits.append(c) base = "".join(reversed(digits)) return "{0}€{1}".format(base, cents) class USACurrencyFormatter: def format_currency(self, base, cents): base, cents = (str(x) for x in (base, cents)) if len(cents) == 0: cents = "00" elif len(cents) == 1: cents = "0" + cents digits = [] for i, c in enumerate(reversed(base)): if i and not i % 3: digits.append(",") digits.append(c) base = "".join(reversed(digits)) return "${0}.{1}".format(base, cents)
这些类使用一些基本的字符串操作来尝试将各种可能的输入(整数、不同长度的字符串等)转换为以下格式:
美国 | 法国 | |
日期 | mm-dd-yyyy | dd/mm/yyyy |
货币 | $14,500.50 | 14 500€50 |
在这段代码中,输入显然可以进行更多的验证,但是为了这个例子,让我们保持简单。
现在我们已经设置好了格式化程序,我们只需要创建格式化程序工厂,如下所示:
class USAFormatterFactory: def create_date_formatter(self): return USADateFormatter() def create_currency_formatter(self): return USACurrencyFormatter() class FranceFormatterFactory: def create_date_formatter(self): return FranceDateFormatter() def create_currency_formatter(self): return FranceCurrencyFormatter()
现在我们设置选择适当格式化程序的代码。由于这种事情只需要设置一次,我们可以将其设置为单例模式——但是单例模式在 Python 中并不是非常有用。让我们将当前格式化程序作为模块级变量:
country_code = "US" factory_map = {"US": USAFormatterFactory, "FR": FranceFormatterFactory} formatter_factory = factory_map.get(country_code)()
在这个例子中,我们硬编码了当前的国家代码;在实践中,它可能会内省区域设置、操作系统或配置文件来选择代码。这个例子使用字典将国家代码与工厂类关联起来。然后,我们从字典中获取正确的类并实例化它。
当我们想要为更多的国家添加支持时,很容易看出需要做什么:创建新的格式化类和抽象工厂本身。请记住,Formatter
类可能会被重用;例如,加拿大的货币格式与美国相同,但其日期格式比其南部邻居更合理。
抽象工厂通常返回一个单例对象,但这并非必需。在我们的代码中,每次调用时都返回每个格式化程序的新实例。没有理由不能将格式化程序存储为实例变量,并为每个工厂返回相同的实例。
回顾这些例子,我们再次看到,对于工厂来说,似乎有很多样板代码在 Python 中并不感到必要。通常,可能需要抽象工厂的要求可以通过为每种工厂类型(例如:美国和法国)使用单独的模块,并确保在工厂模块中访问正确的模块来更轻松地实现。这些模块的包结构可能如下所示:
localize/ __init__.py backends/ __init__.py USA.py France.py ...
技巧在于localize
包中的__init__.py
可以包含将所有请求重定向到正确后端的逻辑。有多种方法可以实现这一点。
如果我们知道后端永远不会动态更改(即在没有程序重新启动的情况下),我们可以在__init__.py
中放一些if
语句来检查当前的国家代码,并使用(通常不可接受的)from``.backends.USA``import``*
语法从适当的后端导入所有变量。或者,我们可以导入每个后端并设置一个current_backend
变量指向特定的模块,如下所示:
from .backends import USA, France if country_code == "US": current_backend = USA
根据我们选择的解决方案,我们的客户端代码将不得不调用localize.format_date
或localize.current_backend.format_date
来获取以当前国家区域设置格式化的日期。最终结果比原始的抽象工厂模式更符合 Python 的风格,并且在典型的使用情况下同样灵活。
组合模式
组合模式允许从简单组件构建复杂的树状结构。这些组件,称为复合对象,能够表现得像容器,也能像变量一样,具体取决于它们是否有子组件。复合对象是容器对象,其中的内容实际上可能是另一个复合对象。
传统上,复合对象中的每个组件必须是叶节点(不能包含其他对象)或复合节点。关键在于复合和叶节点都可以具有相同的接口。以下的 UML 图表非常简单:
然而,这种简单的模式使我们能够创建复杂的元素排列,所有这些元素都满足组件对象的接口。以下图表描述了这样一个复杂排列的具体实例:
组合模式通常在文件/文件夹样式的树中非常有用。无论树中的节点是普通文件还是文件夹,它仍然受到移动、复制或删除节点等操作的影响。我们可以创建一个支持这些操作的组件接口,然后使用复合对象来表示文件夹,使用叶节点来表示普通文件。
当然,在 Python 中,我们可以再次利用鸭子类型来隐式提供接口,因此我们只需要编写两个类。让我们首先在以下代码中定义这些接口:
class Folder: def __init__(self, name): self.name = name self.children = {} def add_child(self, child): pass def move(self, new_path): pass def copy(self, new_path): pass def delete(self): pass class File: def __init__(self, name, contents): self.name = name self.contents = contents def move(self, new_path): pass def copy(self, new_path): pass def delete(self): pass
对于每个文件夹(复合)对象,我们维护一个子对象的字典。对于许多复合实现来说,列表就足够了,但在这种情况下,使用字典来按名称查找子对象会很有用。我们的路径将被指定为由/
字符分隔的节点名称,类似于 Unix shell 中的路径。
考虑涉及的方法,我们可以看到移动或删除节点的行为方式相似,无论它是文件节点还是文件夹节点。然而,复制对于文件夹节点来说必须进行递归复制,而对于文件节点来说,复制是一个微不足道的操作。
为了利用相似的操作,我们可以将一些常见的方法提取到一个父类中。让我们将被丢弃的Component
接口改为一个基类,使用以下代码:
class Component: def __init__(self, name): self.name = name def move(self, new_path): new_folder = get_path(new_path) del self.parent.children[self.name] new_folder.children[self.name] = self self.parent = new_folder def delete(self): del self.parent.children[self.name] class Folder(Component): def __init__(self, name): super().__init__(name) self.children = {} def add_child(self, child): pass def copy(self, new_path): pass class File(Component): def __init__(self, name, contents): super().__init__(name) self.contents = contents def copy(self, new_path): pass root = Folder("") def get_path(path): names = path.split("/")[1:] node = root for name in names: node = node.children[name] return node
我们在Component
类上创建了move
和delete
方法。它们都访问一个我们尚未设置的神秘的parent
变量。move
方法使用一个模块级别的get_path
函数,该函数根据给定的路径从预定义的根节点中找到一个节点。所有文件都将被添加到此根节点或该节点的子节点。对于move
方法,目标应该是一个现有的文件夹,否则我们将会得到一个错误。就像技术书籍中的许多示例一样,错误处理是非常缺乏的,以帮助专注于正在考虑的原则。
让我们在文件夹的add_child
方法中设置那个神秘的parent
变量,如下所示:
def add_child(self, child): child.parent = self self.children[child.name] = child
好吧,这足够简单了。让我们看看我们的复合文件层次结构是否能够正常工作,使用以下代码片段:
$ python3 -i 1261_09_18_add_child.py >>> folder1 = Folder('folder1') >>> folder2 = Folder('folder2') >>> root.add_child(folder1) >>> root.add_child(folder2) >>> folder11 = Folder('folder11') >>> folder1.add_child(folder11) >>> file111 = File('file111', 'contents') >>> folder11.add_child(file111) >>> file21 = File('file21', 'other contents') >>> folder2.add_child(file21) >>> folder2.children {'file21': <__main__.File object at 0xb7220a4c>} >>> folder2.move('/folder1/folder11') >>> folder11.children {'folder2': <__main__.Folder object at 0xb722080c>, 'file111': <__main__.File object at 0xb72209ec>} >>> file21.move('/folder1') >>> folder1.children {'file21': <__main__.File object at 0xb7220a4c>, 'folder11': <__main__.Folder object at 0xb722084c>}
是的,我们可以创建文件夹,将文件夹添加到其他文件夹中,将文件添加到文件夹中,并在它们之间移动!在文件层次结构中,我们还能要求什么呢?
好吧,我们可以要求实现复制,但为了节约树木,让我们把它作为一个练习留下来。
复合模式对于各种类似树结构的结构非常有用,包括 GUI 小部件层次结构,文件层次结构,树集,图形和 HTML DOM。当按照传统实现方式在 Python 中实现时,它可以成为 Python 中的一个有用模式,就像之前演示的例子一样。有时,如果只创建了一个浅树,我们可以使用列表的列表或字典的字典,并且不需要实现自定义组件、叶子和复合类。其他时候,我们可以只实现一个复合类,并将叶子和复合对象视为一个类。另外,Python 的鸭子类型可以很容易地将其他对象添加到复合层次结构中,只要它们具有正确的接口。
练习
在深入研究每个设计模式的练习之前,花点时间为上一节中的File
和Folder
对象实现copy
方法。File
方法应该非常简单;只需创建一个具有相同名称和内容的新节点,并将其添加到新的父文件夹中。Folder
上的copy
方法要复杂得多,因为您首先必须复制文件夹,然后递归地将其每个子对象复制到新位置。您可以不加区分地在子对象上调用copy()
方法,无论每个子对象是文件还是文件夹。这将彰显出复合模式有多么强大。
现在,就像在上一章中一样,看看我们讨论过的模式,并考虑您可能实现它们的理想位置。您可能希望将适配器模式应用于现有代码,因为它通常适用于与现有库进行接口,而不是新代码。您如何使用适配器来强制两个接口正确地相互交互?
你能想到一个足够复杂的系统,可以证明使用外观模式是合理的吗?考虑一下外观在现实生活中的使用情况,比如汽车的驾驶员界面,或者工厂的控制面板。在软件中也是类似的,只不过外观接口的用户是其他程序员,而不是受过培训的人。在你最新的项目中,是否有复杂的系统可以从外观模式中受益?
可能你没有任何巨大的、占用内存的代码会从享元模式中受益,但你能想到哪些情况下它可能会有用吗?任何需要处理大量重叠数据的地方,都可以使用享元模式。在银行业会有用吗?在 Web 应用程序中呢?采用享元模式何时是明智的?什么时候又是画蛇添足呢?
命令模式呢?你能想到任何常见(或更好的是,不常见)的例子,其中将动作与调用解耦会有用吗?看看你每天使用的程序,想象它们内部是如何实现的。很可能其中许多都会以某种方式使用命令模式。
抽象工厂模式,或者我们讨论过的更加 Pythonic 的衍生模式,对于创建一键配置系统非常有用。你能想到这样的系统有用的地方吗?
最后,考虑一下组合模式。在编程中,我们周围都有类似树状结构的东西;其中一些,比如我们的文件层次结构示例,是明显的;其他一些则相当微妙。可能会出现哪些情况,组合模式会有用呢?你能想到在自己的代码中可以使用它的地方吗?如果你稍微调整一下模式;例如,包含不同类型的叶子或组合节点,用于不同类型的对象,会怎样?
总结
在本章中,我们详细介绍了几种设计模式,包括它们的经典描述以及在 Python 中实现它们的替代方法,Python 通常比传统的面向对象语言更灵活、多才多艺。适配器模式用于匹配接口,而外观模式适用于简化接口。享元模式是一种复杂的模式,只有在需要内存优化时才有用。在 Python 中,命令模式通常更适合使用一等函数作为回调来实现。抽象工厂允许根据配置或系统信息在运行时分离实现。组合模式通常用于类似树状结构的情况。
在下一章中,我们将讨论测试 Python 程序的重要性,以及如何进行测试,重点放在面向对象的原则上。
第二十四章:测试面向对象的程序
技术娴熟的 Python 程序员一致认为测试是软件开发中最重要的方面之一。即使这一章放在书的最后,它也不是一个事后补充;到目前为止我们学习的一切都将帮助我们在编写测试时。在本章中,我们将讨论以下主题:
- 单元测试和测试驱动开发的重要性
- 标准的
unittest
模块 pytest
自动化测试套件mock
模块- 代码覆盖率
- 使用
tox
进行跨平台测试
为什么要测试?
许多程序员已经知道测试他们的代码有多重要。如果你是其中之一,请随意略过本节。你会发现下一节——我们实际上如何在 Python 中创建测试——更加有趣。如果你还不相信测试的重要性,我保证你的代码是有问题的,只是你不知道而已。继续阅读!
有人认为在 Python 代码中测试更重要,因为它的动态特性;而像 Java 和 C++这样的编译语言偶尔被认为在编译时强制执行类型检查,所以在某种程度上更“安全”。然而,Python 测试很少检查类型。它们检查值。它们确保正确的属性在正确的时间设置,或者序列具有正确的长度、顺序和值。这些更高级的概念需要在任何语言中进行测试。
Python 程序员测试比其他语言的程序员更多的真正原因是在 Python 中测试是如此容易!
但是为什么要测试?我们真的需要测试吗?如果我们不测试会怎样?要回答这些问题,从头开始编写一个没有任何测试的井字棋游戏。在完全编写完成之前不要运行它,从头到尾。如果让两个玩家都是人类玩家(没有人工智能),井字棋实现起来相当简单。你甚至不必尝试计算谁是赢家。现在运行你的程序。然后修复所有的错误。有多少错误?我在我的井字棋实现中记录了八个,我不确定是否都捕捉到了。你呢?
我们需要测试我们的代码以确保它正常工作。像我们刚才做的那样运行程序并修复错误是一种粗糙的测试形式。Python 的交互式解释器和几乎零编译时间使得编写几行代码并运行程序以确保这些行正在按预期工作变得容易。但是改变几行代码可能会影响我们没有意识到会受到更改影响的程序的部分,因此忽略测试这些部分。此外,随着程序的增长,解释器可以通过代码的路径数量也在增加,手动测试所有这些路径很快就变得不可能。
为了解决这个问题,我们编写自动化测试。这些是自动运行某些输入通过其他程序或程序部分的程序。我们可以在几秒钟内运行这些测试程序,并覆盖比一个程序员每次更改某些东西时想到的潜在输入情况要多得多。
有四个主要原因要编写测试:
- 确保代码按照开发人员的预期工作
- 确保在进行更改时代码仍然正常工作
- 确保开发人员理解了需求
- 确保我们正在编写的代码具有可维护的接口
第一点真的不能证明写测试所花费的时间;我们可以在交互式解释器中直接测试代码,用同样或更少的时间。但是当我们必须多次执行相同的测试操作序列时,自动化这些步骤一次,然后在需要时运行它们需要的时间更少。每次更改代码时运行测试是个好主意,无论是在初始开发阶段还是在维护版本发布时。当我们有一套全面的自动化测试时,我们可以在代码更改后运行它们,并知道我们没有无意中破坏任何被测试的东西。
前面两点更有趣。当我们为代码编写测试时,它有助于设计代码所采用的 API、接口或模式。因此,如果我们误解了需求,编写测试可以帮助突出这种误解。另一方面,如果我们不确定如何设计一个类,我们可以编写一个与该类交互的测试,这样我们就可以知道与之交互的最自然方式。事实上,通常在编写我们要测试的代码之前编写测试是有益的。
测试驱动开发
先写测试是测试驱动开发的口头禅。测试驱动开发将未经测试的代码是有问题的代码的概念推进了一步,并建议只有未编写的代码才应该未经测试。在我们编写测试之前,我们不会编写任何代码来证明它有效。第一次运行测试时,它应该失败,因为代码还没有被编写。然后,我们编写确保测试通过的代码,然后为下一段代码编写另一个测试。
测试驱动开发很有趣;它允许我们构建小谜题来解决。然后,我们实现解决这些谜题的代码。然后,我们制作一个更复杂的谜题,然后编写解决新谜题的代码,而不会解决以前的谜题。
测试驱动方法有两个目标。第一个是确保测试真的被编写。在我们编写代码之后,很容易说:
嗯,看起来好像可以。我不需要为这个写任何测试。这只是一个小改变;什么都不可能出错。
如果测试在我们编写代码之前已经编写好了,我们将确切地知道它何时有效(因为测试将通过),并且在将来,如果我们或其他人对其进行了更改,我们将知道它是否被破坏。
其次,先编写测试迫使我们考虑代码将如何使用。它告诉我们对象需要具有哪些方法,以及如何访问属性。它帮助我们将初始问题分解为更小的、可测试的问题,然后将经过测试的解决方案重新组合成更大的、也经过测试的解决方案。编写测试因此可以成为设计过程的一部分。通常,当我们为一个新对象编写测试时,我们会发现设计中的异常,这迫使我们考虑软件的新方面。
作为一个具体的例子,想象一下编写使用对象关系映射器将对象属性存储在数据库中的代码。在这种对象中使用自动分配的数据库 ID 是很常见的。我们的代码可能会为各种目的使用这个 ID。如果我们为这样的代码编写测试,在我们编写测试之前,我们可能会意识到我们的设计有缺陷,因为对象在保存到数据库之前不会被分配 ID。如果我们想在测试中操作一个对象而不保存它,那么在我们基于错误的前提编写代码之前,它会突出显示这个问题。
测试使软件更好。在发布软件之前编写测试可以使软件在最终用户看到或购买有错误的版本之前变得更好(我曾为那些以用户可以测试它为理念的公司工作过;这不是一个健康的商业模式)。在编写软件之前编写测试可以使软件第一次编写时变得更好。
单元测试
让我们从 Python 内置的测试库开始探索。这个库为单元测试提供了一个通用的面向对象的接口。单元测试专注于在任何一个测试中测试尽可能少的代码。每个测试都测试可用代码的一个单元。
这个 Python 库的名称是unittest
,毫不奇怪。它提供了几个用于创建和运行单元测试的工具,其中最重要的是TestCase
类。这个类提供了一组方法,允许我们比较值,设置测试,并在测试完成时进行清理。
当我们想要为特定任务编写一组单元测试时,我们创建一个TestCase
的子类,并编写单独的方法来进行实际测试。这些方法都必须以test
开头的名称。遵循这个约定时,测试会自动作为测试过程的一部分运行。通常,测试会在对象上设置一些值,然后运行一个方法,并使用内置的比较方法来确保正确的结果被计算出来。这里有一个非常简单的例子:
import unittest class CheckNumbers(unittest.TestCase): def test_int_float(self): self.assertEqual(1, 1.0)
if __name__ == "__main__": unittest.main()
这段代码简单地继承了TestCase
类,并添加了一个调用TestCase.assertEqual
方法的方法。这个方法将根据两个参数是否相等而成功或引发异常。如果我们运行这段代码,unittest
的main
函数将给出以下输出:
. -------------------------------------------------------------- Ran 1 test in 0.000s OK
你知道浮点数和整数可以被比较为相等吗?让我们添加一个失败的测试,如下:
def test_str_float(self): self.assertEqual(1, "1")
这段代码的输出更加阴险,因为整数和字符串不是
被认为是相等的:
.F ============================================================ FAIL: test_str_float (__main__.CheckNumbers) -------------------------------------------------------------- Traceback (most recent call last): File "first_unittest.py", line 9, in test_str_float self.assertEqual(1, "1") AssertionError: 1 != '1' -------------------------------------------------------------- Ran 2 tests in 0.001s FAILED (failures=1)
第一行的点表示第一个测试(我们之前写的那个)成功通过;其后的字母F
表示第二个测试失败。然后,在最后,它会给出一些信息性的输出,告诉我们测试失败的原因和位置,以及失败的数量总结。
我们可以在一个TestCase
类上有尽可能多的测试方法。只要方法名以test
开头,测试运行器就会将每个方法作为一个单独的、隔离的测试执行。每个测试应该完全独立于其他测试。先前测试的结果或计算不应该对当前测试产生影响。编写良好的单元测试的关键是尽可能保持每个测试方法的长度短小,每个测试用例测试一小部分代码。如果我们的代码似乎无法自然地分解成这样可测试的单元,这可能是代码需要重新设计的迹象。
断言方法
测试用例的一般布局是将某些变量设置为已知的值,运行一个或多个函数、方法或进程,然后使用TestCase
的断言方法证明正确的预期结果是通过的或者被计算出来的。
有几种不同的断言方法可用于确认已经实现了特定的结果。我们刚刚看到了assertEqual
,如果两个参数不能通过相等检查,它将导致测试失败。相反,assertNotEqual
如果两个参数比较为相等,则会失败。assertTrue
和assertFalse
方法分别接受一个表达式,并且如果表达式不能通过if
测试,则会失败。这些测试不检查布尔值True
或False
。相反,它们测试与使用if
语句相同的条件:False
、None
、0
或空列表、字典、字符串、集合或元组会通过调用assertFalse
方法。非零数、包含值的容器,或值True
在调用assertTrue
方法时会成功。
有一个assertRaises
方法,可以用来确保特定的函数调用引发特定的异常,或者可以选择作为上下文管理器来包装内联代码。如果with
语句内的代码引发了正确的异常,则测试通过;否则,测试失败。以下代码片段是两个版本的示例:
import unittest def average(seq): return sum(seq) / len(seq) class TestAverage(unittest.TestCase): def test_zero(self): self.assertRaises(ZeroDivisionError, average, []) def test_with_zero(self): with self.assertRaises(ZeroDivisionError): average([]) if __name__ == "__main__": unittest.main()
上下文管理器允许我们以通常的方式编写代码(通过调用函数或直接执行代码),而不必在另一个函数调用中包装函数调用。
还有几种其他断言方法,总结在下表中:
方法 | 描述 |
assertGreater``assertGreaterEqual``assertLess``assertLessEqual |
接受两个可比较的对象,并确保命名的不等式成立。 |
assertIn``assertNotIn |
确保元素是(或不是)容器对象中的一个元素。 |
assertIsNone``assertIsNotNone |
确保一个元素是(或不是)确切的None 值(而不是其他假值)。 |
assertSameElements |
确保两个容器对象具有相同的元素,忽略顺序。 |
assertSequenceEqualassertDictEqual``assertSetEqual``assertListEqual``assertTupleEqual |
确保两个容器以相同的顺序具有相同的元素。如果失败,显示一个比较两个列表的代码差异,以查看它们的不同之处。最后四种方法还测试了列表的类型。 |
每个断言方法都接受一个名为msg
的可选参数。如果提供了,它将包含在错误消息中,如果断言失败,这对于澄清预期的内容或解释可能导致断言失败的错误的地方非常有用。然而,我很少使用这种语法,更喜欢为测试方法使用描述性的名称。
减少样板代码和清理
编写了一些小测试之后,我们经常发现我们必须为几个相关的测试编写相同的设置代码。例如,以下list
子类有三种用于统计计算的方法:
from collections import defaultdict class StatsList(list): def mean(self): return sum(self) / len(self) def median(self): if len(self) % 2: return self[int(len(self) / 2)] else: idx = int(len(self) / 2) return (self[idx] + self[idx-1]) / 2 def mode(self): freqs = defaultdict(int) for item in self: freqs[item] += 1 mode_freq = max(freqs.values()) modes = [] for item, value in freqs.items(): if value == mode_freq: modes.append(item) return modes
显然,我们将要测试这三种方法中的每一种情况,这些情况具有非常相似的输入。我们将要看到空列表、包含非数字值的列表,或包含正常数据集的列表等情况下会发生什么。我们可以使用TestCase
类上的setUp
方法来为每个测试执行初始化。这个方法不接受任何参数,并允许我们在每个测试运行之前进行任意的设置。例如,我们可以在相同的整数列表上测试所有三种方法,如下所示:
from stats import StatsList import unittest class TestValidInputs(unittest.TestCase): def setUp(self): self.stats = StatsList([1, 2, 2, 3, 3, 4]) def test_mean(self): self.assertEqual(self.stats.mean(), 2.5) def test_median(self): self.assertEqual(self.stats.median(), 2.5) self.stats.append(4) self.assertEqual(self.stats.median(), 3) def test_mode(self): self.assertEqual(self.stats.mode(), [2, 3]) self.stats.remove(2) self.assertEqual(self.stats.mode(), [3]) if __name__ == "__main__": unittest.main()
如果我们运行这个例子,它表明所有测试都通过了。首先注意到setUp
方法从未在三个test_*
方法中显式调用过。测试套件会代表我们执行这个操作。更重要的是,注意test_median
如何改变了列表,通过向其中添加一个额外的4
,但是当随后调用test_mode
时,列表已经恢复到了setUp
中指定的值。如果没有恢复,列表中将会有两个四,而mode
方法将会返回三个值。这表明setUp
在每个测试之前都会被单独调用,确保测试类从一个干净的状态开始。测试可以以任何顺序执行,一个测试的结果绝不能依赖于其他测试。
除了setUp
方法,TestCase
还提供了一个无参数的tearDown
方法,它可以用于在类的每个测试运行后进行清理。如果清理需要除了让对象被垃圾回收之外的其他操作,这个方法就很有用。
例如,如果我们正在测试进行文件 I/O 的代码,我们的测试可能会在测试的副作用下创建新文件。tearDown
方法可以删除这些文件,并确保系统处于与测试运行之前相同的状态。测试用例绝不能有副作用。通常,我们根据它们共同的设置代码将测试方法分组到单独的TestCase
子类中。需要相同或相似设置的几个测试将被放置在一个类中,而需要不相关设置的测试将被放置在另一个类中。
组织和运行测试
对于一个单元测试集合来说,很快就会变得非常庞大和难以控制。一次性加载和运行所有测试可能会变得非常复杂。这是单元测试的主要目标:在程序上轻松运行所有测试,并快速得到一个“是”或“否”的答案,来回答“我的最近的更改是否有问题?”的问题。
与正常的程序代码一样,我们应该将测试类分成模块和包,以保持它们的组织。如果您将每个测试模块命名为以四个字符test开头,就可以轻松找到并运行它们。Python 的discover
模块会查找当前文件夹或子文件夹中以test
开头命名的任何模块。如果它在这些模块中找到任何TestCase
对象,就会执行测试。这是一种无痛的方式来确保我们不会错过运行任何测试。要使用它,请确保您的测试模块命名为test_.py
,然后运行python3 -m unittest discover
命令。
大多数 Python 程序员选择将他们的测试放在一个单独的包中(通常命名为tests/
,与他们的源目录并列)。但这并不是必需的。有时,将不同包的测试模块放在该包旁边的子包中是有意义的,例如。
Python 入门指南(七)(4)https://developer.aliyun.com/article/1507469