Skip to content

Latest commit

 

History

History
889 lines (621 loc) · 68.3 KB

File metadata and controls

889 lines (621 loc) · 68.3 KB

八、测试、分析和处理异常

"Just as the wise accepts gold after testing it by heating, cutting and rubbing it, so are my words to be accepted after examining them, but not out of respect for me." – Buddha

我喜欢佛陀的这句话。在软件世界中,它完美地转化为一种健康的习惯,即永远不相信代码,仅仅因为有人编写了它,或者因为它已经运行了很长一段时间了。如果它没有经过测试,代码就不可信。

为什么考试如此重要?首先,它们给了你可预测性。或者,至少,它们可以帮助您实现高可预测性。不幸的是,代码中总是潜入一些 bug。但我们确实希望我们的代码尽可能可预测。我们不想让人惊讶,换句话说,我们的代码以一种不可预测的方式运行。你会高兴地知道,在带你去度假的飞机上检查传感器的软件有时会疯掉吗?不,可能不会。

因此,我们需要测试我们的代码;我们需要检查它的行为是否正确,它在处理边缘情况时是否按预期工作,它在与之交谈的组件损坏或无法访问时是否没有挂起,性能是否在可接受的范围内,等等。

本章旨在确保您的代码准备好面对可怕的外部世界,速度足够快,并且能够处理意外或异常情况。

在本章中,我们将探讨以下主题:

  • 测试(it 的几个方面,包括对测试驱动开发的简要介绍)
  • 异常处理
  • 配置和性能

让我们从理解什么是测试开始。

测试应用程序

有许多不同类型的测试,事实上,很多公司通常都有一个专门的部门,称为质量保证质量保证),由个人组成,他们每天都在测试公司开发人员生产的软件。

为了开始进行初始分类,我们可以将测试分为两大类:白盒测试和黑盒测试。

白盒测试是那些测试代码内部的测试;他们对它进行了细致的检查。另一方面,Ty2 T2 黑箱测试 AutoT3T 是那些被认为是在一个方框内的测试软件,其中的内部被忽略。即使是黑盒测试中使用的技术或语言也不重要。他们所做的是将输入插入到盒子的一端,并验证另一端的输出,就是它。

There is also an in-between category, called gray-box testing, which involves testing a system in the same way we do with the black-box approach, but having some knowledge about the algorithms and data structures used to write the software and only partial access to its source code.

在这些类别中有许多不同类型的测试,每个测试都有不同的用途。为了给你一个想法,这里有几个:

  • 前端测试:确保应用程序的客户端公开了它应该公开的信息、所有链接、按钮、广告以及需要向客户端显示的所有内容。它还可以验证是否可以通过用户界面走特定的路径。
  • 场景测试:利用故事(或场景)帮助测试人员解决复杂问题或测试系统的一部分。
  • 集成测试:验证应用程序的各个组件在通过接口发送消息时的行为。
  • 冒烟测试:在应用程序上部署新更新时特别有用。他们会检查应用程序中最重要、最重要的部分是否仍在正常工作,以及它们是否着火。这个术语来自工程师测试电路时确保没有冒烟。
  • 验收测试用户验收测试UAT):开发人员如何与产品负责人(例如,在 SCRUM 环境中)确定委托的工作是否正确执行。
  • 功能测试:验证您的软件的特性或功能。
  • 破坏性测试:取下系统的部分,模拟故障,以确定系统其余部分的性能。这些类型的测试由需要提供极其可靠服务的公司广泛执行,例如亚马逊和 Netflix。
  • 性能测试:旨在验证系统在特定数据或流量负载下的性能,例如,工程师可以更好地了解系统中的瓶颈,这些瓶颈可能会在重负载情况下导致系统瘫痪,或者阻碍可伸缩性。
  • 可用性测试和密切相关的用户体验****UX测试:目的是检查用户界面是否简单易懂、易用。他们的目标是为设计师提供投入,从而改善用户体验。
  • 安全和渗透测试:目的是验证系统对攻击和入侵的保护程度。
  • 单元测试:帮助开发人员以健壮、一致的方式编写代码,提供第一行反馈,防止编码错误、重构错误等。
  • 回归测试:为开发人员提供更新后系统中受损功能的有用信息。一个系统被称为有回归的一些原因是一个旧的 bug 复活了,一个现有的特性被破坏了,或者一个新的问题被引入。

已经有很多关于测试的书籍和文章,如果您想了解更多关于各种测试的信息,我必须向您指出这些资源。在本章中,我们将集中讨论单元测试,因为它们是软件制作的支柱,并且构成了绝大多数由开发人员编写的测试。

考试是一门艺术,恐怕你不会从书本上学到这门艺术。您可以学习所有的定义(而且您应该学习),并尽可能多地收集关于测试的知识,但是只有在您在该领域已经做了足够长的时间后,您才有可能正确地测试您的软件。

当你在重构一段代码时遇到困难,因为你碰到的每一件小事都会使测试失败,你会学习如何编写不那么严格和限制性的测试,这些测试仍然会验证你的代码的正确性,但同时,允许你自由和快乐地使用它,随心所欲地塑造它。

当您经常被调用以修复代码中意外的错误时,您将学习如何更彻底地编写测试,如何提出更全面的边缘情况列表,以及在它们变成错误之前应对它们的策略。

当您花费太多时间阅读测试并试图重构它们以更改代码中的一个小特性时,您将学会编写更简单、更短、更专注的测试。

当你。。。你学会了。。。,但我想你明白了。你需要把手弄脏,积累经验。我的建议?尽可能多地学习理论,然后用不同的方法进行实验。此外,尽量向有经验的程序员学习;它非常有效。

考试的剖析

在我们专注于单元测试之前,让我们看看什么是测试,以及它的目的是什么。

测试是一段代码,其目的是验证系统中的某些内容。这可能是因为我们调用了一个传递两个整数的函数,一个对象有一个名为donald_duck的属性,或者当您对某个 API 下订单时,一分钟后您可以看到它在数据库中被分解为基本元素。

测试通常由三个部分组成:

  • 准备:这是你设置场景的地方。您需要在需要的地方准备所有数据、对象和服务,以便随时可以使用。
  • 执行:这是您执行要检查的逻辑位的地方。您可以使用在准备阶段设置的数据和接口执行操作。
  • 验证:这是您验证结果并确保结果符合您的期望的地方。您可以检查函数的返回值,或者检查某些数据是否在数据库中,某些数据是否在数据库中,某些数据是否已更改,是否发出了请求,是否发生了什么事情,是否调用了方法,等等。

虽然测试通常遵循此结构,但在测试套件中,您通常会发现参与测试游戏的一些其他构造:

  • 设置:这是在几个不同的测试中非常常见的情况。它的逻辑可以定制为为每个测试、类、模块甚至整个会话运行。在这个阶段,开发人员通常会建立到数据库的连接,可能会用测试所需的数据填充数据库,以此类推。
  • 拆卸:与设置相反;拆卸阶段在测试运行后发生。与设置一样,它可以定制为针对每个测试、类或模块或会话运行。通常在这个阶段,我们会销毁为测试套件创建的任何人工制品,然后自己清理。
  • 夹具:它们是测试中使用的数据片段。通过使用一组特定的夹具,结果是可预测的,因此测试可以对其进行验证。

在本章中,我们将使用pytestPython 库。它是一个非常强大的工具,它使测试变得更加容易,并提供了大量的帮助,因此测试逻辑可以更加关注实际测试,而不是其周围的布线。当我们开始编写代码时,您会看到,pytest的一个特点是固定装置、设置和拆卸经常融合在一起。

