Python 入门指南(七)(3)

简介: Python 入门指南(七)

Python 入门指南(七)(2)https://developer.aliyun.com/article/1507459

命令模式

命令模式在必须执行的操作和调用这些操作的对象之间增加了一个抽象级别,通常是在以后的某个时间。在命令模式中,客户端代码创建一个可以在以后执行的Command对象。这个对象知道一个接收者对象,在命令在其上执行时管理自己的内部状态。Command对象实现了一个特定的接口(通常有一个executedo_action方法,并且还跟踪执行操作所需的任何参数。最后,一个或多个Invoker对象在正确的时间执行命令。

这是 UML 图:


命令模式的一个常见示例是对图形窗口的操作。通常,操作可以通过菜单栏上的菜单项、键盘快捷键、工具栏图标或上下文菜单来调用。这些都是Invoker对象的示例。实际发生的操作,如ExitSaveCopy,是CommandInterface的实现。接收退出的 GUI 窗口,接收保存的文档,接收复制命令的ClipboardManager,都是可能的Receivers的示例。

让我们实现一个简单的命令模式,为SaveExit操作提供命令。我们将从一些适度的接收者类开始,它们本身具有以下代码:

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_itemWindow类。但是如果我们仔细观察,我们会发现根本没有紧密耦合。任何可调用对象都可以设置为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 中,我们不必实现任何接口类,因此我们可以丢弃DateFormatterCurrencyFormatterFormatterFactory。这些格式化类本身非常简单,但冗长,如下所示:

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_datelocalize.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类上创建了movedelete方法。它们都访问一个我们尚未设置的神秘的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 的鸭子类型可以很容易地将其他对象添加到复合层次结构中,只要它们具有正确的接口。

练习

在深入研究每个设计模式的练习之前,花点时间为上一节中的FileFolder对象实现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方法的方法。这个方法将根据两个参数是否相等而成功或引发异常。如果我们运行这段代码,unittestmain函数将给出以下输出:

.
--------------------------------------------------------------
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如果两个参数比较为相等,则会失败。assertTrueassertFalse方法分别接受一个表达式,并且如果表达式不能通过if测试,则会失败。这些测试不检查布尔值TrueFalse。相反,它们测试与使用if语句相同的条件:FalseNone0或空列表、字典、字符串、集合或元组会通过调用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

相关文章
|
2天前
|
数据采集 运维 API
适合所有编程初学者,豆瓣评分8.6的Python入门手册开放下载!
Python是一种跨平台的计算机程序设计语言,它可以用来完成Web开发、数据科学、网络爬虫、自动化运维、嵌入式应用开发、游戏开发和桌面应用开发。 Python上手很容易,基本有其他语言编程经验的人可以在1周内学会Python最基本的内容(PS:没有基础的人也可以直接学习,速度会慢一点) 今天给小伙伴们分享一份Python语言及其应用的手册,这份手册主要介绍 Python 语言的基础知识及其在各个领域的具体应用,基于最新版本 3.x。
|
5天前
|
数据可视化 API Python
Python零基础“圣经”!300W小白从入门到精通首选!
今天分享的这本书在让你尽快学会 Python基础知识的同时,能够编写并正确的运行程序(游戏、数据可视化、Web应用程序) 最大的特色在于,在为初学者构建完整的 Python 语言知识体系的同时,面向实际应用情境编写代码样例,而且许多样例还是 后续实践项目部分的伏笔。实践项目部分的选题经过精心设计,生动详尽 又面面俱到。相信这本书能够得到更多 Python 初学者的喜爱。
|
6天前
|
Python
小白入门必备!计科教授的Python精要参考PDF开放下载!
随着互联网产业的高速发展,在网络上早已积累了极其丰富的Python学习资料,任何人都可以基于这些资源,自学掌握 Python。 但实际上,网络上充斥的资源太多、太杂且不成体系,在没有足够的编程/工程经验之前,仅靠“看”线上资源自学,的确是一件非常困难的事。
|
6天前
|
数据可视化 API Python
豆瓣评分9.4!堪称经典的Python入门圣经,你还没看过吗?
最理想的新人入门书应该满足两个特点:第一就是内容通俗易懂;第二就是要有实战,能够让读者在学完之后知道具体怎么用。 今天给小伙伴们分享的这份Python入门手册,在为初学者构建完整的Python语言知识体系的同时,面向实际应用情境编写代码样例,而且许多样例还是后续实践项目部分的伏笔。实践项目部分的选题经过精心设计,生动详尽又面面俱到。
|
8天前
|
数据采集 运维 API
适合所有编程初学者,豆瓣评分8.6的Python入门手册开放下载!
Python是一种跨平台的计算机程序设计语言,它可以用来完成Web开发、数据科学、网络爬虫、自动化运维、嵌入式应用开发、游戏开发和桌面应用开发。 Python上手很容易,基本有其他语言编程经验的人可以在1周内学会Python最基本的内容(PS:没有基础的人也可以直接学习,速度会慢一点)
|
9天前
|
数据采集 SQL 数据可视化
使用Python和Pandas库进行数据分析的入门指南
使用Python和Pandas库进行数据分析的入门指南
72 0
|
9天前
|
Linux iOS开发 MacOS
Python入门指南
Python入门指南
32 0
|
10天前
|
数据采集 前端开发 JavaScript
Python爬虫入门
网络爬虫是自动抓取网页数据的程序,通过URL获取网页源代码并用正则表达式提取所需信息。反爬机制是网站为防止爬取数据设置的障碍,而反反爬是对这些机制的对策。`robots.txt`文件规定了网站可爬取的数据。基础爬虫示例使用Python的`urllib.request`模块。HTTP协议涉及请求和响应,包括状态码、头部和主体。`Requests`模块是Python中常用的HTTP库,能方便地进行GET和POST请求。POST请求常用于隐式提交表单数据,适用于需要发送复杂数据的情况。
16 1
|
13天前
|
机器学习/深度学习 人工智能 数据可视化
Python编程入门:从零开始探索编程的奇妙世界
这篇教程引导初学者入门Python编程,从安装Python开始,逐步讲解基本语法,如`print()`、变量、条件判断、循环以及自定义函数。文章强调了Python在数据处理、数据分析、人工智能和机器学习等领域的重要性,并鼓励学习者探索Python的广泛应用,开启编程之旅。
|
14天前
|
数据可视化 API Python
Python零基础“圣经”!300W小白从入门到精通首选!
今天分享的这本书在让你尽快学会 Python基础知识的同时,能够编写并正确的运行程序(游戏、数据可视化、Web应用程序) 最大的特色在于,在为初学者构建完整的 Python 语言知识体系的同时,面向实际应用情境编写代码样例,而且许多样例还是 后续实践项目部分的伏笔。实践项目部分的选题经过精心设计,生动详尽 又面面俱到。相信这本书能够得到更多 Python 初学者的喜爱。