测试指南

和软件一样,测试可以是好的或坏的,中间有一系列的色调。要编写好测试,以下是一些指导原则:

  • 让它们尽可能简单。违反一些好的编码规则是可以的,例如硬编码值或复制代码。测试首先需要尽可能的可读且易于理解。当测试难以阅读或理解时,您永远无法确信它们确实在确保代码正确执行。
  • 测试应验证一件事,且仅验证一件事。非常重要的一点是,你要让它们简短、内敛。编写多个测试来执行单个对象或函数是非常好的。只要确保每个测试都有一个且只有一个目的。
  • 在验证数据时,测试不应做出任何不必要的假设。这一点一开始很难理解,但很重要。验证函数调用的结果是否为[1, 2, 3]与表示输出是包含数字123的列表不同。在前一种情况下,我们也假设了顺序;在后者中,我们只假设列表中有哪些项。这些差异有时相当微妙,但它们仍然非常重要。
  • 测试应该使用 what,而不是 how。测试应该关注于检查一个函数应该做什么,而不是它是如何做的。例如,关注它正在计算一个数字的平方根(即什么),而不是它正在调用math.sqrt来做这件事(即如何做)。除非您正在编写性能测试,或者您特别需要验证某个操作是如何执行的,否则请尽量避免此类测试,并将重点放在什么上。测试如何导致限制性测试并使重构变得困难。此外,当您集中精力于如何时,您必须编写的测试类型更有可能在您频繁修改软件时降低测试代码库的质量。**
  • 测试应使用完成工作所需的最小夹具组。这是另一个关键点。固定装置有随时间增长的趋势。他们也倾向于时不时地改变。如果您在测试中使用大量的固定装置并忽略冗余,重构将花费更长的时间。发现虫子将更加困难。试着使用一套足够大的夹具来正确执行测试,但不要太大。
  • 测试应尽可能快地运行。一个好的测试代码库最终可能比被测试的代码本身要长得多。它根据情况和开发人员的不同而有所不同,但是,无论长度如何,最终都会有数百个(如果不是数千个)测试要运行,这意味着测试运行得越快,您就可以越快地重新编写代码。例如,当使用 TDD 时,您经常运行测试,因此速度是必不可少的。
  • 测试应使用尽可能少的资源。这样做的原因是,每个签出您的代码的开发人员都应该能够运行您的测试,无论他们的测试框有多强大。它可能是一个瘦小的虚拟机,也可能是一个被忽略的 Jenkins 盒子,您的测试应该在运行时不会占用太多资源。

A Jenkins box is a machine that runs Jenkins, software that is capable of, among many other things, running your tests automatically. Jenkins is frequently used in companies where developers use practices such as continuous integration and extreme programming.

单元测试

既然您已经了解了什么是测试以及为什么我们需要它,那么让我们介绍一下开发人员最好的朋友:单元测试

在我们继续进行示例之前,请允许我分享一些注意事项:我将尝试向您介绍单元测试的基本原理,但我不会完全遵循任何特定的思想流派或方法。多年来,我尝试了许多不同的测试方法,最终提出了我自己的做事方式,这是不断发展的。正如李小龙所说:

"Absorb what is useful, discard what is useless and add what is specifically your own."

编写单元测试

单元测试因其用于测试小型代码单元而得名。为了解释如何编写一个单元测试,让我们来看看一个简单的片段:

# data.py
def get_clean_data(source): 
    data = load_data(source) 
    cleaned_data = clean_data(data) 
    return cleaned_data 

get_clean_data函数负责从source获取数据,清理数据,并将数据返回给调用者。我们如何测试这个函数?

一种方法是调用它,然后确保调用了一次load_data,并将source作为其唯一参数。然后我们必须验证调用了一次clean_data,返回值为load_data。最后,我们需要确保clean_data的返回值也是get_clean_data函数返回的值。

为此,我们需要设置源代码并运行此代码,这可能是一个问题。单元测试的黄金法则之一是任何跨越应用程序边界的东西都需要模拟。我们不想与真实的数据源交谈,如果真实的函数与应用程序中不包含的任何内容通信,我们也不想实际运行它们。有几个例子是数据库、搜索服务、外部 API 和文件系统中的文件。

我们需要这些限制作为屏障,这样我们就可以始终安全地运行测试,而不用担心破坏真实数据源中的某些内容。

另一个原因是单个开发人员可能很难在他们的机器上复制整个体系结构。它可能需要设置数据库、API、服务、文件和文件夹等,这可能很困难、耗时,有时甚至不可能。

Very simply put, an application programming interface (API) is a set of tools for building software applications. An API expresses a software component in terms of its operations, input and output, and underlying types. For example, if you create a software that needs to interface with a data provider service, it's very likely that you will have to go through their API in order to gain access to the data.

因此,在我们的单元测试中,我们需要以某种方式模拟所有这些事情。单元测试需要由任何开发人员运行,而不需要在他们的机器上设置整个系统。

另一种方法是模拟实体而不使用假对象,而是使用专用测试对象,这是我在可能的情况下一直喜欢的方法。例如,如果您的代码与数据库对话,而不是伪造与数据库对话的所有函数和方法,并对假对象进行编程,以便它们返回真实对象所返回的内容,我更愿意生成一个测试数据库,设置我需要的表和数据,然后修补连接设置,使我的测试针对测试数据库运行真正的代码,从而不会造成任何伤害。对于这些情况,内存中的数据库是很好的选择。

One of the applications that allow you to spawn a database for testing is Django. Within the django.test package, you can find several tools that help you write your tests so that you won't have to simulate the dialog with a database. By writing tests this way, you will also be able to check on transactions, encodings, and all other database-related aspects of programming. Another advantage of this approach consists in the ability of checking against things that can change from one database to another.

但是,有时候,这仍然是不可能的,我们需要使用假货,所以让我们来谈谈它们。

模拟对象和修补

首先,在 Python 中,这些伪对象称为mock。在版本 3.3 之前,mock库是一个第三方库,基本上每个项目都会通过pip安装,但从版本 3.3 开始,它就被包含在unittest模块下的标准库中,考虑到它的重要性和广泛性,这是理所当然的。

用模拟替换真实对象或函数(或一般情况下,任何数据结构)的行为称为修补mock库提供了patch工具,它可以作为函数或类装饰器,甚至可以作为上下文管理器,您可以使用它来模拟。一旦用合适的模拟替换了不需要运行的所有内容,就可以进入测试的第二阶段并运行正在运行的代码。执行后,您将能够检查这些模拟,以验证代码是否正常工作。

断言

验证阶段是通过使用断言来完成的。断言是一个函数(或方法),可用于验证对象之间的相等性以及其他条件。当一个条件不满足时,断言将引发一个异常,使测试失败。您可以在unittest模块文档中找到断言列表;然而,当使用pytest时,您通常会使用泛型assert语句,这使得事情更加简单。

测试 CSV 生成器

现在让我们采取一种切实可行的方法。我将向您展示如何测试一段代码,在本例的上下文中,我们将涉及单元测试的其他重要概念。

我们想要编写一个export函数,它执行以下操作:它获取一个字典列表,每个字典代表一个用户。它创建一个 CSV 文件,在其中放入一个标题,然后继续添加根据某些规则被认为有效的所有用户。export函数还接受一个文件名,该文件名将是输出中 CSV 的名称。最后,它还指示是否允许覆盖具有相同名称的现有文件。

至于用户,他们必须遵守以下规定:每个用户至少有一封电子邮件、一个姓名和一个年龄。可以有第四个字段表示角色,但它是可选的。用户的电子邮件地址必须有效,姓名必须非空,年龄必须为 18 到 65 之间的整数。

这是我们的任务,所以现在我将向您展示代码,然后我们将分析我为其编写的测试。但是,首先,在下面的代码片段中,我将使用两个第三方库:marshmallowpytest。它们都符合本书源代码的要求,因此请确保您已使用pip安装了它们。

marshmallow是一个很棒的库,它为我们提供了序列化和反序列化对象的能力,最重要的是,它让我们能够定义一个模式,我们可以使用它来验证用户词典。pytest是我见过的最好的软件之一。它现在到处都在使用,并且已经取代了其他工具,例如nose。它为我们提供了编写漂亮的简短测试的强大工具。

但是让我们来看看代码。我之所以称之为api.py,只是因为它公开了一个我们可以用来做事情的函数。我将分块向您展示:

# api.py
import os
import csv
from copy import deepcopy

from marshmallow import Schema, fields, pre_load
from marshmallow.validate import Length, Range

class UserSchema(Schema):
    """Represent a *valid* user. """

    email = fields.Email(required=True)
    name = fields.String(required=True, validate=Length(min=1))
    age = fields.Integer(
        required=True, validate=Range(min=18, max=65)
    )
    role = fields.String()

    @pre_load(pass_many=False)
    def strip_name(self, data):
        data_copy = deepcopy(data)

        try:
            data_copy['name'] = data_copy['name'].strip()
        except (AttributeError, KeyError, TypeError):
            pass

        return data_copy

schema = UserSchema()

第一部分是我们导入所有需要的模块(oscsv,以及marshmallow中的一些工具,然后我们为用户定义模式。如您所见,我们从marshmallow.Schema继承,然后设置了四个字段。请注意,我们使用了两个String字段EmailInteger,它们已经为我们提供了marshmallow中的一些验证。请注意role字段中没有required=True

不过,我们需要添加一些自定义代码。我们需要添加validate_age以确保该值在我们想要的范围内。我们提出ValidationError以防万一。如果我们传递的不是整数,那么marshmallow会很小心地引发一个错误。

接下来,我们添加了validate_name,因为字典中有name键这一事实并不保证名称实际上是非空的。所以我们取它的值,去掉所有前导和尾随的空格字符,如果结果为空,我们再次提升ValidationError。请注意,我们不需要为email字段添加自定义验证器。这是因为marshmallow将对其进行验证,有效电子邮件不能为空。

然后我们实例化schema,以便我们可以使用它来验证数据。那么让我们来编写export函数:

# api.py
def export(filename, users, overwrite=True):
    """Export a CSV file.

    Create a CSV file and fill with valid users. If `overwrite`
    is False and file already exists, raise IOError.
    """
    if not overwrite and os.path.isfile(filename):
        raise IOError(f"'{filename}' already exists.")

    valid_users = get_valid_users(users)
    write_csv(filename, valid_users)

正如您所看到的,它的内部结构非常简单。如果overwriteFalse且该文件已经存在,我们将发出IOError并显示一条消息,说明该文件已经存在。否则,如果我们可以继续,我们只需获取有效用户的列表并将其提供给write_csv,而write_csv负责实际执行任务。让我们看看这些函数是如何定义的:

# api.py
def get_valid_users(users):
    """Yield one valid user at a time from users. """
    yield from filter(is_valid, users)

def is_valid(user):
    """Return whether or not the user is valid. """
    return not schema.validate(user)

事实证明,我将get_valid_users编码为一个生成器,因为没有必要为了将其放入文件而制作一个潜在的大列表。我们可以逐一验证并保存它们。验证的核心只是对schema.validate的委托,它使用marshmallow的验证引擎。其工作方式是返回字典,如果验证成功,字典将为空,否则它将包含错误信息。我们并不真正关心为这个任务收集错误信息,所以我们只是忽略它,在is_valid中,如果schema.validate的返回值为空,我们基本上返回True,否则返回False

最后一件丢失了;这是:

# api.py
def write_csv(filename, users):
    """Write a CSV given a filename and a list of users.

    The users are assumed to be valid for the given CSV structure.
    """
    fieldnames = ['email', 'name', 'age', 'role']

    with open(filename, 'x', newline='') as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
        writer.writeheader()
        for user in users:
            writer.writerow(user)

同样,逻辑是直截了当的。我们在fieldnames中定义了头,然后打开filename进行写入,并指定了newline='',这是文档中在处理 CSV 文件时推荐的。创建文件后,我们使用csv.DictWriter类获得一个writer对象。这个工具的优点在于它能够将用户字典映射到字段名,所以我们不需要考虑排序问题。

我们先写标题,然后循环用户并逐个添加。请注意,此函数假定向其提供了一个有效用户列表,如果该假定为 false,则可能会中断(使用默认值,如果任何用户字典有额外字段,则会中断)。

这就是你必须记住的全部代码。我建议你花点时间再看一遍。不需要记住它,而且我使用了具有有意义名称的小助手函数这一事实将使您能够更轻松地跟踪测试。

现在让我们进入有趣的部分:测试我们的export函数。再次,我将分块向您展示代码:

# tests/test_api.py
import os
from unittest.mock import patch, mock_open, call
import pytest
from ..api import is_valid, export, write_csv

让我们从导入开始:我们需要os、临时目录(我们已经在第 7 章文件和数据持久性中看到了)、然后是pytest,最后,我们使用相对导入来获取我们想要实际测试的三个函数:is_validexportwrite_csv

不过,在编写测试之前,我们需要制作一些装置。正如您将看到的,fixture是一个用pytest.fixture装饰符装饰的函数。在大多数情况下,我们希望fixture返回一些东西,以便我们可以在测试中使用它。我们对用户词典有一些要求,所以让我们编写两个用户:一个具有最低要求,另一个具有完整要求。两者都必须有效。代码如下:

# tests/test_api.py
@pytest.fixture
def min_user():
    """Represent a valid user with minimal data. """
    return {
        'email': 'minimal@example.com',
        'name': 'Primus Minimus',
        'age': 18,
    }

@pytest.fixture
def full_user():
    """Represent valid user with full data. """
    return {
        'email': 'full@example.com',
        'name': 'Maximus Plenus',
        'age': 65,
        'role': 'emperor',
    }

在本例中,唯一的区别是role键的存在,但它足以向您展示我希望的要点。请注意,我们并没有简单地在模块级别声明字典,而是编写了两个返回字典的函数,并使用pytest.fixture装饰符对它们进行了修饰。这是因为,当您在模块级声明字典(应该在测试中使用)时,您需要确保在每个测试开始时复制它。如果您不这样做,您可能会有一个测试修改它,这将影响所有遵循它的测试,损害它们的完整性。

通过使用这些装置,pytest将在每次测试运行时为我们提供一个新的字典,因此我们不需要自己经历这种痛苦。请注意,如果一个 fixture 返回另一个类型,而不是 dict,那么您将在测试中得到它。夹具也是可组合的,这意味着它们可以相互使用,这是pytest的一个非常强大的功能。为了向您展示这一点,让我们为一个用户列表编写一个 fixture,其中我们放置了两个已经存在的用户,加上一个由于没有年龄而无法验证的用户。让我们看看下面的代码:

# tests/test_api.py
@pytest.fixture
def users(min_user, full_user):
    """List of users, two valid and one invalid. """
    bad_user = {
        'email': 'invalid@example.com',
        'name': 'Horribilis',
    }
    return [min_user, bad_user, full_user]

美好的现在我们有两个用户可以单独使用,但我们也有一个三个用户的列表。第一轮测试将测试我们如何验证用户。我们将在一个类中对此任务的所有测试进行分组。这不仅有助于为相关测试提供名称空间和位置,而且,正如我们稍后将看到的那样,它允许我们声明类级别的装置,这些装置仅为属于该类的测试定义。请看下面的代码:

# tests/test_api.py
class TestIsValid:
    """Test how code verifies whether a user is valid or not. """
    def test_minimal(self, min_user):
        assert is_valid(min_user)

    def test_full(self, full_user):
        assert is_valid(full_user)

我们首先要确保我们的设备实际上通过了验证。这是非常重要的,因为这些装置将在任何地方使用,所以我们希望它们是完美的。接下来,我们测试年龄。这里需要注意两件事:我不会重复类签名,所以下面的代码缩进了四个空格,这是因为这些都是同一个类中的方法,好吗?其次,我们将大量使用参数化。

参数化是一种技术,它使我们能够多次运行同一个测试,但向它提供不同的数据。它非常有用,因为它允许我们只编写一次测试,不重复,并且结果将由pytest非常智能地处理,它将运行所有这些测试,就像它们实际上是分开的一样,从而在失败时为我们提供清晰的错误消息。如果您手动参数化,您将失去此功能,相信我,您不会高兴的。让我们看看如何测试年龄:

# tests/test_api.py
    @pytest.mark.parametrize('age', range(18))
    def test_invalid_age_too_young(self, age, min_user):
        min_user['age'] = age
        assert not is_valid(min_user)

好的,所以我们首先编写一个测试来检查当用户太年轻时验证是否失败。根据我们的规则,用户在 18 岁以下时就太年轻了。我们使用range检查 0 到 17 岁之间的每个年龄段。

如果您看看参数化是如何工作的,您会看到我们声明了一个对象的名称,然后将其传递给该方法的签名,然后指定该对象将采用哪些值。对于每个值,测试将运行一次。在第一次测试的情况下,对象的名称为age,值均为range(18)返回的值,即包含017的所有整数。注意我们是如何将age输入测试方法的,就在self之后,然后我们做一些其他的事情,这也是非常有趣的。我们将此方法传递给夹具:min_user。这样做的效果是激活测试运行的夹具,以便我们可以使用它,并且可以从测试中引用它。在这种情况下,我们只需在min_user字典中更改年龄,然后验证is_valid(min_user)的结果是否为False

我们通过断言not FalseTrue这一事实来完成最后一点。在pytest中,这是您检查某些内容的方式。你只是断言某事是真实的。如果是这种情况,则测试成功。如果相反,测试将失败。

让我们继续并添加使验证在年龄上失败所需的所有测试:

# tests/test_api.py
    @pytest.mark.parametrize('age', range(66, 100))
    def test_invalid_age_too_old(self, age, min_user):
        min_user['age'] = age
        assert not is_valid(min_user)

    @pytest.mark.parametrize('age', ['NaN', 3.1415, None])
    def test_invalid_age_wrong_type(self, age, min_user):
        min_user['age'] = age
        assert not is_valid(min_user)

那么,另外两个测试。其中一个负责另一个领域,从 66 岁到 99 岁。而第二个则是确保年龄不是整数时是无效的,所以我们传递一些值,比如字符串、浮点和None,只是为了确保。注意,测试的结构基本上总是相同的,但是,由于参数化,我们向它提供了非常不同的输入参数。

现在,我们已经解决了所有未通过的年龄问题,让我们添加一个测试,实际检查年龄是否在有效范围内:

# tests/test_api.py
    @pytest.mark.parametrize('age', range(18, 66))
    def test_valid_age(self, age, min_user):
        min_user['age'] = age
        assert is_valid(min_user)

就这么简单。我们传递正确的范围,从1865,并删除断言中的not。请注意,所有测试都以test_前缀开头,并具有不同的名称。

我们可以考虑照顾这个年龄。让我们继续在必填字段上编写测试:

# tests/test_api.py
    @pytest.mark.parametrize('field', ['email', 'name', 'age'])
    def test_mandatory_fields(self, field, min_user):
        min_user.pop(field)
        assert not is_valid(min_user)

    @pytest.mark.parametrize('field', ['email', 'name', 'age'])
    def test_mandatory_fields_empty(self, field, min_user):
        min_user[field] = ''
        assert not is_valid(min_user)
    def test_name_whitespace_only(self, min_user):
        min_user['name'] = ' \n\t'
        assert not is_valid(min_user)

前三个测试仍然属于同一类。第一个测试在缺少一个必填字段时用户是否无效。请注意,在每次测试运行时,min_user夹具都会被恢复,因此每次测试运行只有一个缺少的字段,这是检查必填字段的适当方法。我们只需从字典中取出钥匙。这次参数化对象的名称为field,通过查看第一个测试,您可以看到参数化修饰符中的所有必填字段:emailnameage

在第二种情况下,情况有点不同。我们没有弹出键,而是将它们(一次一个)设置为空字符串。最后,在第三个示例中,我们检查名称是否仅由空格组成。

前面的测试考虑了必填字段的存在和非空,以及用户name键周围的格式。好的现在让我们为这个类编写最后两个测试。我们要检查电子邮件的有效性,并键入电子邮件、姓名和角色:

# tests/test_api.py
    @pytest.mark.parametrize(
        'email, outcome',
        [
            ('missing_at.com', False),
            ('@missing_start.com', False),
            ('missing_end@', False),
            ('missing_dot@example', False),

            ('good.one@example.com', True),
            ('δοκιμή@παράδειγμα.δοκιμή', True),
            ('аджай@экзампл.рус', True),
        ]
    )
    def test_email(self, email, outcome, min_user):
        min_user['email'] = email
        assert is_valid(min_user) == outcome

这一次,参数化稍微复杂一些。我们定义两个对象(emailoutcome),然后将元组列表(而不是简单的列表)传递给装饰器。每次运行测试时,这些元组中的一个将被解包,以便分别填充emailoutcome的值。这允许我们为有效和无效的电子邮件地址编写一个测试,而不是两个单独的测试。我们定义了一个电子邮件地址,并指定了我们期望从验证中得到的结果。前四个是无效的电子邮件地址,但后三个实际上是有效的。我使用了几个 Unicode 示例,只是为了确保我们不会忘记将来自世界各地的朋友包括在验证中。

注意验证是如何完成的,断言调用的结果需要与我们设置的结果相匹配。

现在,让我们编写一个简单的测试,以确保在向字段中输入错误的类型时验证失败(同样,之前已经单独处理了年龄):

# tests/test_api.py
    @pytest.mark.parametrize(
        'field, value',
        [
            ('email', None),
            ('email', 3.1415),
            ('email', {}),

            ('name', None),
            ('name', 3.1415),
            ('name', {}),

            ('role', None),
            ('role', 3.1415),
            ('role', {}),
        ]
    )
    def test_invalid_types(self, field, value, min_user):
        min_user[field] = value
        assert not is_valid(min_user)

正如我们之前所做的,只是为了好玩,我们传递了三个不同的值,它们实际上都不是字符串。这个测试可以扩展到包含更多的值,但是,老实说,我们不需要编写这样的测试。我把它放在这里只是为了告诉你什么是可能的。

在我们进入下一节测试课之前,让我谈谈我们在检查年龄时看到的一些事情。

边界和粒度

在检查年龄时,我们编写了三个测试,涵盖三个范围:0-17(失败)、18-65(成功)、66-99(失败)。我们为什么要这样做?答案在于,我们正在处理两个边界:18 和 65。因此,我们的测试需要关注这两个边界定义的三个区域:在18之前、1865之内以及65之后。如何做并不重要,只要确保正确测试边界即可。这意味着如果有人将模式中的验证从18 <= value <= 65更改为18 <= value < 65(请注意缺少的=,则必须有一个测试在65上失败。

这个概念被称为边界,在代码中识别它们非常重要,这样您就可以针对它们进行测试。

另一件重要的事情是了解我们想要接近边界的缩放级别。换句话说,我应该使用哪个单位来移动它?就年龄而言,我们处理的是整数,因此,1单位将是完美的选择(这就是为什么我们使用1617181920…)。但是如果你在测试时间戳呢?那么,在这种情况下,正确的粒度可能会有所不同。如果代码必须根据时间戳执行不同的操作,并且时间戳表示秒,那么测试的粒度应该缩小到秒。如果时间戳表示年,那么年应该是您使用的单位。我希望你明白了。这个概念被称为粒度,并且需要与边界的粒度相结合,这样通过使用正确的粒度绕过边界,您可以确保您的测试不会留下任何机会。

现在让我们继续我们的示例,并测试export函数。

测试导出功能

在同一个测试模块中,我定义了另一个类,它表示export函数的测试套件。这是:

# tests/test_api.py
class TestExport:

    @pytest.fixture
    def csv_file(self, tmpdir):
        yield tmpdir.join("out.csv")

    @pytest.fixture
    def existing_file(self, tmpdir):
        existing = tmpdir.join('existing.csv')
        existing.write('Please leave me alone...')
        yield existing

让我们开始了解固定装置。这次我们在类级别定义了它们,这意味着它们只有在类中的测试运行时才是活动的。我们不需要这个类之外的这些装置,所以在模块级声明它们是没有意义的,就像我们在用户装置上所做的那样。

所以,我们需要两个文件。如果您还记得我在本章开头所写的内容,当涉及到与数据库、磁盘、网络等的交互时,我们应该模拟一切。但是,如果可能的话,我更喜欢使用不同的技术。在本例中,我将使用临时文件夹,这些文件夹将在 fixture 中生成,并在 fixture 中消亡,不会留下它们存在的痕迹。如果我能避免嘲笑,我会更快乐。模拟是令人惊奇的,但它可能很棘手,并且是 bug 的来源,除非它做得正确。

现在,第一个 fixturecsv_file定义了一个托管上下文,我们在其中获取对临时文件夹的引用。我们可以考虑逻辑和包括 Ty1 T1,作为设置阶段。就数据而言,设备本身由临时文件名表示。文件本身还不存在。当测试运行时,将创建夹具,并在测试结束时执行夹具代码的其余部分(在yield之后的代码,如果有)。该部分可被视为拆卸阶段。在这种情况下,它包括退出上下文管理器,这意味着删除临时文件夹(及其所有内容)。您可以在任何夹具的每个阶段投入更多,凭借经验,我相信您将掌握以这种方式进行安装和拆卸的艺术。事实上,它来得很自然很快。

第二个 fixture 与第一个 fixture 非常相似,但我们将使用它来测试在调用overwrite=False时是否可以防止覆盖。因此,我们在临时文件夹中创建了一个文件,并将一些内容放入其中,只是为了验证它是否未被触动。

请注意,这两个装置如何返回带有完整路径信息的文件名,以确保我们在代码中实际使用了临时文件夹。现在让我们看一下测试:

# tests/test_api.py
    def test_export(self, users, csv_file):
        export(csv_file, users)

        lines = csv_file.readlines()

        assert [
            'email,name,age,role\n',
            'minimal@example.com,Primus Minimus,18,\n',
            'full@example.com,Maximus Plenus,65,emperor\n',
        ] == lines

本测试使用userscsv_file夹具,并立即调用export。我们希望已经创建了一个文件,并填充了我们拥有的两个有效用户(请记住,该列表包含三个用户,但其中一个无效)。

为了验证这一点,我们打开临时文件,并将其所有行收集到一个列表中。然后,我们将该文件的内容与我们期望在其中的行列表进行比较。请注意,我们只按正确的顺序放置标题和两个有效用户。

现在我们需要另一个测试,以确保如果其中一个值中有逗号,我们的 CSV 仍然正确生成。作为一个逗号分隔的值CSV)文件,我们需要确保数据中的逗号不会将内容分解:

# tests/test_api.py
    def test_export_quoting(self, min_user, csv_file):
        min_user['name'] = 'A name, with a comma'

        export(csv_file, [min_user])

        lines = csv_file.readlines()
        assert [
            'email,name,age,role\n',
            'minimal@example.com,"A name, with a comma",18,\n',
        ] == lines

这一次,我们不需要完整的用户列表,我们只需要一个,因为我们正在测试一个特定的东西,我们有以前的测试,以确保我们正确地生成所有用户的文件。请记住,始终尽量减少您在测试中所做的工作。

因此,我们使用min_user,并在其名称中加上一个漂亮的逗号。然后我们重复这个过程,这与前面的测试非常相似,最后我们确保名称被放在 CSV 文件中,并用双引号括起来。这足以让任何一个好的 CSV 解析器理解,它们不必在双引号内打断逗号。

现在我还需要一个测试,需要检查文件是否存在,我们不想覆盖它,我们的代码不会触及它:

# tests/test_api.py
    def test_does_not_overwrite(self, users, existing_file):
        with pytest.raises(IOError) as err:
            export(existing_file, users, overwrite=False)

        assert err.match(
            r"'{}' already exists\.".format(existing_file)
        )

        # let's also verify the file is still intact
        assert existing_file.read() == 'Please leave me alone...'

这是一个漂亮的测试,因为它允许我向您展示如何告诉pytest您期望函数调用引发异常。我们在pytest.raises提供给我们的上下文管理器中执行此操作,我们将在该上下文管理器的主体内进行的调用中预期的异常提供给该上下文管理器。如果未引发异常,测试将失败。

我喜欢在考试中彻底,所以我不想就此止步。我还通过使用方便的err.match助手对消息进行断言(注意,它需要正则表达式,而不是简单的字符串–我们将在第 14 章Web 开发中看到正则表达式)。

最后,通过打开该文件,并将其所有内容与应该包含的字符串进行比较,确保该文件仍然包含其原始内容(这就是我创建existing_filefixture 的原因)。

最后考虑

在我们进入下一个话题之前,让我先总结一下一些考虑因素。

首先,我希望您已经注意到我没有测试我编写的所有函数。具体来说,我没有测试get_valid_usersvalidatewrite_csv。原因是这些函数是由我们的测试套件隐式测试的。我们已经测试了is_validexport,这足以确保我们的模式正确验证用户,export功能用于正确过滤无效用户,在需要时尊重现有文件,并编写正确的 CSV。我们还没有测试的功能是内部的,它们提供了参与我们已经彻底测试过的事情的逻辑。为这些函数添加额外的测试是好是坏?想一想。

答案其实很难。测试越多,重构代码的能力就越弱。现在,我可以很容易地决定用另一个名字来调用is_valid,并且我不需要更改任何测试。如果你仔细想想,这是有道理的,因为只要is_validget_valid_users函数提供了正确的验证,我就不需要知道它。这对你有意义吗?

相反,如果我对validate函数进行了测试,那么如果我决定以不同的方式调用它(或者以某种方式更改它的签名),我就必须更改它们。

那么,正确的做法是什么?测试还是不测试?这将取决于你。你必须找到正确的平衡。我个人对这件事的看法是,一切都需要直接或间接地彻底测试。我想要尽可能小的测试套件来保证这一点。通过这种方式,我将拥有一个覆盖范围非常大的测试套件,但不会超出必要的范围。您需要维护这些测试!

我希望这个例子对你们有意义,我认为它让我触及了重要的话题。

如果您查看本书的源代码,在test_api.py模块中,我添加了两个额外的测试类,这将向您展示如果我决定一直使用 mock,测试会有多么不同。请确保您阅读了该代码并很好地理解它。这是非常直接的,将为您提供一个很好的比较,与我的个人方法,我已经向您展示了这里。

现在,我们运行这些测试怎么样?(输出被重新安排以适合本书的格式):

$ pytest tests
====================== test session starts ======================
platform darwin -- Python 3.7.0b2, pytest-3.5.0, py-1.5.3, ...
rootdir: /Users/fab/srv/lpp/ch8, inifile:
collected 132 items

tests/test_api.py ...............................................
.................................................................
.................... [100%]

================== 132 passed in 0.41 seconds ===================

确保从ch8文件夹中运行$ pytest test(为详细输出添加-vv标志,该标志将显示参数化如何修改测试名称)。正如您所看到的,132测试在不到半秒钟的时间内运行,它们都成功了。我强烈建议您查看此代码并使用它。更改代码中的某些内容,然后查看是否有任何测试被破坏。理解它为什么会断裂。是不是有什么重要的事情意味着考试不够好?或者是一些愚蠢的事情不应该导致测试失败?所有这些看似无害的问题将帮助您深入了解测试的艺术。

我也建议你学习unittest模块,还有pytest模块。这些是您将一直使用的工具,因此您需要非常熟悉它们。

现在让我们看看测试驱动开发!

测试驱动开发

让我们简单谈谈测试驱动开发TDD。这是 Kent Beck 重新发现的一种方法,他撰写了通过示例进行测试驱动的开发Addison Wesley,2002,如果你想了解这门学科的基础知识,我鼓励你去看看。

TDD is a software development methodology that is based on the continuous repetition of a very short development cycle.

首先,开发人员编写测试并使其运行。测试应该检查尚未成为代码一部分的特性。可能是要添加的新功能,或者要删除或修改的内容。运行测试将使其失败,因此,此阶段称为红色

当测试失败时,开发人员编写最少的代码使其通过。当运行测试成功时,我们有所谓的绿色阶段。在这个阶段,编写作弊代码是可以的,只是为了让测试通过。这种技术被称为假它,直到你成功。在第二个时刻,测试用不同的边缘案例来丰富,然后必须用适当的逻辑重写作弊代码。添加其他测试用例称为三角剖分

周期的最后一部分是开发人员负责代码和测试(在不同的时间)并重构它们,直到它们处于所需的状态。最后一个阶段称为重构

因此,TDD咒语是红绿重构

起初,在编写代码之前编写测试感觉很奇怪,我必须承认我花了一段时间才习惯。但是,如果你坚持下去,强迫自己学习这种稍微违反直觉的工作方式,在某个时刻,一些几乎不可思议的事情发生了,你会看到代码的质量以一种不可能的方式提高。

当你在测试前编写代码时,你必须同时考虑代码必须做什么以及它必须如何做。另一方面,当您在编写代码之前编写测试时,您可以在编写测试时将注意力集中在什么部分。当您在之后编写代码时,您将主要考虑代码如何实现测试所需的*。这种注意力的转移可以让你的注意力集中在什么如何部分,在不同的时刻,产生一种脑力提升,这会让你大吃一惊。*

采用这种技术还有其他几个好处:

  • 您将更加自信地重构:如果引入 bug,测试将中断。此外,架构重构还将受益于充当守护者的测试。
  • 代码将更具可读性:这在我们这个时代是至关重要的,因为编码是一项社会活动,每个专业开发人员花在阅读代码上的时间比写代码要多得多。
  • 代码将更加松散耦合,更易于测试和维护:编写测试首先会迫使您更深入地思考代码结构。
  • 编写测试首先需要您更好地理解业务需求:如果您对需求的理解缺乏信息,您会发现编写测试非常具有挑战性,这种情况对您来说是一个哨兵。
  • 对所有内容进行单元测试意味着代码将更易于调试:此外,小型测试非常适合提供替代文档。英语可能会产生误导,但在一个简单的测试中,Python 的五行代码很难被误解。
  • 更高的速度:编写测试和代码比先编写代码,然后再浪费时间调试要快。如果您不编写测试,您可能会更快地交付代码,但随后您将不得不跟踪 bug 并解决它们(而且,请放心,将会有 bug)。编写代码然后进行调试所花费的时间通常比使用 TDD 开发代码所花费的时间要长,在 TDD 中,在编写代码之前要运行测试,以确保代码中的 bug 数量比其他情况下少得多。

另一方面,该技术的主要缺点如下:

  • 整个公司都需要相信它:否则,你将不得不不断与你的老板争论,老板不会理解你为什么要花这么长时间才能完成任务。事实是,在短期内交付可能需要更长的时间,但从长期来看,TDD 会给您带来很多好处。然而,很难看到长期,因为它不像短期那样在我们的眼皮底下。在我的职业生涯中,为了能够使用 TDD 进行编码,我与顽固的老板进行过斗争。有时这是痛苦的,但总是值得的,我从不后悔,因为最终,结果的质量总是得到赞赏。
  • 如果您无法理解业务需求,这将反映在您编写的测试中,因此也会反映在代码中:在您进行 UAT 之前,很难发现此类问题,但您可以做一件事来降低发生这种情况的可能性,那就是与其他开发人员合作。配对不可避免地需要讨论业务需求,讨论将带来澄清,这将有助于编写正确的测试。
  • **糟糕的笔试很难维持:**这是事实。带有太多模拟、额外假设或结构不良数据的测试很快就会成为负担。不要因此而气馁;只要不断尝试并改变编写它们的方式,直到找到一种不需要每次接触代码都要做大量工作的方式。

我对 TDD 非常感兴趣。当我面试一份工作时,我总是问公司是否采纳了它。我鼓励您查看并使用它。使用它,直到你感觉到有东西在你的脑海中点击。你不会后悔的,我保证。

例外情况

尽管我还没有正式向你们介绍它们,但我希望你们至少对什么是例外有一个模糊的概念。在前面的章节中,我们已经看到当迭代器耗尽时,对其调用next会引发StopIteration异常。当我们试图访问位于有效范围之外位置的列表时,我们遇到了IndexError。当我们试图访问一个没有属性的对象上的属性时,我们也遇到了AttributeError,当我们使用一个键和一个字典访问属性时,我们遇到了KeyError

现在是我们讨论例外情况的时候了。

有时,即使一个操作或一段代码是正确的,也有可能出现错误的情况。例如,如果我们将用户输入从string转换为int,用户可能会意外地键入一个字母来代替数字,这使得我们无法将该值转换为数字。在对数字进行除法时,我们可能事先不知道我们是否在尝试用零除法。打开文件时,文件可能丢失或损坏。

在执行过程中检测到错误时,称为异常。例外不一定是致命的;事实上,我们已经看到StopIteration被深入集成到 Python 生成器和迭代器机制中。但是,通常情况下,如果不采取必要的预防措施,异常将导致应用程序中断。有时,这是我们想要的行为,但在其他情况下,我们希望预防和控制此类问题。例如,我们可能会提醒用户他们试图打开的文件已损坏或丢失,以便用户可以修复该文件或提供另一个文件,而无需应用程序因此问题而死亡。让我们来看几个例外的例子:

# exceptions/first.example.py
>>> gen = (n for n in range(2))
>>> next(gen)
0
>>> next(gen)
1
>>> next(gen)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
StopIteration
>>> print(undefined_name)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
NameError: name 'undefined_name' is not defined
>>> mylist = [1, 2, 3]
>>> mylist[5]
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
IndexError: list index out of range
>>> mydict = {'a': 'A', 'b': 'B'}
>>> mydict['c']
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
KeyError: 'c'
>>> 1 / 0
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

正如您所看到的,pythonshell 非常宽容。我们可以看到Traceback,这样我们就有了关于错误的信息,但是程序没有死。这是一种特殊的行为,如果不处理异常,常规程序或脚本通常会死亡。

为了处理异常,Python 提供了try语句。当您输入try子句时,Python 将注意一种或多种不同类型的异常(根据您的指示),如果出现异常,它将允许您做出反应。try语句由打开该语句的try子句、一个或多个except子句(所有可选)组成,这些子句定义捕获异常时要执行的操作;一个else子句(可选),在退出try子句而不引发任何异常时执行;以及一个finally子句(可选),无论其他子句中发生了什么,都会执行其代码。finally子句通常用于清理资源(我们在第 7 章文件和数据持久性中看到了这一点,当时我们在不使用上下文管理器的情况下打开文件)。

注意顺序,这很重要。此外,try后面必须至少有一个except子句或一个finally子句。让我们看一个例子:

# exceptions/try.syntax.py
def try_syntax(numerator, denominator):
    try:
        print(f'In the try block: {numerator}/{denominator}')
        result = numerator / denominator
    except ZeroDivisionError as zde:
        print(zde)
    else:
        print('The result is:', result)
        return result
    finally:
        print('Exiting')

print(try_syntax(12, 4))
print(try_syntax(11, 0))

前面的示例定义了一个简单的try_syntax函数。我们执行两个数的除法。如果我们使用denominator = 0调用函数,我们准备捕获ZeroDivisionError异常。最初,代码进入try块。如果denominator不是0,则计算result并在离开try块后在else块中继续执行。我们打印result并返回。看看输出,您会注意到,在返回函数的退出点result之前,Python 执行finally子句。

denominator0时,情况会发生变化。我们进入except块并打印zdeelse块未执行,因为在try块中引发了异常。在(隐式)返回None之前,我们仍然执行finally块。查看输出,看看它是否对您有意义:

$ python try.syntax.py
In the try block: 12/4     # try
The result is: 3.0         # else
Exiting                    # finally
3.0                        # return within else

In the try block: 11/0     # try
division by zero           # except
Exiting                    # finally
None                       # implicit return end of function

当您执行一个try块时,您可能希望捕获多个异常。例如,当试图解码一个 JSON 对象时,您可能会因为格式错误的 JSON 而导致进入ValueError,或者如果您输入给json.loads()的数据类型不是字符串,则可能导致进入TypeError。在这种情况下,您可以这样构造代码:

# exceptions/json.example.py
import json
json_data = '{}'

try:
    data = json.loads(json_data)
except (ValueError, TypeError) as e:
    print(type(e), e)

此代码将捕获ValueErrorTypeError。尝试将json_data = '{}'更改为json_data = 2json_data = '{{',您将看到不同的输出。

如果您想以不同的方式处理多个异常,只需添加更多的except子句,如下所示:

# exceptions/multiple.except.py
try:
    # some code
except Exception1:
    # react to Exception1
except (Exception2, Exception3):
    # react to Exception2 or Exception3
except Exception4:
    # react to Exception4
...

请记住,异常是在定义该异常类或其任何基的第一个块中处理的。因此,当您像我们刚刚做的那样堆叠多个except子句时,请确保将特定异常放在顶部,将一般异常放在底部。用面向对象的术语来说,孩子在上面,祖父母在下面。此外,请记住,当引发异常时,只执行一个except处理程序。

您还可以编写自定义异常。要做到这一点,您只需从任何其他异常类继承。Python 的内置异常太多,无法在此列出,因此我必须向您指出官方文档。需要知道的一件重要事情是,每个 Python 异常都源自BaseException,但您的自定义异常永远不应该直接从它继承。原因是,处理此类异常也会捕获系统退出异常,例如SystemExitKeyboardInterrupt,这些异常源于BaseException,这可能会导致严重问题。如果发生灾难,您希望能够Ctrl+C退出应用程序。

您可以通过继承Exception来轻松解决问题,该Exception继承自BaseException,但其子项中不包含任何系统退出异常,因为它们是内置异常层次结构中的同级(请参见https://docs.python.org/3/library/exceptions.html#exception-层次结构

使用异常编程可能非常棘手。您可能会无意中消除错误,或捕获不需要处理的异常。为了安全起见,请记住一些准则:始终只在try子句中输入可能导致您要处理的异常的代码。当你写except条款时,要尽可能具体,不要因为简单就诉诸except Exception。使用测试确保您的代码以尽可能少的异常处理量处理边缘情况。编写不指定任何异常的except语句将捕获任何异常,因此,当您从BaseException派生自定义异常时,您的代码将面临相同的风险。

你可以在网上到处找到关于异常的信息。一些编码人员大量使用它们,而另一些则很少使用。通过从别人的源代码中获取示例,找到自己处理这些问题的方法。在 GitHub(等网站上有很多有趣的开源项目 https://github.com 和比特桶(https://bitbucket.org/ )。

在我们讨论分析之前,让我向您展示异常的一种非常规用法,只是为了给您一些帮助,帮助您扩展对异常的看法。它们不仅仅是错误:

# exceptions/for.loop.py
n = 100
found = False
for a in range(n):
    if found: break
    for b in range(n):
        if found: break
        for c in range(n):
            if 42 * a + 17 * b + c == 5096:
                found = True
                print(a, b, c)  # 79 99 95

如果处理数字,前面的代码是一个非常常见的习惯用法。您必须迭代几个嵌套范围,并查找满足条件的abc的特定组合。在这个例子中,条件是一个简单的线性方程,但是想象一下比这个更酷的东西。让我感到困扰的是,在每个循环开始时,必须检查是否找到了解决方案,以便在找到解决方案时,尽可能快地突破它们。突破逻辑干扰了代码的其余部分,我不喜欢它,所以我为此想出了一个不同的解决方案。看一看,看看你是否也能将其应用到其他情况:

# exceptions/for.loop.py
class ExitLoopException(Exception):
    pass

try:
    n = 100
    for a in range(n):
        for b in range(n):
            for c in range(n):
                if 42 * a + 17 * b + c == 5096:
                    raise ExitLoopException(a, b, c)
except ExitLoopException as ele:
    print(ele)  # (79, 99, 95)

你能看到它有多优雅吗?现在,突破逻辑完全由一个简单的异常来处理,该异常的名称甚至暗示了它的用途。一旦发现结果,我们就提出它,并立即将控制权交给处理它的except子句。这是值得深思的。这个例子间接地向您展示了如何引发自己的异常。阅读官方文件,深入了解这个主题的美丽细节。

此外,如果您正面临挑战,您可能希望尝试将最后一个示例制作成嵌套的for循环的上下文管理器。祝你好运

剖析 Python

有几种不同的方法可以评测 Python 应用程序。分析意味着让应用程序运行,同时跟踪几个不同的参数,例如函数的调用次数和在函数中花费的时间。分析可以帮助我们发现应用程序中的瓶颈,这样我们就可以只改进真正让我们慢下来的东西。

如果您查看标准库官方文档中的评测部分,您将看到同一评测接口有两种不同的实现-profilecProfile

  • cProfile建议大多数用户使用,它是一个具有合理开销的 C 扩展,适合评测长时间运行的程序
  • profile是一个纯 Python 模块,其接口被cProfile模仿,但这会给分析程序增加大量开销

此接口执行决定论评测,这意味着监控所有函数调用、函数返回和异常事件,并对这些事件之间的间隔进行精确计时。另一种称为统计分析的方法,对有效的指令指针进行随机采样,并推断时间花费在何处。

后者通常涉及较少的开销,但只提供近似的结果。此外,由于 Python 解释器运行代码的方式,确定性评测不会像人们想象的那样增加太多开销,因此我将从命令行向您展示一个使用cProfile的简单示例。

我们将使用以下代码计算毕达哥拉斯三元组(我知道,您错过了它们…):

# profiling/triples.py
def calc_triples(mx):
    triples = []
    for a in range(1, mx + 1):
        for b in range(a, mx + 1):
            hypotenuse = calc_hypotenuse(a, b)
            if is_int(hypotenuse):
                triples.append((a, b, int(hypotenuse)))
    return triples

def calc_hypotenuse(a, b):
    return (a**2 + b**2) ** .5

def is_int(n):  # n is expected to be a float
    return n.is_integer()

triples = calc_triples(1000)

剧本非常简单;我们用ab迭代区间[1mx(通过设置b >= a避免成对重复),并检查它们是否属于直角三角形。我们用calc_hypotenuse得到abhypotenuse,然后用is_int检查它是否是整数,也就是说(abc是勾股三元组。当我们分析这个脚本时,我们以表格形式获得信息。列为ncallstottimepercallcumtimepercallfilename:lineno(function)。它们表示我们对一个函数调用的数量,我们在其中花费的时间,等等。我将修剪几列以节省空间,因此,如果您自己运行分析,则不必担心是否会得到不同的结果。代码如下:

$ python -m cProfile triples.py
1502538 function calls in 0.704 seconds
Ordered by: standard name

ncalls tottime percall filename:lineno(function)
500500   0.393   0.000 triples.py:17(calc_hypotenuse)
500500   0.096   0.000 triples.py:21(is_int)
 1   0.000   0.000 triples.py:4(<module>)
 1   0.176   0.176 triples.py:4(calc_triples)
 1   0.000   0.000 {built-in method builtins.exec}
 1034   0.000   0.000 {method 'append' of 'list' objects}
 1   0.000   0.000 {method 'disable' of '_lsprof.Profil...
500500   0.038   0.000 {method 'is_integer' of 'float' objects}

即使数据量有限,我们仍然可以推断出有关此代码的一些有用信息。首先,我们可以看到我们选择的算法的时间复杂度随着输入大小的平方而增长。我们进入内环体的次数正好是mx(mx+1)/2。我们使用mx = 1000运行脚本,这意味着我们在内部for循环中得到500500次。在这个循环中发生了三件主要的事情:我们调用calc_hypotenuse,我们调用is_int,如果条件满足,我们将其附加到triples列表中。

看看评测报告,我们注意到算法在calc_hypotenuse中花费了0.393秒,这比在is_int中花费的0.096秒多得多,因为它们被调用的次数相同,所以让我们看看是否可以稍微提高calc_hypotenuse

事实证明,我们可以。正如我在本书前面提到的,**电力运营商非常昂贵,在calc_hypotenuse中,我们使用了三次。幸运的是,我们可以很容易地将其中两个转换为简单的乘法,如下所示:

def calc_hypotenuse(a, b): 
    return (a*a + b*b) ** .5 

这个简单的改变应该会有所改善。如果我们再次运行分析,我们会看到0.393现在下降到0.137。不错!这意味着现在我们在calc_hypotenuse内的时间只有以前的 37%。

让我们看看是否可以通过改变is_int来改进is_int,如下所示:

def is_int(n): 
    return n == int(n) 

这个实现是不同的,优点是当n是整数时它也可以工作。唉,当我们针对它运行评测时,我们看到is_int函数内部花费的时间已经上升到0.135秒,因此,在这种情况下,我们需要恢复到以前的实现。您将在本书的源代码中找到这三个版本。

当然,这个示例并不重要,但足以向您展示如何评测应用程序。对函数执行的调用数量有助于我们更好地理解算法的时间复杂性。例如,你不会相信有多少编码器看不到这两个for循环与输入大小的平方成比例地运行。

值得一提的是:根据您使用的系统,结果可能会有所不同。因此,能够在一个尽可能接近软件部署的系统上(如果不是在该系统上)评测软件是非常重要的。

什么时候进行配置文件?

评测非常酷,但我们需要知道什么时候适合这样做,以及我们需要以什么方式来处理从中得到的结果。

Donald Knuth 曾经说过,【过早优化是万恶之源】,尽管我不会这么彻底地否定它,但我确实同意他的观点。毕竟,我有谁会不同意这个人的观点呢?他给了我们计算机编程的艺术,TeX,还有一些我在大学时学习过的最酷的算法?

所以,首先也是最重要的是:正确性。您希望您的代码提供正确的结果,因此需要编写测试、查找边缘案例,并以您认为有意义的方式强调代码。不要保护自己,不要因为你认为事情不太可能发生,就把事情放在你的后脑勺里等着以后再做。要彻底。

第二,注意编码最佳实践。记住以下可读性、可扩展性、松耦合性、模块化和设计。应用 OOP 原则:封装、抽象、单一责任、打开/关闭等。仔细阅读这些概念。它们将为您打开视野,并扩展您思考代码的方式。

第三,*像野兽一样重构!*童子军规则规定:

"Always leave the campground cleaner than you found it."

将此规则应用于代码。

最后,当所有这些都得到解决时,然后也只有到那时,才需要进行优化和分析。

运行探查器并确定瓶颈。当你对需要解决的瓶颈有了一个想法后,首先从最糟糕的瓶颈开始。有时,修复瓶颈会产生连锁反应,从而扩展并改变其余代码的工作方式。根据代码的设计和实现方式,有时这只是一点点,有时甚至更多。因此,首先从最大的问题开始。

Python 如此流行的原因之一是,可以用多种不同的方式实现它。因此,如果您发现自己在使用纯 Python 增强部分代码时遇到困难,那么没有什么可以阻止您卷起袖子,购买 200 升咖啡,并用 C 语言重写缓慢的代码,这肯定会很有趣!

总结

在本章中,我们探讨了测试、异常和分析的世界。

我试图给你一个相当全面的测试概述,特别是单元测试,这是开发人员通常做的测试。我希望我已经成功地传达了这样一个信息,即测试并不是一个你可以从一本书中学到的完美定义。在你感到舒适之前,你需要做很多实验。在程序员必须在研究和实验方面做出的所有努力中,我认为测试是最重要的。

我们简要地了解了如何防止程序因运行时发生的错误(称为异常)而死亡。为了避开通常的情况,我给了你们一个例子,有点非常规地使用异常来打破嵌套的for循环。这不是唯一的情况,我相信随着你成为一名程序员,你会发现其他的情况。

最后,我们简要介绍了概要分析,并给出了一个简单的示例和一些指导原则。为了完整性起见,我想谈谈评测,这样至少你可以玩转它。

在下一章中,我们将探索秘密、哈希和创建令牌的奇妙世界。

I am aware that I gave you a lot of pointers in this chapter, with no links or directions. I'm afraid this was by choice. As a coder, there won't be a single day at work when you won't have to look something up in a documentation page, in a manual, on a website, and so on. I think it's vital for a coder to be able to search effectively for the information they need, so I hope you'll forgive me for this extra training. After all, it's all for your benefit.