Python-最佳实践高级教程-二-

161 阅读44分钟

Python 最佳实践高级教程(二)

原文:Pro Python Best Practices

协议:CC BY-NC-SA 4.0

七、使用交互式调试器

调试器就像对一个病人做全身扫描。—Zed Shaw,艰难地学习 Pythonwww。learnpythontheha rdway。org

我们现在已经准备好了游戏第一版运行的所有部分。我们只需要结合绘制地图、移动玩家、运行事件循环的功能。因此,我们将导入到目前为止编写的模块,添加一些代码将它们粘在一起,运行程序,并开始游戏!至少理论上是这样的。你认为组件在第一次尝试时会顺利合作吗?在这一章的开始,我不想让你失望,但这不太可能——至少在我的代码中不会。当然,有许多软件在第一次用于生产环境时必须工作(例如,控制飞机、医疗设备、选举等的程序)。).这种软件是使用不同的过程开发的,而不是“让我们从一个 bug 爬到下一个 bug”当编写游戏时,我们可以在问题出现时修复它们。

在第六章中,我们使用自省来检查程序的状态。在这一章中,我们将研究它的动力学,一个程序如何从一个状态转移到下一个状态。通过使用另一个调试工具,交互式调试器,我们将以慢动作观看程序,并选择与之交互。

在我们看到游戏运行之前,我们需要做一些准备。让我们从导入到目前为止我们创建的所有内容开始。因为我们已经有了相当数量的进口,所以我将它们按升序排列:

from load_tiles import load_tiles
from generate_maze import create_maze
from event_loop import event_loop
from draw_maze import draw_grid, parse_grid
from moves import move, LEFT, RIGHT, UP, DOWN
from pygame import Rect
import pygame

我们还需要初始化一个 Pygame 显示,以便处理事件和交互式绘制图形。因为我们稍后将在其他函数中需要显示,所以需要首先执行以下代码:

pygame.init()
pygame.display.set_mode((800,  600))
display = pygame.display.get_surface()

接下来,我们创建一个迷宫,并在对角添加一个玩家(*)和一个出口(x):

maze = parse_grid(create_maze(12, 7))
maze[1][1] = '*'
maze[5][10] = 'x'

最后,我们在游戏开始前显示地图(黑屏会让你的第一步很尴尬)。

tile_img, tiles = load_tiles()
img = draw_grid(maze, tile_img, tiles)
display.blit(img, Rect((0, 0, 384, 224)), Rect((0, 0, 384, 224)))
pygame.display.update()

这是执行代码的好时机。在五章关于 bug 的内容之后,你不应该对程序失败感到太惊讶:

Traceback (most recent call last):
  File "maze_run.py", line 22, in <module>
    img = draw_grid(maze, tile_img, tiles)
  File "/home/krother/projects/maze_run/maze_run/draw_maze.py", line 25, in draw_grid
    img.blit(tile_img, rect, tiles[char])
KeyError: 'x'

当把不同的代码组合成一个程序时,我们不能期望每件事都能马上顺利运行。与此同时,我们的程序变得越来越复杂(现在它应该包含 100 多行代码和至少 10 个函数)。无论潜在的缺陷是什么,我们都可以期望在不同的函数之间跳来跳去(就像我们的代码一样),以便识别缺陷。在这种情况下,科学方法仍然完全适用。但是为了对正在执行的程序进行精确的观察,我们需要一个额外的工具:交互式调试器。

交互式调试器 ipdb

ipdb这样的交互式调试器可以让我们一行一行地跟踪程序的执行,看着它以慢动作执行。在包括 Python 在内的大多数编程语言中,使用交互式调试器来跟踪缺陷是一种基本的最佳实践。交互式调试器允许我们做几件事情:

  • 检查变量的内容。
  • 使用自省功能。
  • 评估常规 Python 指令。
  • 在异常发生之前检查我们程序的状态。
  • 一条指令一条指令地执行我们的代码。我们需要按键来执行下一条指令。
  • 继续或终止我们程序的执行。
  • 设置断点,即代码中正常执行停止并进入交互调试模式的位置。

与我们在第 5 和第六章中用print和自省分析的程序状态相比,交互式调试器的优势在于分析程序执行的动态:一个状态如何转换到下一个状态,以及相应地,缺陷如何传播。在 Python 中,首选的交互式调试器是 ipdb。

安装 ipdb

ipdb 是标准 Python 调试器pdb的扩展版本。像 IPython 一样,它提供了语法高亮和用[TAB]完成名称。Python 异常的回溯提供了更多信息。在第六章第六章中描述的 IPython 中的神奇函数在ipdb中不起作用,但在 Python 的调试器pdb中也不起作用。

使用pip, ipdb安装起来很简单:

pip install ipdb

Note

如果你的安装因为某种原因失败了,你可以使用内置的调试器pdb.来完成本章中的大部分例子,只需用pdb替换ipdb即可。

启动调试器

在使用ipdb的时候,我们有三种可能的开始:程序执行的开始,结束,中间。

从命令行运行 ipdb

我们可以通过将ipdb指定为 Python 解释器的额外模块来开始调试我们的代码:

python -m ipdb maze_run.py

或者,我们可以使用-d选项从 IPython 内部执行程序:

%run -d maze_run.py

在这两种情况下,程序都是从调试器的第一行开始执行的。您应该会看到如下输出:

> /home/krother/Desktop/python_maze_game/maze_run/maze_run.py(1)<module>()

----> 1 from draw map import draw grid, parse grid

      2 from event_loop import event_loop
      3 from generate_maze import create_maze
ipdb>

箭头指示调试器接下来要执行的行(即,第一次导入尚未执行)。在代码摘录之后有一个提示ipdb>,我们可以在这里输入命令。idpb中最重要的命令是 q,它终止调试器。毕竟,maze_run.py的第一行离追溯的位置相当远,所以我们将尝试不同的方式来调用 ipdb。

从程序启动 ipdb

或者,我们可以在程序中的任意位置启动调试器。为此,我们需要在希望启动调试会话的位置插入以下代码片段。例如,我们可以在maze_run.py中引发异常的第 22 行之前调用调试器:

..
tile_img, tiles = load_tiles()

import ipdb; ipdb.set trace()

img = draw_grid(maze, tile_img, tiles)

..

Hint

这是极少数适合在同一行中放置两个 Python 命令的情况之一。“适当的”比如“在这个特殊的聚会上,多喝几杯酒是合适的。”你可能会得出结论,这仍然不是一个好主意。

当我们执行程序时,我们再次看到ipdb提示。这一次,代码中的位置是调用set_trace()的地方。现在我们可以检查变量。查看前面调用img.blit时的KeyError,我们假设tiles字典有问题。我们使用调试会话打印带有pp快捷键的tiles:

ipdb> pp tiles
{' ': <rect(0, 32, 32, 32)>,
'#': <rect(0, 0, 32, 32)>,
'*': <rect(96, 0, 32, 32)>,
'.': <rect(64, 0, 32, 32)>}

我们注意到我们忘记了将出口图块添加到字典中。我们的假设被证实了。

Hint

如果你的程序包含列表、字典或集合(哪个 Python 程序没有?)使用pprint是使屏幕输出更容易阅读的最佳实践。幸运的是,ipdb中的pp快捷方式省去了我们导入模块的麻烦。

我们可以尝试立即修复缺陷。ipdb接受任何 Python 命令,所以我们可以简单地添加缺少的条目。退出磁贴的左上角是位置(32, 32)。之后,我们使用c命令继续执行程序:

ipdb> tiles['x'] = Rect((32, 32, 32, 32))
ipdb> c

添加了缺少的条目后,程序运行得完美无缺。我们看到了水平,至少在窗口关闭前的短暂时刻。修补我们的程序不是一个非常干净或优雅的策略,但有时它是检查假设是否正确的最快方法。让我们从代码中删除对ipdb.set_trace()的调用,以便尝试另一种调试策略。

事后调试

对大多数犯罪现场调查员来说,麻烦在于他们来的太晚了。犯罪结束了,嫌疑人跑了,侦查员需要从各种线索中重构事件。不是这样的:在死后调试中,我们在异常发生的那一刻到达犯罪现场,仿佛时间凝固了。我们可以在程序崩溃前检查它。这是可能的,因为 Python 记住了许多关于上一个异常的信息(可通过sys.exc_info())获得)。当然,Python 解释器必须还在运行。幸运的是,当我们从交互提示符下启动程序时,IPython 会保留 Python 对象,这样我们就可以使用函数ipdb.pm():启动后期调试器

In [1]: %run maze_run.py

    .. Traceback

KeyError: 'x'

In [2]: import ipdb

In [3]: ipdb.pm()

ipdb>

关于这个命令的一个恼人的事情是,如果我们打错了ipdb,我们需要重新开始(Python 只记住一个异常,控制台上任何新创建的错误都会丢弃前一个)。幸运的是,IPython 提供了%debug魔法函数来代替前面的序列:

In [1]: %run maze_run.py

    .. Traceback

KeyError: 'x'

In [2]: %debug

ipdb>

像使用ipdb.set_trace()一样,我们可以检查变量的内容并修改它们。主要区别是不能继续执行(程序已经死了)。按下c终止调试器会话并返回 IPython 提示符。在程序的生命周期中,事后调试是步入调试器的一个令人兴奋的替代方法。

Postmortem Analysis

术语“事后分析”也被用来描述项目后的会议或回顾。这不是这里的意思。我想他们也在停尸房做死后分析,但这和我们在这里做的差远了。

异常时启动调试器

有些情况下,无论何时出现异常,我们都需要默认进入调试器。例如,我们有一个程序,我们需要不惜任何代价保持它的活力(一个生产 web 服务器)。或者我们有不想丢失的数据。或者我们正在现场测试我们的程序,并且很好奇会出现什么样的问题。在这种情况下,我们可以将整个程序包装在一个大的try块中,并在except块中开始ipdb.pm()。它的代码非常难看,不符合 Pythonic 语言,所以我不想在这里展示。在 Python 中实现这一点的正确方法是使用上下文管理器和with

from ipdb import launch_ipdb_on_exception

with launch_ipdb_on_exception():
    # your code

这个表达式平滑地与现有代码集成,易于阅读,我们可以选择程序的哪些部分ipdb应该被检查。启动交互式调试器的所有三种模式(程序开始、执行期间或异常终止后)都是最佳实践。像print一样,有一个假设有助于决定在给定的情况下使用三者中的哪一个,并且通常防止你在代码中到处搜索。

修复缺陷

当然,我们仍然需要在load_tiles.py中的程序中将额外的条目添加到字典中:

TILE_POSITIONS = [
    ('#', 0, 0), # wall
    ('˽', 0, 1), # floor

    ('x', 1, 1), # exit

    (", 2, 0), # dot

    ('*', 3, 0), # player
    ]

通过添加一行代码,我们的程序可以正确地设置图形。我们可以继续添加游戏机制。

调试器提示符下的命令

在我们查看调试器更复杂的功能之前,让我们总结一下到达ipdb提示符后的基本选项:

检查变量

我们可以在ipdb提示符下检查任何变量的内容。这可以通过键入它们的名字(就像在 Python shell 中一样)或使用p name来完成。用ipdb检查变量的主要优点是,我们可以检查程序状态,而不会因为调用print和内省函数而破坏代码。

Tip

单字符变量也有转义:大多数调试器命令都有一些程序员喜欢用作变量名的名称。如果您有这样的名称,您仍然可以通过用感叹号(!idspnonenote)对其进行转义来访问您的变量。):

ipdb> !p = 3
ipdb> p p

评估 Python 表达式

我们可以编写 Python 表达式来改变变量值(例如,检查我们是否通过手动纠正找到了 bug 的原因)。为此,只需输入一个 Python 命令,看看会发生什么。这样,我们可以测试关于什么可以立即修复缺陷的假设。后者不是一直都管用;在修复前面的KeyError时,我们有点幸运。

单步调试我们的代码

为了找到问题所在,我们可以用n在调试器中执行下一行。如果我们需要深入一个函数并从内部观察它,使用s而不是n。有了s,我们基本上可以在我们的代码中爬上整个调用栈(并返回)。用lll我们显示下一个执行的行和它周围的几行。一般来说,调试器命令l, n,s的组合允许我们有效地一步一步导航我们的程序。简而言之,在“示例 ipdb 会话”一节中,我们将查看一个交互式会话的详细示例。

重新开始

一旦我们看够了,我们可以简单地点击c并继续执行到最后(或调试器的下一次调用)。相比之下,当我们断定我们正在运行的程序无法再保存时,q命令会快速终止它,以便我们可以更改它并重试。表 7-1 列出了在ipdb提示符下可用的命令。

表 7-1。

Common Commands in ipdb

| 命令 | 描述 | | --- | --- | | `l (list)` | 在下一个执行的代码周围列出几行代码 | | `ll (long)` | 再列几行 | | `n (next)` | 执行下一行 | | `s (step)` | 执行下一行,如果它包含函数调用,跳到函数的第一行 | | `p (print)` | 显示变量的内容 | | `pp (pretty-print)` | 显示格式良好的复合类型 | | `c (continue)` | 不使用调试器继续执行 | | `R` | 继续到函数的结尾 | | `q (quit)` | 终止程序的执行 | | `u, d` | 在调用堆栈中上下导航(以便可以在那里查看代码) | | `b (break)` | 列出断点 | | `b ` | 添加断点 | | `b ` | 添加断点 | | `b , ` | 添加带条件的断点 | | `b , ` | 添加带条件的断点 | | `cl ` | 移除断点 | | `?` | 列出可用命令 | | `help ` | 显示调试器命令的帮助 | | `[ENTER]` | 重复上一个调试器命令 |

使用断点

断点告诉调试器在特定行停止执行并启动调试器。它们让我们的生活更加舒适,以至于我们不需要一直在代码中插入pdb.set_trace()。我们可以在一个特定函数的开始处设置一个断点(例如,在move()函数中)

ipdb> b moves.move

假设此时moves模块是已知的。或者,我们可以指定文件名和行号:

ipdb> b moves.py:24

为了利用断点,我们以调试模式启动程序(作为一个新的 ipdb 会话)。然后我们按一次c到达第一个断点并开始我们的分析。如果我们已经设置了多个断点,我们可以用c命令从一个断点跳到下一个断点。

断点有助于缩小缺陷的位置。一种可能的搜索策略是在几次迭代中圈出错误代码。在一切都正确的时候设置一个断点,在肯定出错的地方设置第二个断点。中途设置第三个断点,并检查故障是否已经出现在第三个断点处。根据结果,缩小一半或另一半的搜索范围。

查看和删除断点

只需按下b即可列出当前设置的所有断点。该列表包括编号、位置和断点被访问的次数:

ipdb> b
Num Type         Disp Enb   Where
1   breakpoint   keep yes   at ../maze_run/debugging/maze_run.py:35
    breakpoint already hit 2 times
2   breakpoint   keep yes   at  ../maze_run/debugging/moves.py:24
    breakpoint already hit 2 times

如果你想删除一个断点,你可以使用cl命令,使用与创建断点相同的参数。使用表 7-1 中的数字可以更容易地删除大多数时间断点:

cl 2

这将从列表中删除第二个断点。有时候我发现在 ipdb 中手动设置和删除断点有点笨拙。在 PyCharm 中,设置和删除它们需要单击程序代码左侧的栏(见下图 7-2 )。

条件断点

在调试复杂程序时,一个常见的问题是,当程序第一次到达某一行时,不会出现错误。假设在达到我们感兴趣的条件之前,我们有 99 次迭代。手动输入前 99 次会让我们在发现错误前睡着。在这种情况下,我们需要根据程序的状态停止程序的执行。我们可以自己给代码添加一个条件,作为一个if子句。例如,我们可以检查下一个瓷砖是否是一面墙:

if level[newy][newx] == '#':
    ipdb.set_trace()

然而,这不是一个优雅的解决方案。与条件打印一样,这往往会弄乱我们的代码。条件断点是一种更优雅的捷径。我们可以通过在ipdb中设置一个断点来达到同样的目的

b moves:27, level[newy][newx] == '#'

有趣的是,如果我们错误地输入了条件表达式,或者由于其他原因它不起作用,这都不是问题。当 Python 从条件中引发异常时,ipdb假设有理由仔细检查代码,并且断点仍然被触发。

配置 ipdb

我们可以在进入调试器时执行来自.pdbrc文件的命令。ipdb在我们启动 Python 的目录和我们的主目录中查找这个配置文件(后者适用于所有 Python 项目)。或者,当从 Python 命令行运行调试器时,我们可以用选项-c指定一个文件。一个.pdbrc配置文件可能如下所示:

from pprint import pprint
ll
print(dir()

)
b maze_run.handle_key

当进入调试器时,会发生四件事:首先,我们导入漂亮的打印函数pprint()来更容易地检查大型数据结构。其次,我们让ipdb产生代码中当前位置的一段较长的摘录。第三,我们显示当前名称空间的内容。第四,我们设置了一个断点,每当调用maze_run模块中的handle_key函数时就会触发该断点。

如您所见,配置文件可能包含普通 Python 命令和调试器命令的混合。配置的目的是使我们的调试更容易。例如,如果您正在调试一个大型程序,您可能会在那里列出您最喜欢的断点。如果正在调试包含关键数据的系统,可以在调试前自动保存数据。如果想跟踪调试的时间和地点,可以写一个带有时间戳的日志消息等等。大量干扰你的程序或者强烈依赖于所检查的问题类型的命令不应该进入你的.pdbrc。综上所述,一个好的配置可以让你的紧急着陆更加平稳。

ipdb 会话示例

使用调试器的主要好处是单步调试您的代码。也就是说,我们一个命令接一个命令地执行程序,并在执行过程中检查变量,直到我们(希望)发现缺陷发生的地方。我们可以用表 7-1 中的快捷方式控制代码的执行。在这里,我们将从头到尾经历一个ipdb会话。

添加游戏控制功能

在导入所有必要的模块并初始化游戏后,我们需要将箭头键插入我们的move函数。在第四章中,我们发现了事件循环中使用的箭头键的键码(整数 273-276)。我们可以将每个箭头键映射到字典中的一个运动向量:

DIRECTIONS = {
    276: LEFT,
    275: RIGHT,
    273: UP,
    274: DOWN
}

在游戏控制函数中,我们取一个键,得到移动向量,在迷宫中执行移动,然后画出所有的东西。我们通过将该函数作为回调传递到事件循环中来开始游戏:

def handle_key(key):
    """Handles key events in the game"""

    move(maze, DIRECTIONS.get(key))
    img = draw_grid(maze, tile_img, tiles)
    display.blit(img, Rect((0, 0, 384, 224)), Rect((0, 0, 384, 224)))
    pygame.display.update()

event_loop(handle_key)

当我们运行这个程序时,它起初看起来很好。当我们按下箭头键时,图形四处移动并吃掉点,走在出口瓷砖上终止程序。但是如果我们按了不同的键,程序会突然崩溃,并显示一条错误消息:

Traceback (most recent call last):
  File "maze_run.py", line 45, in <module>
    event_loop(handle_key)
  File "/home/krother/projects/maze_run/maze_run/event_loop.py", line 16, in event_loop
    handle_key(event.key)
  File "maze_run.py", line 39, in handle_key
    move(maze, direction)
  File "/home/krother/projects/maze_run/maze_run/moves.py", line 29, in move
    newx = oldx + direction[0]

TypeError

: 'NoneType' object is not subscriptable

单步执行代码

我们将从头开始单步执行代码。让我们从 IPython 启动调试器:

In [2]: %run -d maze_run.py

> /home/krother/Desktop/python_maze_game/maze_run/debugging/maze_run.py(1)<module>()

1---> 1 from draw map import draw grid, parse grid

      2 from event_loop import event_loop
      3 from generate_maze import create_maze
      4 from load_tiles import load_tiles
      5 from moves import move, LEFT, RIGHT, UP, DOWN

调试器让我们从第一行开始。我们现在可以按n来一行一行地浏览。

ipdb> n
> /home/krother/Desktop/python_maze_game/maze_run/debugging/maze_run.py(2)<module>()
1     1 from draw_map import draw_grid, parse_grid

----> 2 from event loop import event loop

      3 from generate_maze import create_maze
      4 from load_tiles import load_tiles
      5 from moves import move, LEFT, RIGHT, UP, DOWN

很快就会发现,这不是一个理想的前进方式。首先,浏览一长串与问题无关的导入和其他代码很无聊。第二,一旦我们到达那里,我们将不得不离开事件循环(你可以自由地尝试发生什么)。幸运的是,我们已经知道错误发生在哪里,所以我们将从回溯的顶层开始调试。为了到达代码中的点,我们在handle_key函数中设置了一个断点:

ipdb> b handle_key
Breakpoint 1 at /home/krother/projects/maze_run/maze_run/maze_run.py:35

对于断点的设置,我们不需要指定模块,因为我们已经在game模块的范围内了。现在,我们可以继续执行:

ipdb> c

您应该看到 Pygame 窗口弹出,调试器会话被挂起。现在,如果您将焦点切换到游戏窗口并按下箭头键,调试器将到达第一个断点。

> /home/krother/projects/maze_run/maze_run/maze_run.py(37)handle_key()
1    35 def handle_key(key):
     36     """Handles key events in the game"""

---> 37 move(maze, DIRECTIONS.get(key))

     38      img = draw_grid(maze, tile_img, tiles)
     39      display.blit(img, Rect((0, 0, 384, 224)), Rect((0, 0, 384, 224)))

我们可以将焦点切换回运行ipdb的终端。请注意,如何切换焦点很重要(如果您使用鼠标,Pygame 不会注意到,但如果您使用键(例如,Alt + Tab),它们会进入 Pygame 事件队列)。我们现在可以检查功能参数key的值:

ipdb> key
275

这是我们知道正在起作用的“安全”价值观之一。我们继续执行并返回游戏窗口:

ipdb> c

这一次,我们按空格键。同样,断点是在同一位置触发的。这一次,我们为key获得了一个不同的值:

..

---> 22 move(maze, DIRECTIONS.get(key))

..
ipdb> key
32

我们现在可以通过按下n来评估handle_key函数中的第一行。然而,我们从回溯中知道,调用move的那一行将会发生错误。我们现在可以用s命令进入函数内部:

ipdb> s
--Call--
> /home/krother/Desktop/python_maze_game/maze_run/debugging/moves.py(22)move()
     20
     21

---> 22 def move(level, direction):

     23      """Handles moves on the level"""

     24      oldx, oldy = get_player_pos(level)

每当在调试会话期间进入一个函数时,列出它的所有参数是一个好主意。一种方便的方法是自省函数locals():

ipdb> locals()
{'level': [['#', '#', '#', '#', '#', '#', '#', '#', '#', '#', '#', '#'], ['#', '*', '.', '.', '.', '.', '.', '.', '.', '.', '.', '#'],['#', '.', '.', '#', '.', '#', '.', '#', '.', '.', '.', '#'],['#', '.', '.', '.', '.', '#', '.', '.', '.', '.', '.', '#'], ['#', '#', '.', '#

如果您决定一个接一个地输入参数名,而不是使用locals(),那么directionNone值将变得更难识别。我们必须使用p命令来实际查看它:

ipdb> direction
ipdb> p direction
None

Hint

如果您想更详细地分析level,那么pp命令会给出更好的输出。

此时,您可能已经假设 None 值导致了异常。克制自己不立即修改代码是值得的。首先,我们已经确定了一个症状,而不是缺陷本身。第二,有多种方法可以修复它。因此,观察缺陷的整个传播过程是值得的。我们将再按几次n,直到出现异常。幸运的是,我们不用走很远:

ipdb> n
..
ipdb> n
..
ipdb> n
TypeError: 'NoneType' object is not subscriptable
> /home/krother/Desktop/python_maze_game/maze_run/debugging/moves.py(25)move()
     23      """Handles moves on the level"""

     24      oldx, oldy = get_player_pos(level)

---> 25 newx = oldx + direction[0]

     26 newy = oldy + direction[1]
     27 if level[newy][newx]
== 'x':

我们现在有了完整的了解:Python 不能对值None应用索引 0。此时,调试器会话对我们不再有用(值oldxoldy与该现象无关)。是时候退出会话了。

ipdb>  q

修复缺陷

显然,我们还没有告诉我们的程序当一个未知的键被按下时该做什么。有不同的方法来缓解这个问题。首先,我们可以给move()添加防御代码:

if direction == None:
    return

这使得函数比必要的更复杂。第二个想法是在访问DIRECTIONS字典时添加一个默认值:

DIRECTIONS.get(key, (0, 0))

这会用一个不移动任何东西的值调用move()。添加零向量(0, 0)作为默认值也不是一个好主意。从某种意义上来说,我们在强迫move()做一些该函数原本不应该做的事情。即使它有效,很可能这个修复会很快发展成一个比我们正在修复的更复杂的缺陷。

修复缺陷的最佳位置就是它第一次出现的地方。我们明确地关注游戏功能中缺失的关键情况:

direction = DIRECTIONS.get(key)
if direction:
    move(maze, direction)

虽然这引入了两行额外的代码,但它是最容易阅读和理解的。

成功了!

完成互动调试环节,修复缺陷后,游戏工作了!庆祝的时间到了。你找到图[中截图的代码 7-1 创建于 https://github.com/krother/maze_run 。在子目录buggy/中,您可以找到包含前六章中所有 bug 的代码版本。如果你在读这本书的时候写了你自己版本的游戏,你自己的 bug 当然就足够了。

A419627_1_En_7_Fig1_HTML.jpg

图 7-1。

Scene from the working game at the end of this chapter

现在的程序没有缺陷吗?

有一条经验法则是,程序中仍然存在的缺陷数量与我们已经发现的缺陷数量成正比。我们知道我们在第三章中发现的几个设计弱点。例如,程序有时会产生一个迷宫,里面有无法到达的位置。可能还有其他我们不知道的。这个程序能在所有运行 Python 的计算机上运行吗?老版本的 Python/Pygame 怎么样?我们不知道。因此,我们的问题的答案显然是否定的,仍然存在缺陷。但是我们不会为他们担心——这个项目现在已经足够好了。如果我们已经知道有问题,调试是有用的。在调试中,我们卷起袖子,弄脏手。

其他调试工具

Python 调试器 pdb

标准的 Python 调试器pdb有一个写得很清楚的文档值得查看: https://docs.python.org/3/library/pdb.html

PyCharm IDE

ipdb 的缺点是需要知道几个键盘命令。如果你喜欢使用像 PyCharm ( https://www.jetbrains.com/pycharm/ ),这样的 IDE,你可以做完全一样的事情,只是 PyCharm 有所有的按钮(见图 7-2 )。您可能会发现使用图形调试器单步调试程序更方便。PyCharm 调试器提供了一些值得一提的有用特性:

  • 您可以通过点击行号旁边的一个按钮来设置和删除断点(图 7-2 中的红点)。
  • 左下方的选项卡允许您切换到当前调用堆栈中其他框架的名称空间。
  • 调试器中底部中间的选项卡(变量)显示了当前名称空间的内容
  • 右下角的选项卡(Watches)允许您在单步执行代码时连续观察表达式的结果。

A419627_1_En_7_Fig2_HTML.jpg

图 7-2。

The interactive debugger in PyCharm provides the same functionality as ipdb through a graphical interface.

ipdbplugin

ipdbplugin是测试框架nose的插件。它允许你在运行自动测试时运行ipdb。在第三部分中,您将看到py.test框架的一个类似选项。https://pypi.python.org/pypi/ipdbplugin.

pudn

Andreas Kloeckner 是一个基于控制台的可视化调试器。它在控制台中创建了一个类似于 IDE 的屏幕布局,因此您可以获得比常规调试器更多的信息。pudb由键盘命令控制,可以让你非常快速地工作(参见 https://pypi.python.org/pypi/pudb ).

走过冬季情感论坛

wdb是一个网页调试器。它可以作为中间件插入到像 Django 或 Flask 这样的 Python web 服务器中,在 web 请求导致错误的情况下,它可以为您提供一个调试器。wdb能够在不同于 web 服务器的机器上运行调试器(参见 https://github.com/Kozea/wdb ).

django-调试-工具栏

Django web 服务器的一个插件,在浏览器页面上显示 web 请求的各种信息(参见 http://django-debug-toolbar.readthedocs.org )。

设定档

cProfile 是一个标准的 Python 模块,可以生成关于代码性能的详细报告。在cProfile的输出中,你可以看到每个函数被调用了多少次,Python 解释器在每个函数上花费了多少时间。如果你需要优化你的代码,它是在你的程序中找到瓶颈的一个很好的工具。见 https://docs.python.org/3/library/profile.html .

最佳实践

  • ipdb是一个 Python 的交互式调试器。
  • 您可以使用 prefer 或从 ipython 内部启动ipdb
  • 您可以在程序开始时、从任意一行或在异常发生后调用ipdb
  • 在调试器提示符下,您有一组命令来控制执行。
  • 您可以在调试器提示符下显示变量并执行普通的 Python 命令。
  • 您可以使用n执行单个指令,或者使用s单步执行功能。
  • 断点在给定位置启动调试器,而不编辑您的代码。
  • ipdb启动时,执行配置文件.pdbrc中的命令。
  • 修复缺陷有很多方法,有好的,也有坏的。
  • 即使你的程序看起来一切正常,里面可能还是有更多的缺陷。

八、编写自动化测试

质量工程师走进一家酒吧。点了杯啤酒。点了 0 瓶啤酒。订购 999999999 啤酒。订购一只蜥蜴。订单-1 瓶啤酒。订购 sfdeljknesv。——比尔·塞姆普夫(@塞姆普夫)在推特上

在本书的第二部分,我们将关注一种强大的编程技术:自动化测试。自动化测试被许多 Python 程序员视为基本的最佳实践。为什么会这样?在这一章中,我们将会看到一个关于什么是自动化测试以及它有什么好处的温和介绍。在关于调试的章节中,我们通过简单地执行程序来检查我们的程序是否工作。我们有时直观地将输出与我们自己的期望进行比较,有时使用准备好的输入/输出值表。然而,这种策略并不能很好地适应我们程序的规模:想象一下我们在游戏中加入了更多的元素(方块、关卡、谜题等的类型)。).每次我们添加一些东西,我们都需要从头到尾玩一遍并检查

  • 新功能是否有效。
  • 程序的其余部分是否仍然有效。

无论我们的游戏有多好,手动测试很快就会变成一项令人生畏的任务。在这里,自动化测试来拯救。测试是一项枯燥、重复的任务,因此我们可以(也应该)将其自动化。什么是自动化测试?基本上,在自动化测试中,我们编写一个程序来测试另一个程序。在某种意义上,我们已经这样做了:在第一章中,我们的每个模块都包含一个__main__块,我们用它来执行每个模块,看看它是否在工作。但是自动化测试有一些不同之处。术语自动化测试通常意味着

  • 测试代码给了我们一个明确的答案:测试通过或者失败。
  • 我们一次测试一小部分代码。
  • 我们使用专门的测试框架来简化测试代码。

在本书中,我们将使用测试框架 py.test 来编写自动化测试。自动化测试就像一个脚手架,随着代码的增长,它将代码保持在适当的位置。让我们考虑一个例子:我们想给我们的迷宫添加可移动的板条箱。有些走廊里有板条箱,我们可以推来推去,但不能拉。这意味着如果一个板条箱被扔在角落里,我们就再也拿不出来了。我们一次只能移动一个板条箱。在我们的示例中,我们必须移动板条箱一次才能到达出口(见图 8-1 )。

A419627_1_En_8_Fig1_HTML.jpg

图 8-1。

The four blue crates around the player shall be pushed. At least one needs to be moved in order to reach the exit.

安装 py.test

我们将使用 py.test 框架测试板条箱移动特性。首先,我们需要安装 py.test:

sudo pip install pytest

为了成功运行测试,py.test 需要能够从maze_run包中导入模块。为此,我们需要添加一个文件__init__.py来导入我们的模块:

from . import load_tiles
from . import moves
from . import draw_maze

我们还需要将PYTHONPATH变量设置为目录。您可以在 bash 控制台上使用

export PYTHONPATH=.

每次启动新的控制台会话时,我们都必须这样做。或者,我们可以在我们的.bashrc文件中添加变量

export  PYTHONPATH=$PYTHONPATH:/home/krother/projects/maze_run/maze_run

当然,你需要把路径调整到自己的maze_run目录。

编写测试函数

接下来,我们在一个名为test_crate.py的文件中创建测试本身。现在,我们将该文件放在与 MazeRun 代码相同的目录中。首先,我们从前面的章节中导入一些对象并创建一个迷宫,使用“o”作为板条箱的符号:

from draw_maze import parse_grid
from moves import move
from moves import LEFT, RIGHT, UP, DOWN

LEVEL = """
#######
#.....#
#..o..#
#.o*o.#
#..o..#
#.....#
#######"""

接下来,我们实现一个名为test_move_crate_right.py的小 Python 函数,它检查我们的代码是否正常工作(如果您在家尝试这样做,您可以选择不同的名称,只要函数名称以test_开头)。然后我们移动玩家一次:

def test_move_crate_right():
    maze = parse_grid(LEVEL)
    move(maze, RIGHT)
    assert maze[3][4] == '*'

最后两行中的assert语句检查玩家符号(' * ')是否向右移动了一个位置。如果我们用常规的 Python 解释器运行这个程序,什么都不会发生,因为在任何地方都不会调用test_move_crate_right函数。让我们看看 py.test 对函数做了什么。

运行测试

我们通过在 Unix 终端中键入来执行测试

py.test

并获得以下输出:

============================= test session starts ==============================
platform linux -- Python 3.4.0, pytest-2.9.2, py-1.4.31, pluggy-0.3.1
rootdir: /home/krother/projects/maze_run/maze_run, inifile:
collected 1 items

test_crate.py .

=========================== 1 passed in 0.06 seconds ===========================

发生了什么事?py.test 报告它收集了一个测试。它自动在我们的测试模块中找到了我们的测试函数(都由前缀test_标识)。然后它执行了test_crate.py中的测试。测试通过,意味着assert语句中的条件评估为True。我们的第一次测试成功了!

编写失败的测试

当然,检查玩家是否移动是不够的。我们需要检查板条箱是否也移动了。为了检查板条箱的新位置,我们向测试函数添加了第二个断言:

assert maze[3][5] == ’o’

当再次运行py.test命令时,它发现了一些要抱怨的东西:

===================================   FAILURES   ===================================
_____________________________________test_crate_____________________________________

     def test_move_crate_right():
         maze = parse_grid(LEVEL)
         move(maze, RIGHT)
         assert maze[3][4] == ’*’

>        assert  maze[3][5]  ==  ’o’

E        assert ’.’ == ’o’
E        - .
E        +  o

test_crate.py:19: AssertionError
=========================== 1 failed in 0.14 seconds ===========================

测试失败。在输出中,我们看到第 17 行的assert是失败的。如果条件失败,assert会引发一个AssertionError,老实说,这个异常本身并没有多大帮助。但是,py.test 劫持了异常,并明确告诉我们比较双方的值是什么。我们看到的不是一个箱子(' o '),而是一个点('.').显然,板条箱没有移动(相反,玩家吃了板条箱)。当然,板条箱没有移动,因为我们还没有实现移动板条箱。如果你想看的话,你可以从测试关卡开始游戏,然后自己啃几个箱子。

通过测试

要移动板条箱并通过测试,我们需要编辑moves.py中的move()功能。我们需要为“o”符号添加一个额外的if条件。让我们将板条箱向右移动一格:

def move(level, direction):
    """Handles moves on the level"""

    oldx, oldy = get_player_pos(level)
    newx = oldx + direction[0]
    newy = oldy + direction[1]
    if level[newy][newx] == ’x’:
        sys.exit(0)
    if level[newy][newx] == ’o’:
        level[newy][newx + 1] = ’o’

    if level[newy][newx] != ’#’:
        level[oldy][oldx] = ’˽’
        level[newy][newx] = ’*’

当我们重新运行测试时,它再次通过。

=========================== 1 passed in 0.06 seconds ===========================

通过与失败的测试

最后一个例子应该引起我们的关注,或者至少是思考。前面的代码只负责向右移动。代码充其量是不完整的。我们知道代码还不能工作,但是测试通过了。最后,我们必须注意自动化测试的一个基本事实:测试并不能证明代码是正确的。

那么,测试有什么好处呢?让我们考虑图 8-2 中的选择:如果代码是正确的,测试通过。如果代码是不正确的(错误的,错误的,不完整的,设计糟糕的,等等)。),两种测试结果都有可能。因此,如果我们观察到我们的测试通过了,这给了我们很少的信息。我们不能决定代码是否正确,因为测试可能不完整。反过来效果更好:每当我们观察到一个失败的测试,我们肯定知道有什么地方出错了。我们仍然不知道是代码错了,还是测试不完整,或者两者都有。但是我们有确凿的证据表明这种情况需要进一步的调查。简而言之:自动化测试证明了缺陷的存在,而不是不存在。知道通过测试并不意味着什么,使得自动化测试有点棘手。帮助我们编写有意义的测试的最佳实践:您可能已经注意到,在前面的章节中,我们甚至在编写移动板条箱的代码之前就已经运行了测试。首先编写一个失败的测试有助于我们证明我们编写的代码有所不同。如果测试从失败切换到通过,我们知道我们的代码比以前更加正确。我们将在第十一章中重温这个想法。

A419627_1_En_8_Fig2_HTML.jpg

图 8-2。

Possible outcomes when testing code. Testing does not prove that code is correct (with incorrect code, both outcomes are possible), but failing tests prove that something is wrong.

编写单独的测试函数

我们能做些什么来把我们的代码更坚定地推向正确的状态呢?答案非常简单:编写更多的测试。您可能会注意到,目前我们只实现了向右移动板条箱。从技术上讲,我们可以用与第一个方向相同的方式实现其他方向。我们将每个方向实现为一个单独的测试函数。四个函数名都以test_开头,这样 py.test 会自动发现它们。为了避免代码重复,我们将对move的调用和断言放在助手函数move_crate中:

def move_crate(direction, plr_pos, crate_pos):
    """Helper function for testing crate moves"""
    maze = parse_grid(LEVEL)
    move(maze, direction)
    assert maze[plr_pos[0]][plr_pos[1]] == ’*’
    assert maze[crate_pos[0]][crate_pos[1]] == ’o’

def test_move_crate_left():
    move_crate(LEFT, (3, 2), (3, 1))

def test_move_crate_right():
    move_crate(RIGHT, (3, 4), (3, 5))

def test_move_crate_up():
    move_crate(UP, (2, 3), (1, 3))

def test_move_crate_down():
    move_crate(DOWN, (4, 3), (5, 3))

使用助手函数来保持测试代码简短是一个最佳实践,因为通常要避免代码重复,测试代码也不例外。此外,自动化测试应该比被测试的代码简单(否则我们可能会调试两个复杂的程序,而不是一个)。但是为什么我们要定义四个单行函数,而不是将所有四个调用组合在一个函数中呢?在这个代码上再次调用py.test给了我们一个答案:

3 failed, 1 passed in 0.10 seconds

只有test_move_crate_right通过,因为在实现move()函数时,我们假设板条箱会一直向右移动,这当然是无稽之谈。py.test 给出的输出不仅告诉我们四个测试中有三个失败了,还精确地告诉我们是哪一个失败了。这给了我们比一个更大的测试函数更多的信息。显然,编写许多小测试是最佳实践。测试结果为我们提供了在move()功能中重写if条件处理箱的信息。更新的功能是

def move(level, direction):
    """Handles moves on the level"""

    oldx, oldy = get_player_pos(level)
    newx = oldx + direction[0]
    newy = oldy + direction[1]
    if level[newy][newx] == ’x’:
        sys.exit(0)
    if level[newy][newx] == ’o’:
        cratex = newx + direction[0]

        cratey = newy + direction[1]

        level[cratey][cratex]  =  ’o’

    if level[newy][newx] != ’#’:
        level[oldy][oldx] = ’˽’
        level[newy][newx] = ’*’

现在四项测试都通过了:

============================= test session starts ==============================
platform linux -- Python 3.4.0, pytest-2.9.2, py-1.4.31, pluggy-0.3.1
rootdir: /home/krother/projects/maze_run/part1_debugging, inifile:
collected 4 items

test_crate.py ....

=========================== 4 passed in 0.07 seconds ===========================

Note

许多程序员认为一个测试函数应该只包含一个断言。这通常是一个好的实践,但是在这一点上,它会不必要地膨胀我们的代码。在第九章中,我们将看到一种更优雅的方式来构建我们的测试并简化我们的代码。

断言提供了有用的输出

assert语句将计算 Python 中任何有效的布尔表达式。这允许我们测试许多不同的情况,其中一些应用于下面的二维列表maze:

def test_assert_examples():
    maze = parse_grid(LEVEL)
    assert len(maze) <= 7                            # comparison operator
    assert 1 < len(maze) < 10                        # range check
    assert maze[0][0] == ’#’ and maze[1][1] == ’.’   # logical operators
    assert maze[0].count(’#’) == 7                   # using methods

如前所述,py.test 将assert的输出从AssertionError变为更有意义的输出。这样做的一个实际方面是,我们可以在assert语句中比较列表、字典和其他复合类型。例如,我们使用仅由三块瓷砖组成的极简迷宫’*o#’编写了另一个测试:

def test_push_crate_to_wall():
    maze = parse_grid("*o#")
    move(maze, RIGHT)
    assert maze[0] == [’*’, ’o’, ’#’]

运行 py.test 时测试失败!显然,我们还没有注意到阻碍板条箱移动的墙壁。py.test 报告两个列表之间不匹配的位置(实际上,三个位置都不匹配,但第一个位置足以使测试失败):

E            assert [’ ’, ’*’, ’o’] == [’*’, ’o’, ’#’]
E              At index 0 diff: ’ ’ != ’*’

类似地,我们可以编写一个测试来确保板条箱也能阻止运动。这一次,我们比较了assert语句中的两个嵌套列表:

def test_push_crate_to_crate():
    maze = parse_grid("*oo")
    move(maze, RIGHT)
    assert maze == [[’*’, ’o’, ’o’]]

对于二维列表,我们还会得到关于不匹配项的精确报告:

E        assert [[’ ’, ’*’, ’o’]] == [[’*’, ’o’, ’o’]]
E          At index 0 diff: [’ ’, ’*’, ’o’] != [’*’, ’o’, ’o’]

在测试大型列表和类似的复合类型时,这种精确的输出是救命稻草。比较预期输出和实际输出可以在一个断言中完成,我们不需要手动比较列表来寻找不匹配。py.test 使用的 assert 语句非常通用。在测试失败的情况下,py.test 经常设法从表达式中创建一个有意义的输出(使用-v选项,它甚至变得更好)。因为我们可以在assert中使用任何类型的布尔表达式,所以我们可以构造实践中需要的大多数测试。

异常测试

有一种情况下assert语句不起作用:如果我们想测试是否出现了异常。让我们考虑一个例子:如果我们调用方向参数设置为None?move()函数会发生什么(我们在第七章中遇到了这种情况)。)假设我们想要创建一个TypeError,并且想要测试move是否会引发异常。测试没有异常产生是很容易的:

move(maze, None)
assert True

但是反过来就不行了。没有语法上正确的方法来编写一个单独的assert语句,用None调用move并在函数内部引发异常时通过。问题是,如果我们想让TypeError在一切正常的情况下发生,那么assert总是会因为那个异常而失败。我们可以用一个笨拙的方法来帮助自己:

try:
     move(maze, None)
except TypeError:
     assert True
else:
     assert False

现在,如果(且仅当)异常没有发生,测试失败。对于这种情况,py.test 提供了一种快捷方式,这是一种推荐的最佳实践。上下文管理器pytest.raises允许我们专门测试异常:

import pytest

def test_move_to_none():
    """direction=None generates an Exception"""

    maze = parse_grid(LEVEL)
    with pytest.raises(TypeError):
        move(maze, None)

为什么我们要针对异常进行测试?这样做有几个很好的理由:

  • 首先,我们不希望我们的代码表现出任何随机行为。如果代码失败,我们希望以我们之前指定的方式失败。pytest.raises函数是一个很好的方法。
  • 其次,它有助于我们控制错误——如果我们知道错误输入的行为方式,错误就不太可能在我们的代码中传播。
  • 第三,错误的来源变得更容易找到。
  • 第四,它帮助我们发现设计缺陷,那些我们以前根本没有想到的东西。

如果测试失败,我们会看到消息’Failed: DID  NOT  RAISE’。然而,它很好地通过了。

边境案件

到目前为止,我们编写的测试表现如何?在八个测试函数中,六个通过,两个失败。用py.test -v我们获得

============================= test session starts ==============================
platform linux -- Python 3.4.0, pytest-2.9.2, py-1.4.31, pluggy-0.3.1
rootdir: /home/krother/projects/maze_run/part1_debugging, inifile:
collected 8 items

test_crate.py::test_move_crate_left PASSED
test_crate.py::test_move_crate_right PASSED
test_crate.py::test_move_crate_up PASSED
test_crate.py::test_move_crate_down PASSED
test_crate.py::test_assert_examples PASSED
test_crate.py::test_push_crate_to_wall FAILED
test_crate.py::test_push_crate_to_crate FAILED
test_crate.py::test_move_to_none PASSED

==================================== FAILURES ==================================
________________________________test_push_crate_to_wall__________________________

    def test_push_crate_to_wall():
        maze = parse_grid("*o#")
        move(maze, RIGHT)
>       assert maze[0] == [’*’, ’o’, ’#’]
E       assert [’ ’, ’*’, ’o’] == [’*’, ’o’, ’#’]
E        At index 0 diff: ’ ’ != ’*’
E        Full  diff:
E        - [’ ’, ’*’, ’o’]
E        + [’*’, ’o’, ’#’]

test_crate.py:51: AssertionError
_______________________________test_push_crate_to_crate_____________________________

    def test_push_crate_to_crate():
        maze = parse_grid("*oo")
        move(maze, RIGHT)
>       assert maze == [[’*’, ’o’, ’o’]]
E       assert [[’ ’, ’*’, ’o’]] == [[’*’, ’o’, ’o’]]
E         At index 0 diff: [’ ’, ’*’, ’o’] != [’*’, ’o’, ’o’]
E         Full  diff:
E         - [[’ ’, ’*’, ’o’]]
E         + [[’*’, ’o’, ’o’]]

test_crate.py:57: AssertionError
=============================== 2 failed, 6 passed in 0.11 seconds =============

如果我们检查两个失败测试中的不匹配列表,我们会注意到板条箱很容易被移动。板条箱可以被推过任何东西,包括墙壁和其他板条箱。把它推得更远会使程序崩溃。我们忽略了几种可能出现的情况。这些被称为边界情况或边缘情况。一般来说,边界案例旨在用尽可能少的测试代码覆盖尽可能多的可能输入。好的边界案例涵盖了各种各样的输入。例如,浮点数的典型边界情况包括典型值和极值,如最大值和最小值、空的和错误的数据或错误的数据类型。在我们的案例中,我们没有明确涉及的边界案例包括

  • 把板条箱推到墙上。
  • 把一个板条箱推进另一个板条箱。
  • 把板条箱推进出口。

为了满足我们的边界情况,我们需要再次更新move()函数:

def move(level, direction):
    """Handles moves on the level"""
    oldx, oldy = get_player_pos(level)
    newx = oldx + direction[0]
    newy = oldy + direction[1]
    if level[newy][newx] == ’x’:
        sys.exit(0)
    if level[newy][newx] == ’o’:
        cratex = newx + direction[0]
        cratey = newy + direction[1]
        if level[cratey][cratex] in ’. ’:
            level[cratey][cratex] = ’o’

            level[newy][newx] = ’ ’

        elif level[cratey][cratex] == ’x’:

            raise NotImplementedError("Crate pushed to exit")

    if level[newy][newx] in ’. ’:

        level[oldy][oldx] = ’˽’
        level[newy][newx] = ’*’

最后一个添加使得我们所有的测试都通过了:

=========================== 7 passed in 0.08 seconds ===========================

我们也可以为其他方向测试相同的边界情况。但是由于我们正在测试的代码不是很长或者很复杂,我们决定我们的七个测试现在已经足够好地覆盖了可能的情况(也见图 8-3 )。

A419627_1_En_8_Fig3_HTML.jpg

图 8-3。

“I admit the chance of finding a seven-tentacled monster below the house was low, but I found it worth testing anyway.”

复杂的边境案件

在前面的例子中,边界情况相对容易识别。我们只是简单地考虑如果某块瓷砖挡住了板条箱会发生什么。通常,边界案例并不容易找到。原因是输入包含很多维度。为了探索这个想法,让我们进行一个思维实验:想象我们将增加使玩家变得超强的能量,这样它就可以移动多个板条箱(见图 8-4 )。假设每个玩家都有一个实力,用浮点数表示,一个玩家一次可以移动到strength crates 的平方根。

A419627_1_En_8_Fig4_HTML.jpg

图 8-4。

When we add the strength of the player as an extra dimension, the border cases become a lot more complex.

这一小小的增加引发了一系列关于板条箱到底是如何移动的问题:

  • 强度值需要多大的数值精度?
  • 实际移动的板条箱的最大数量是多少?
  • 最小数量是多少?有没有可能是玩家太弱了,什么都推不动?
  • 平方根的舍入是如何进行的(向上、向下、数学)?
  • 强度可以是负的吗(这里是虚数)?
  • 实力可以无限大吗?

如果我们想涵盖前面例子中的所有边界情况,我们必须考虑这六种情况中每一种的至少一个代表性强度值。更重要的是,假设我们想要确保加电功能在所有可能的情况下都能正常工作。然后,我们必须将每个有代表性的力量值与我们之前测试的每个游戏情况结合起来。结果将是几十个额外的测试。如果有更多的维度,比如运动方向、上电强度、可能挡路的瓷砖等等,那么通过测试详尽地涵盖所有情况就变得不可行了。幸运的是,推板条箱游戏在实践中并不复杂,但许多现实世界的应用程序却很复杂。令人讨厌的边界情况的范围从数学计算、读取输入文件到各种模式匹配。在我的不止一个编程项目中,维护一个干净的、有代表性的边界测试用例集合(并迫使程序理解它们)是投入工作的主要部分。一般的策略是挑选能够很好地代表我们输入的边界案例。如果我们把程序的可能输入想象成一个多维空间,通过选择边界情况,我们试图用尽可能少的测试和尽可能多的必要测试来定义那个空间的轮廓。

自动化测试的好处

到目前为止,我们已经编写了五个测试,我们有了一个自动化的过程,允许我们(或其他开发人员)检查移动的板条箱是否工作。在程序的未来开发过程中,测试可能失败的原因有几个:

  • 我们给游戏增加了更多的功能,却意外地破坏了箱子的功能。
  • 我们修复了一个缺陷,却意外地破坏了板条箱特征。
  • 我们重命名了move函数,这样我们的测试就不会再找到它了。
  • 我们改变编码墙砖和地砖的字符,这样我们的助手函数move_crate就可以检查无意义的内容。
  • 我们尝试在没有安装 P ygame的计算机上运行测试。

当然,我们简单的测试无法自动找出到底哪里出了问题。但是自动化测试的显著之处在于,在所有这些情况下,py.test 都会产生失败的测试,这告诉我们:“有些地方出错了,请进一步调查问题。”这难道不是件好事吗?

毫无疑问,编写自动化测试会预先产生额外的工作。这项工作有什么回报?还有三个好处值得一提。首先,自动化测试节省了时间。我们开始这一章的前提是手工测试是不可伸缩的。想象一下,每次修改代码时,您都必须手动检查所有的边界情况。你要么花大量时间手工测试,要么跳过一些测试,花更多时间在你的代码上。有了可重复的自动化测试,在运行几次测试之后,投入的努力就有了回报。第二,自动化测试促进了结构良好的代码。值得指出的是,我们可以在不初始化图形或事件循环的情况下完全测试move()函数。代码以易于独立测试的方式编写。如果我们要测试一个单一的大函数,高质量的测试会很复杂,如果不是不可能编写的话。第三,自动化测试是有益的。当你执行你的测试,看到失败的测试变成了通过,这给了程序员一种成就感。通常,这种积极的反馈会让你坚持下去,尤其是在咬穿一段困难的代码时。至少这是我和许多其他 Python 程序员反复遇到的情况。所有这三个好处对于自动化测试的最佳实践都是必不可少的。为了更深入地理解它们,当我们在接下来的章节中看到更多的自动化测试技术时,我们将需要重温它们。

Python 中的其他测试框架

py.test 的一些替代方法值得一提:

单元测试

unittest是 Python 安装的默认测试框架。因为unittest不需要任何安装,所以知道它是如何工作的就好。实际上,unittest提供了 py.test 的一小部分可能性,主要是将测试分组到类中。

鼻子

测试框架 nose 通过自动测试发现和一些其他方便的特性扩展了unittest。nose 和 py.test 是远亲,py.test 是两者中更强大的。我们将描述限制在一个与鼻子相关的链接上: http://nose.readthedocs.io

doctest(测试)

doctest 框架的工作方式完全不同。文档测试是写入 Python 文档字符串的 Python shell 会话。doctests 的伟大之处在于,它使得在文档中包含可测试的代码示例变得很容易。第十七章描述了如何将 doctests 包含在文档中。

编写 main

在一些模块中,您可以在__main__块中找到测试代码。如果一个程序不需要__main__块进行常规执行,我们不妨为模块中的函数编写一些测试。例如,我们可以测试移动的板条箱:

if __name__ == ’ __main__’:
    maze = parse_grid(LEVEL)
    move(maze, direction)
    if maze[3][5] == ’o’:
        print(’OK’)

在非常小的程序中,这是一个可以接受的策略,用来测试不直接执行的模块。一方面,我们会在一段时间内避免使用较重的基础设施。另一方面,随着项目的增长,这种测试很快变得很麻烦。举个例子,你不能用这种方式测试多个模块。一旦代码增长超过 100 行,就有必要切换到“真正的”测试框架。

最佳实践

  • 自动化测试由检查程序是否工作的代码组成。
  • py.test 是一个用 Python 编写自动化测试的框架。
  • 要导入被测模块,需要将它们导入到_init_.py.文件中
  • 断言决定测试是通过还是失败。
  • py.test 提供了断言失败原因的详细信息。
  • 功能pytest.raises允许明确测试故障。
  • 编写许多小的测试函数比一些大的测试能提供更多的信息。
  • 失败的测试提供了最有用的信息。测试证明缺陷的存在,而不是不存在。
  • 首先编写一个失败的测试,然后让它通过是一个更可靠的方法。
  • 测试应涵盖不同的边界情况。
  • 收集和维护边界案例的工作可能相当繁重。
  • 自动化测试节省了时间,促进了代码的可维护性,并且是有益的。

九、组织测试数据

质量工程师走进一家酒吧。在第一杯啤酒上桌之前,迅速点了第二杯啤酒。—@botticus

我们在前一章中的测试很容易编写。每个测试只需要很少的输入,所以测试函数最多只有三四行。但是如果我们想为更复杂的情况编写测试呢?我们需要仔细查看与我们的测试相关的数据。理想情况下,我们希望测试不同的数据,以覆盖尽可能多的边界情况(见图 9-1 )。同时,我们不能通过测试来覆盖程序的每一个可能的输入。如何实现数据驱动测试?在这一章中,我们将遇到有效地组织我们的测试和相应数据的最佳实践。我们将实现需要准备好的示例数据的测试,并从数据和测试中创建一系列测试,以隔离程序内的模块间依赖。我们将从测试在 MazeRun 移动板条箱的几个特定场景开始,我们还没有检查:

  • 把板条箱移到角落。
  • 来回移动板条箱。
  • 移动到不同路径上的相同位置。
  • 搬几个板条箱。

A419627_1_En_9_Fig1_HTML.jpg

图 9-1。

Test data is like collecting diverse objects and checking which of them burn

这种测试场景更加复杂,可能会发生在真实的游戏场景中。因此,它们给了我们一个机会去发现意想不到的错误,这些错误会通过简单的测试而不被注意到。我们的场景有一个共同点,它们由多个步骤组成,需要一些准备工作。

使用夹具

当针对多种情况编写测试时,测试数据通常会在几个测试中重复。在第八章中,我们编写了一个助手函数来准备迷宫并执行断言 s。这里,我们将使用 py.test 提供的快捷方式,它允许我们更灵活地重用我们的测试数据:fixtures。py.test 中的 fixture 是一个为一个或多个测试返回数据的函数。要创建一个 fixture,我们需要将@ pytest.fixture装饰器应用到函数中。测试前会自动调用 fixture。假设我们想要实现我们的第一个测试场景:将一个板条箱移动到一个角落。我们从导入几个模块开始,使用与上一章相同的级别(见图 9-2 )。然后我们实现 fixture 函数:

A419627_1_En_9_Fig2_HTML.jpg

图 9-2。

The level used for several scenario is provided by the level fixture

from .draw_maze import parse_grid
from .moves import move
from .moves import LEFT, RIGHT, UP, DOWN
import pytest

LEVEL = """#######
#.....#
#..o..#
#.o*o.#
#..o..#
#.....#
#######"""

@pytest.fixture
def level():
    """A level with four single crates"""
    return parse_grid(LEVEL)

夹具level由一条线组成。这不是一件坏事。创建我们的测试数据的过程可能会在以后变得容易(例如,如果我们决定以后使用类)。现在,我们可以通过简单地添加level作为附加参数,在测试函数中使用我们的 fixture。要将顶部的板条箱移到角落,我们需要将它向上推,绕过它,然后向左推三次:

def test_move_crate_to_corner(level):
    """Moves top crate to upper left corner"""
    for d in [UP, RIGHT, UP, LEFT, LEFT, LEFT]:
        move(level, d)
    assert level[1][1] == 'o'

当我们用上一章更新的 move 函数调用py.test时,测试通过。注意,我们不必关心告诉我们的测试函数level参数来自哪里。py.test 为我们做了这些!

Reminder

如果运行测试不起作用,在调用py.test.之前尝试export PYTHONPATH =.

只有当我们不止一次使用时,创建夹具才变得有用。对于我们的第二个场景,我们将来回移动一个板条箱。这是一个简单的健全性检查。好的测试通常包括一个循环或重复的操作,例如读一个文件,写一个文件,然后再读一次,以确保数据在过程中不会改变。来回移动板条箱也是为了同样的目的。即使移动一次板条箱工作正常,第二次也可能会卡住。这次我们将在测试中使用一个assert来比较两个列表

def test_move_crate_back_forth(level):
    """Sanity check: move the top crate twice"""

    for d in [LEFT, UP, RIGHT, UP, RIGHT, RIGHT, DOWN, LEFT, LEFT, LEFT]:
        move(level, d)
    assert level[2] == list('#o*   #')

两项测试都通过了。这里我们可能会注意到一个关于夹具的重要事实:两个测试都移动顶部的板条箱。更重要的是,我们的第一次测试把板条箱推到了一个它应该永远呆在那里的角落。尽管如此,我们的第二次测试通过了。这是可能的,因为每次测试前都会重新创建夹具。py.test 负责在每次测试前通过再次调用level将板条箱重置到其起始位置。

范围参数

有时创建夹具会花费很多时间(例如,读取一个巨大的文件、创建一个数据库、启动 web 服务等。).如果许多测试使用如此昂贵的夹具,测试执行将会变慢。如果数据没有改变,我们可以通过设置scope参数来指示 py.test 只为整个测试模块初始化一次夹具。我们将fixture装饰器替换为

@pytest.fixture(scope='module')

请注意,在我们的示例中,第二次执行的测试将会失败,因为夹具中的顶部板条箱已经在其他地方了。相互干扰的测试很难调试,这就是为什么我们将 scope 参数保留为默认值(函数级别)。

测试参数化

另一个典型的情况是,我们想要编写许多类似的测试。假设我们想要检查另一个测试场景:移动到不同路径上的相同位置。假设我们有以下路径通向level夹具中的位置(2, 2):

PATHS = [
    (UP, LEFT),
    (LEFT, UP),
    (RIGHT, UP, LEFT, LEFT),
    (DOWN, DOWN),  # wrong on purpose
]

为了避免代码重复,我们可以使用for循环来测试这些路径:

def test_paths():
    for path in PATHS:
        level = parse_grid(LEVEL)
        for direction in path:
            move(level, direction)
        assert level[2][2] == '*'

然而,这种方法有严重的缺点:

  • 我们不能使用我们的 fixture level,因为我们需要在测试中多次重新初始化它。
  • 只要一条路径失败,整个测试就会失败。我们不知道有多少条路径出现故障。
  • 如果一条路径失败,其余的路径将不会被执行。我们不会知道他们是否会失败。

一个更好的选择是从我们的数据中自动生成测试。在 py.test 中,这种策略被称为测试参数化。我们编写一个测试函数,并指示 py.test 用不同的参数调用这个测试函数。每个调用将导致一个单一的测试。为了使用测试参数化,我们应用了@ pytest.mark.parametrize装饰器:

@pytest.mark.parametrize('path', PATHS)
def test_paths(path, level):
    """Different paths lead to the same spot"""
    for direction in path:
        move(level, direction)
    assert level[2][2] == '*'

当我们运行测试时,我们会看到四个新的测试。PATHS中的每个条目产生一个单独的测试函数。py.test 给每个测试分配编号,这样我们就可以更容易地识别它们。事实上,第四个测试失败了,因为(DOWN, DOWN)不是左上角的有效路径。在输出中,我们看到test_paths[path3]正在失败(索引照常从 0 开始):

test_data.py .....F

================================ FAILURES =================================
_____________________________test_paths[path3]_____________________________

path = ((0, 1), (0, 1))
level = [['#', '#', '#', '#', '#', '#', ...], ['#', '.', '.', '.', '.', '.',
...], ['#', '.', '.', 'o', '.', '.', ...], ['#', '.', 'o', ' ', 'o', '.',
...], ['#', '.', '.', '*', '.', '.', ...], ['#', '.', '.', 'o', '.', '.',
...], ...]

    @pytest.mark.parametrize('path', PATHS)
    def test_paths(path, level):
        """Different paths lead to the same spot"""

        for direction in path:
            move(level, direction)
>       assert level[2][2] ==  '*'
E       assert '.' == '*'
E         - .
E         +  *

test_data.py:50:  AssertionError
=================== 1 failed, 5 passed in 0.09 seconds ====================

如果我们预期一个测试会失败,我们可以在定义PATHS:时使用pytest.mark.xfail函数将测试标记为“预期失败”

PATHS = [
    (UP, LEFT),
    (LEFT, UP),
    (RIGHT, UP, LEFT, LEFT),
    pytest.mark.xfail((DOWN, DOWN))
]

py.test 将在输出中用'x'表示该测试。最佳实践是将xfail作为一个占位符来标记需要在以后修复的测试。

多参数

通过测试参数化,我们可以用一个装饰器为一个测试函数提供多个参数。这种模式通常用于向测试提供输入数据和相应的预期输出。这里我们只举一个简短的例子。我们的输入应该是一个运动路径,我们的预期输出是玩家的最终 x/y 位置:

PATH_PLAYERPOS = [
    ((LEFT,), 2, 3),
    ((LEFT, RIGHT), 3, 3),
    ((RIGHT, RIGHT), 4, 3),
]

@pytest.mark.parametrize('path, expected_x, expected_y', PATH_PLAYERPOS)
def test_move_player(level, path, expected_x, expected_y):
    """Player position changes correctly"""

    for direction in path:
        move(level, direction)
    assert level[expected_y][expected_x] == '*'

'@pytest.mark.parametrize'装饰器中,参数名以字符串的形式给出。py.test 自动将它们分配给我们的测试函数的参数,这给了我们三个额外的通过测试。

参数化夹具

测试参数化是使测试代码更短更简单的好策略。参数化夹具是将@pytest.mark.parametrize装饰器应用于测试的一种替代方法。例如,我们可以检查关卡中的点是否有任何不同。如果我们定义一个相同的水平,但没有点,我们会期望相同的结果。要创建级别,字符串替换就足够了:

LEVEL_NO_DOTS = LEVEL.replace('.', ' ')

我们不想复制到目前为止编写的所有测试函数,而是想告诉 py.test 将所有东西运行两次:一次在关卡中有圆点,一次没有。创建参数化夹具类似于测试参数化。这一次,我们需要将测试数据作为params参数添加到@pytest.fixture装饰器中,并在request.params.中使用它。我们通过替换level夹具来实现这一点:

@pytest.fixture(params=[LEVEL, LEVEL_NO_DOTS])
def level(request):
    """A level with four single crates"""
    return parse_grid(request.param)

使用level夹具的每个测试都在两个级别中的每一个级别上执行一次。因此,我们有效地测试了两倍多的情况!通过编写四个测试函数,我们已经涵盖了 16 种不同的情况。这应该使我们的板条箱功能充分测试。

Hint

测试夹具和测试参数化在 py.test 中配合得非常好。我们可以在单个测试函数中结合多个参数化夹具和测试参数化。这样,您可以用很少的代码轻松地生成数百个测试,因为 py.test 会在生成测试时遍历所有的参数组合。在使用参数化测试时,要注意执行时间!

嘲弄的

在这一节中,我们将关注一个更具挑战性的测试环境:我们如何测试连接到其他组件的程序的一部分?如果这些组件在测试中做了我们不希望发生的事情怎么办?假设我们想测试一个向打印模块发送文档的函数。这是否意味着每次运行测试时,我们都必须打开打印机,插入纸张,并观察打印的页面?更糟糕的是,如果外部组件正在等待用户输入呢?幸运的是,Python 为这种场景提供了一个优雅的解决方案:模仿。

模拟是在我们的程序中替代真实对象的假对象。Mock 模拟被替换对象的行为,但实际上不做任何事情。使用 Mock,我们可以将正在测试的组件与其依赖项隔离开来,并检查 Mock 是否被调用。由于 Python 中的动态类型系统,模仿几乎可以插入到任何地方。模块unittest .mock给了我们一个创建模拟的舒适界面。

假设我们想在 MazeRun 中测试图形。详细测试图形(捕获图像并分析其内容)会导致惊人的开销。如果我们假设 Pygame 工作正常的话,大部分甚至是不必要的。我们需要知道的是是否已经对 Pygame 函数进行了相应的调用。下面我们通过两个unittest.mock的例子来说明它的用法。为了简单起见,我们想要测试draw,一个绘制单个图块的短函数:

from pygame import image, Rect
import pygame

pygame.init()
pygame.display.set_mode((80, 60))

def draw(surface):
    img = image.load('img/tiles.xpm')
    surface.blit(img, Rect((0, 0, 32, 32)), Rect((0, 0, 32, 32)))
    pygame.display.update()

首先,我们将测试 draw 函数是否真的在调用pygame.display.update来更新屏幕。为此,我们用一个 Mock 替换了pygame.display.update@mock.patch装潢师会处理这些:

from unittest import mock

@mock.patch('pygame.display.update')
def test_mocking(mock_update):
    display = pygame.display.get_surface()
    draw(display)
    assert mock_update.called is True
    assert mock_update.call_count == 1

装饰器自动将创建的模拟交给我们的测试函数。我们可以给它取任何我们喜欢的名字(这里,mock_update)。这两个assert语句使用了 Mock 的两个属性,都验证了我们的 Mock 函数已经被调用。

对于第二个测试,我们想看看是否在display对象上画了什么。为此,我们从MagicMock类中创建一个模拟对象来代替display:

def test_blit():
    mock_disp = mock.MagicMock(name='display')
    draw(mock_dist)
    assert mock_disp.blit.called is True

两项测试都通过了。MagicMock对象如何知道自己有一个blit方法?MagicMock类的一个实用属性是,它对所有事情都说“是”,并在需要时为自己创建新属性。这个属性使得模仿有点危险,因为我们可能会意外地创建通过测试。

def test_bad_mocks():

  mo = mock.MagicMock()
  assert mo.twenty_blue_dolphins()
  assert mo.foo.bar('spam')['eggs']
  assert mo.was_called()  # wrong method that passes
  assert mo.caled         # typo that passes!

通常,模仿是替换外部组件的最佳实践:网络操作、时钟、随机数、输入/输出流等等。我们可以模仿os.environ在测试期间创建假的环境变量。我们可以模仿time.sleep来减少延迟并加速我们的测试。我们可以模仿urllib来创建一个总是有效的网络连接。模块unittest.mock提供了许多有趣的选项,值得一读。详见 https://docs.python.org/3/library/unittest.mock.html

测试输出文件

在许多应用程序中,我们需要测试程序产生的输出文件是否正确。涉及文件的测试通常会带来至少四个挑战:

  • 我们需要先读取文件(作为字符串或使用支持库),然后才能解释其内容。
  • 通常有很多信息需要我们解析、构造、过滤等等。
  • 读取文件的测试可能在许多方面失败(错误的内容、错误的路径、缺少写权限)。因此,测试结果更难解释。
  • 如果我们的测试创建了一个文件,我们需要在之后清理它。否则,下一次测试可能会通过,因为文件仍然在那里,即使我们在此期间破解了代码。

在这些挑战中,最后一个是最危险的,因为它可能会掩盖我们程序中的缺陷。我们将首先处理这个问题。为了避免测试留下文件的问题,我们可以尝试用os.remove自己清理输出文件:

import os

def test_file_output():
    open('output.txt', 'w').write("Hello  World!")  # the code being tested
    assert os.path.exists('output.txt')
    os.remove('output.txt')

只要代码工作正常或因异常而终止,这就很好。该测试中的潜在缺陷发生在以下事件序列中:

  1. 被测试的代码创建文件,但是随后崩溃并出现异常。测试失败,但输出文件在那里。
  2. 在下一次测试运行之前,我们试图修复代码,但意外的是它什么也做不了。
  3. 在下一次测试运行中,代码什么也不做,但是测试仍然会找到该文件。测试通过。

更好(更安全)的替代方法是在测试前删除输出文件。但是接下来,我们会用输出文件来填充我们的磁盘。您可能已经猜到,对于这种情况,有一些很好的捷径。

测试后清理

py.test 提供了一种测试后清理的机制。功能teardown_functionteardown_module在测试(或模块中的所有测试)完成后执行。测试前运行的相应函数分别是setup_functionsetup_module。为了在测试完成后删除输出文件,我们需要编写

import os

def teardown_function(function):
    if os.path.exists('output.txt'):
        os.remove('output.txt')

def test_file_output():
    open('output.txt',  'w').write("Hello  World!")  # the code being tested
    assert os.path.exists('output.txt')

这种方法的主要优点是,即使测试因异常而终止,也会在每次测试后自动调用 teardown 函数。将清理代码从我们的测试中分离出来是一个最佳实践。

Hint

如果您对清理单个夹具感兴趣,请查看屈服夹具的 py.test 文档: http://doc.pytest.org/en/latest/fixture.html#fixture-finalization-executing-teardown-code

使用临时文件

另一种可能是使用临时文件。Python 标准库中的tempfile模块允许我们创建临时文件对象,这些对象随后会自动删除:

import tempfile

def test_file_output():
    with tempfile.TemporaryFile('w') as f:
        f.write("Hello World!")  # the code being tested

在这种情况下,我们的测试不需要 teardown 函数。TemporaryFile对象要求我们的代码接受文件对象作为输入。如果我们需要一个物理文件(例如文件名),在 py.test 中使用tmpdir fixture 是一个更好的主意:

def test_file_output_with_tempdir(tmpdir):
    tempf = tmpdir.join('output.txt')
    tempf.write("Hello  World!") # the code being tested
    content = tempf.read()
    assert content == "Hello World!"

tmpdir fixture 的一个有用的方面是测试结束后不会自动删除临时文件。在 Unix 上,它们存储在/tmp目录中,在那里它们不太可能干扰我们程序的其余部分。如果测试失败,将显示确切的路径。这使得tmpdir成为存储输出以供以后诊断的好地方。详情请查阅 py.test 文档: http://docs.pytest.org/en/latest/tmpdir.html

将输出文件与测试数据进行比较

当我们生成输出文件(计算结果、报告、日志文件)时,有许多细节需要测试。手动创建一个正确的样本文件,并将样本与实际输出进行比较,通常比解析文件和为每个细节编写测试更容易。Python 标准库提供了两个模块来帮助我们:

filecmp 模块

filecmp.cmp函数接受两个文件对象并返回TrueFalse。假设我们有一个文件'output.txt'包含测试结果,另一个文件'expected.txt'包含手工准备的样本,我们可以用

import filecmp

def test_compare_files:
    open('output.txt', 'w').write("Hello World!")  # the code being tested
    assert filecmp.cmp('output.txt', 'expected.txt')

这种方法的缺点是,如果文件不相等,我们将看不到它们有什么不同。为此,我们需要第二个模块。

difflib 模块

difflib模块逐行比较两个文件,并产生类似于 Unix diff命令的输出。函数difflib.ndiff接受两个字符串列表。difflib.ndiff函数的输出对于测试断言不是很有用,因为如果测试失败,py.test 不会显示完整的输出。但是我们可以扩展前面的测试来打印关于文件不匹配的信息:

def test_compare_files():
    open('output.txt', 'w').write("***Hello World***") # the code being tested
    lines_result = open('output.txt').readlines()
    lines_expected = open('expected.txt').readlines()
    print('\n'.join(difflib.ndiff(lines_result, lines_expected)))
    assert filecmp.cmp('output.txt', 'expected.txt')

如果测试失败,py.test 只显示标准输出(和差异)。这就是为什么这个例子包含了一些额外的星号。我们获得了单个字符的不匹配:

-------------------------- Captured  stdout  call ---------------------------
- ***Hello World***
? ---           ˆˆˆ

+  Hello  World!
?              ˆ

涉及大文件的测试的最佳实践

在测试大型数据文件时,发现差异并不是唯一的问题。此外,首先设计测试是具有挑战性的。我们应该测试什么?我们需要多少测试?对于一个大的输出文件,很明显我们不能测试所有的东西。在下面的列表中,您会发现一些涵盖多种情况的典型测试:

  1. 首先测试文件是否在那里。
  2. 测试文件是否不为空。
  3. 读取文件并检查它是否有正确的条目数。
  4. 将文件与样本文件进行匹配。
  5. 检查一些典型的细节,不是全部。
  6. 在将输出数据写入文件之前,对其进行测试。通常写作本身是琐碎的,或者可以委托给像csvjson这样的支持库。

生成随机测试数据

许多测试需要包含姓名、电子邮件地址或简单文本的样本数据记录。我们自己创造几个虚拟人很有趣,但创造一整本电话簿就不好玩了。faker 包帮助我们为许多常见和不太常见的字段生成随机数据。我们可以安装 faker

pip install faker

这个包为我们提供了一个Faker对象,它的方法随机生成数据:

In [1]: import faker
In [2]: f = faker.Faker()

In [3]: f.date()
Out[3]: '1979-12-17'

In [4]: f.name()
Out[4]: u'Sonji Moore'

In [5]: f.sentence()
Out[5]: u'Unde ea quidem asperiores voluptate eos adipisci sed.'

生成的数据涵盖了许多边界情况。例如,f.name()经常抛出 PhDs、royalty 和其他前缀和后缀,如果考虑得太晚,会让开发者头疼。faker 中有很多方法可以生成随机测试数据。值得看一下dir(f)的输出。

在哪里存储测试数据?

到目前为止,我们将大部分测试数据存储在带有测试函数的 Python 文件中。这是一个公认的最佳实践,即使在非常大的测试集中也能找到(例如,在 Python 标准库的测试中)。但是,有一些替代方案值得一提。在这里,我们将简要地看一下它们。

测试数据模块

将测试数据(例如,所有夹具)分组到单独的模块中。这种方法的优点是多个测试模块可以访问相同的数据。

测试数据目录

如果您的测试需要文件(输入好的/坏的/奇异的例子,样本输出),那么将它们分组在一个单独的目录中是值得的。这个集合可能会在一段时间后发展出自己的生命。如果您存储了您或您的用户发现的令人讨厌的例子,测试数据就会成为项目集体记忆中有价值的一部分。

测试数据库

当您的程序使用数据库时,您可能需要一个专用的测试数据库。实践中经常发现两种方法。首先,您可以在测试中创建数据库。每次开始测试时,fixture 都会创建一个新的数据库,用数据填充它,然后销毁数据库。这种方法的缺点是从头开始创建数据库通常很慢。哪怕延迟只有几秒钟,这都会拖累你的开发速度。其次,您可以维护一个专用的测试数据库。您永久地保留一个数据库用于测试。这种方法往往更快,并且它给你一个地方来存储你的测试例子。这种方法的缺点是维护数据一致性更加困难:总是存在代码意外更改数据并破坏测试数据库的风险。有一些策略可以降低风险(例如,只读访问或使用事务),但是一般来说,您需要关注如何管理数据库。

没有完美的解决方案,维护一个非常大的测试数据集本身就是一个有趣的项目。

最佳实践

  • Fixtures 是为测试函数准备数据的函数。
  • 参数化测试从数据条目列表中创建多个测试。
  • 模仿使用用unittest.mock模块生成的假对象。
  • 用模拟代替外部对象,测试代码的依赖性变得更简单。
  • py.test 中的清理函数在测试完成后被调用。
  • tmpfiletmpdir fixture 创建的临时文件是测试生成的输出文件的好地方。
  • filecmp 和 difflib 模块有助于比较文件。
  • faker 库创建随机数据用于测试。
  • 有了多方面的投入,就不可能详尽无遗地涵盖所有的边界情况。一个粗略的指导方针是写尽可能多的测试和尽可能少的测试。

十、编写测试套件

如果你不关心质量,你就无法满足任何其他要求。—杰拉尔德·m·温伯格

到目前为止,我们已经为我们的游戏创建了一套测试函数。我们可以从 Linux 命令行运行所有测试,只需输入'py.test'或'py.test filename.py'。如果我们正在测试一个更大的程序,测试的数量将很容易增长到数百个。对于参数化测试,甚至成千上万的测试都是常见的。我们如何组织如此大量的测试,并且仍然有效地使用它们呢?

为了更好地组织我们的测试,我们将在本章中创建一个测试套件。测试套件是伴随给定程序的一组完整的结构化测试。要为 MazeRun 编写一个测试套件,我们将使用我们已经编写的测试。我们将把我们的测试组织成模块、类和包。我们将重构测试代码,使其更加简洁。我们还将以不同的方式执行测试,这样我们就不需要每次都运行整个测试套件。最后,我们将计算测试覆盖率,以衡量我们的代码有多少已经被测试。一个好的测试套件是一个强大的诊断工具,可以识别程序中的各种问题。测试套件可以测试基本功能是否工作(单元测试),软件系统的组件是否一起工作(集成测试),或者用户要求的功能是否存在(验收测试)——下一章将详细介绍这三个方面。我们通过将每个测试放在一个单独的函数中来编写我们的第一个测试。我们有意将测试函数存储在单独的模块中。在最后两章之后,py.test 的输出如下所示:

=============================== test session starts ===============================
platform linux -- Python 3.4.0, pytest-2.9.2, py-1.4.31, pluggy-0.3.1
rootdir: /home/krother/projects/maze_run/tests, inifile:
plugins: cov-2.3.0
collected 33 items

test_crate.py ........
test_data.py .......x...x......
test_files.py ....
test_mock.py ...
====================== 31 passed, 2 xfailed in 0.30 seconds =======================

随着测试函数数量的增长,我们需要更加关注整体结构。为了改进结构,我们将清理或重构我们的测试套件。与普通代码类似,我们将把函数分组到合理的高阶结构中。我们要做的第一件事是改进测试模块的结构。然后我们将把我们的测试分组。

测试模块

通过许多测试,测试模块提供了一种构造测试套件的简单方法。我们可以将测试分成多个模块。从技术上讲,这很容易做到:我们只需将相应的测试复制到一个新的 Python 文件中。如果这些模块的名称像前面一样带有test,py.test 中的自动发现将相应地收集所有测试模块。通常,我们不需要显式地导入我们的测试模块。然而,在组织测试模块时,需要记住一些最佳实践:

  • 首先,测试模块的名字应该和被测试的模块一样。按照惯例,如果我们正在测试maze_run/moves.py模块,相应的测试模块应该是test_moves.py。这使得为一段给定的代码寻找测试代码变得更加容易,反之亦然。
  • 第二,助手函数和测试数据应该保存在不同的模块中。在我们的测试目录中有一个额外的非测试模块(例如util.py or fixtures.py),测试模块可以在那里导入它们,这有助于避免测试套件中的冗余。
  • 第三,所有的测试模块应该在同一个地方。

我们可以对模块的结构进行一些改进。重组我们的测试模块主要包括将相似的测试组合在一起,并给每个模块起一个描述性的名字。首先,我们将我们的 fixtures 移动到一个单独的模块中,fixtures.py,其他测试可以从这个模块中导入它们。夹具模块包含我们的参数化夹具level:

import pytest
from maze_run.draw_maze import parse_grid

LEVEL = """#######
#.....#
#..o..#
#.o*o.#
#..o..#
#.....#
#######"""

LEVEL_NO_DOTS = LEVEL.replace('.', ' ')

@pytest.fixture

(params=[LEVEL, LEVEL_NO_DOTS])
def level(request):
    """A level with four single crates"""
    return parse_grid(request.param)

接下来,我们从第章第八章和第章第九章中发现移动玩家形象和箱子的测试覆盖了非常相似的功能。我们把它们合并成一个模块,test_moves.py。我们将图形(带有嘲讽)和文件输出的测试留在原处,只将文件分别重命名为test_graphics.pytest_file_output.py。因此,我们有三个模块和一个助手模块。

Best Practice

在我们重新组织代码之后继续之前,有必要检查一下是否所有的测试都通过了。

测试等级 es

使用 py.test,创建一个测试类并不需要做很多事情。创建测试类的简单方法如下:

  1. 收集几个属于一起的测试函数。
  2. 将函数缩进一级。
  3. 在上面写一个class语句。
  4. 给类起一个以Test开头的名字,这样 py.test 就知道里面有测试。
  5. 添加一个 docstring 来描述测试类的好处。
  6. 给每个函数添加self参数。

让我们在模块test_moves.py中清理移动玩家和箱子的测试。当合并我们以前的代码时,该模块包含以下函数:

move_crate(direction, plr_pos, crate_pos)
test_move_crate_left()
test_move_crate_right()
test_move_crate_up()
test_move_crate_down()
test_assert_examples()
test_push_crate_to_wall()
test_push_crate_to_crate()
test_move_to_none()
test_move_crate_to_corner(level)
test_move_crate_back_forth(level)
test_paths(path, level)
test_move_player(level, path, expected_x, expected_y)

虽然您可能注意到代码本身可以改进,但是我们将首先把测试分组到类中。查看代码,可能会发现两个潜在的类。第一个,TestCrateMoves,用于与板条箱相关的一切,第二个,TestPlayerMoves,用于剩余的测试。功能test_assert_examples与模块中的任何功能无关。我们决定删除它,因为它在 MazeRun 没有任何用途。将self添加到每个方法后,模块的结果结构如下:

def move_crate(direction, plr_pos, crate_pos):

class TestCrateMoves:
    def test_move_crate_left(self)
    def test_move_crate_right(self)
    def test_move_crate_up(self)
    def test_move_crate_down(self)
    def test_push_crate_to_wall(self)
    def test_push_crate_to_crate(self)
    def test_move_crate_to_corner(self, level) def test_move_crate_back_forth(self, level)

class TestCrateMoves:
    def test_move_to_none(self)
    def test_paths(self, path, level)
    def test_move_player(self, level, path, expected_x, expected_y)

测试类的行为类似于普通的 Python 类。我们不需要写一个构造函数(_init_)或者实例化这个类:py.test 会为我们处理这些。用[NEWLINE]from fixtures import level, LEVEL[NEWLINE]使测试通过。

Hint

如果你熟悉标准 Python 库的测试框架unittest,你可能会期望我们创建一个unittest.TestCase的子类。当使用 py.test 时,您仍然可以做到这一点,并且来自unittest的所有断言方法都以同样的方式工作。这里我们将使用 py.test 断言实现所有测试(这与unittest风格的类不兼容)。生成的代码更短,更容易阅读。

重构测试函数

现在我们转向改进代码本身。我们将借此机会执行一些小的重构。我们要关注的第一个施工现场是四个测试test_move_crate_lefttest_move_crate_right, and so on。他们都使用LEVEL数据,而不是水平夹具。事实上,我们可以应用第九章中描述的测试参数化。作为奖励,我们也去掉了助手函数move_crate。我们不会改变其他四个测试的实现。代码与第八章中的相同。重构后的测试类如下所示:

from maze_run.draw_maze import parse_grid
from maze_run.moves import move
from maze_run.moves import LEFT, RIGHT, UP, DOWN
from fixtures import level
import pytest

CRATE_MOVES = [
    (LEFT,  (3, 2), (3, 1)),
    (RIGHT, (3, 4), (3, 5)),
    (UP,    (2, 3), (1, 3)),
    (DOWN,  (4, 3), (5, 3)),
]

class TestCrateMoves:

    @pytest.mark.parametrize

(’direction, plr_pos, crate_pos’, CRATE_MOVES)
    def test_move_crate(self, level, direction, plr_pos, crate_pos):
        """After move player and crate moved by one square"""
        print(direction, plr_pos, crate_pos)
        move(level, direction)
        assert level[plr_pos[0]][plr_pos[1]] == ’*’
        assert level[crate_pos[0]][crate_pos[1]] == ’o’

    def test_push_crate_to_wall(self):
        ...

    def test_push_crate_to_crate(self):
        ...

    def test_move_crate_to_corner(self, level):
        ...

    def test_move_crate_back_forth(self, level):
        ...

TestPlayerMoves类中,我们将合并测试函数 s test_pathstest_move_player。两者都使用测试参数化,代码大部分是冗余的。重构后的函数使用单个参数列表,这使得我们的测试类大大缩短了:

PATHS = [
    ((UP, LEFT), 2, 2),
    ((LEFT, UP), 2, 2),
    ((RIGHT, UP, LEFT, LEFT), 2, 2),
    pytest.mark.xfail(((DOWN, DOWN), 0, 0)),
    ((LEFT,), 2, 3),
    ((LEFT, RIGHT), 3, 3),

    ((RIGHT, RIGHT), 4, 3),
]

class TestPlayerMoves:

    def test_move_to_none(self, level):
        """direction=None generates an Exception"""

        with pytest.raises

(TypeError):
            move(level, None)

    @pytest.mark.parametrize

(’path, expected_x, expected_y’, PATHS)
    def test_move_player(self, level, path, expected_x, expected_y):
        """Player position changes correctly"""

        for direction in path:
            move(level, direction)
        assert level[expected_y][expected_x] == ’*’

在这个重构之后,我们再次验证所有的测试都通过了。他们有:

test_file_output.py ....
test_graphics.py ...
test_moves.py ...................x.......x...

====================== 35 passed, 2 xfailed in 0.44 seconds =======================

除了四次额外的测试,我们得到了什么?现在,模块test_moves只包含 7 个方法,而不是 13 个。夹具和参数化的使用导致了一个更短更简单的测试套件,如果其中一个测试失败,这将有利于将来的维护或修复缺陷。只要稍微注意一下将测试分组到类和模块中,通过查看文件名就可以很容易地找到相应的测试。在图 10-1 中,你可以看到我们已经创建的测试模块和类的概述。

A419627_1_En_10_Fig1_HTML.gif

图 10-1。

Example test suite. Test function s (left) are nested in a test class, which in turn is nested in a test module. A test suite usually contains many test modules.

测试类中的夹具

你可能会问自己为什么我们把level夹具移到一个单独的模块中(fixtures模块只包含一个夹具,我们只在test_moves)中使用它)。另一方面,这种结构为增长留下了空间:如果我们发明更多的固定装置或在其他地方使用它们,一个适当的结构已经到位。但是让我们简单地检查一个可能的选择:将 fixture 放在一个类中。

class TestCrateMoves:

    @pytest.fixture

(params=[LEVEL, LEVEL_NO_DOTS])
    def level(self, request):
        """A level with four single crates"""

        return parse_grid(request.param)

    ...

除了额外的self,与我们之前定义的 fixture 几乎没有语法上的区别。参数化也以同样的方式工作。当然,一个类中的 fixture 只在那个类中可用。这就是我们坚持使用fixtures模块的原因(两个类都使用level)。作为最后一个细节,您需要知道我们可以将scope参数设置为@pytest.fixture装饰器:

@pytest.fixture(scope="class", params=[LEVEL, LEVEL_NO_DOTS])

在这种情况下,将分别为该类创建一次夹具。将范围设置为class(或module)的动机是,一次构建夹具将节省时间,从而加快我们的测试。缺点是我们的测试可能会干扰。我们已经在第九章中看到了通过灯具干扰的例子。总之,这个选项需要小心使用。

测试如何找到被测试的代码?

当我们在第八章中编写我们的第一个测试时,我们简单地将test_crate.py模块放在与我们测试的模块相同的目录中。对于一个或两个测试模块来说,将测试和我们的代码放在一个文件夹中是没有坏处的。随着我们程序的增长,这变得很麻烦。因此,我们将把我们的测试模块和类放在主项目目录中一个单独的tests/文件夹中(也参见第十三章)。这将有助于我们清楚地将程序代码和测试分开。现在我们需要确保我们测试过的代码可以被正确导入。由于 Python 中的包导入机制,有一些最佳实践可以使测试和程序都是可执行的。我推荐以下步骤:

  1. 在主项目目录中创建一个test/文件夹,与包含我们想要测试的 Python 模块的maze_run文件夹平行。
  2. 确保maze_run文件夹中有一个__init__.py文件(可能是空的)。
  3. 将程序模块中的导入改为绝对导入(例如,from draw_maze import parse_grid变成了from maze_run.draw_maze import parse_grid))。在程序的所有模块中都这样做。
  4. 确保maze_run文件夹中没有文件maze_run.py。改名为__main__.py
  5. PYTHONPATH变量设置到主项目文件夹中。

如果游戏的 Python 模块在/home/krother/projects/maze_run/maze_run目录中,我们将在主目录的.bashrc文件中添加下面一行:

export PYHTONPATH=$PYTHONPATH:/home/krother/projects/maze_run

只要我们打开一个新的终端,PYTHONPATH就会更新。之后,我们可以在我们的测试模块中(或任何其他地方)编写:

from maze_run.moves import move

或者,我们可以告诉pip在可编辑模式下安装软件包。如果我们有一个setup.py文件,我们可以转到/home/krother/projects/maze_run目录并键入

pip install -e .

这将把到maze_run的路径添加到导入路径(sys.path)。第二种方法要求项目有一个setup.py文件。这两种导入测试代码的方法都是公认的最佳实践,尽管第二种方法与第十三章中详细讨论的 virtualenv 集成得更加无缝。

因此,程序内部和测试中的导入以同样的方式工作。现在我们可以从主项目文件夹中执行游戏

python maze_run/

我们的测试

cd tests/
py.test

请注意,test/文件夹或其子包中不需要__init__.py文件。同样,测试自动发现会处理一切。没有__init__.py文件也避免了在运行python setup.py install时安装测试(这在大多数时候是没有意义的)。

Hint

如果您以前没有处理过如此大规模的包导入,那么让路径和目录起作用可能会令人沮丧。让测试在前面章节的代码上运行的一个不太好的方法是给PYTHONPATH变量添加一个更深的文件夹级别:例如,/home/krother/projects/maze_run/maze_run

多重测试包

当测试数量进一步增长时,我们可以将我们的测试模块分组到多个测试包中。您应该使用哪些软件包?像模块一样,有一个简单的最佳实践,类似于测试模块的最佳实践:使用与被测试项目的包相同的结构来创建您的测试包。唯一的区别是名称需要有test作为前缀或后缀,这样 py.test 就可以自动找到其中的测试。结果,我们得到了两个平行的目录树:一个包含我们程序的包和模块,另一个,通常在一个名为tests/,的目录中,包含了整个测试套件,可能还有进一步的测试包和测试模块。假设我们想测试两个 Python 包:maze_run包含游戏,maze_run.graphics包含在屏幕上画图的所有代码。在我们的项目中,我们将有一个用于maze_runtest/目录和一个用于子包的test/test_graphics(见图 10-2 )。通过遵循这个命名约定,找到程序特定部分的测试变得很容易。

A419627_1_En_10_Fig2_HTML.gif

图 10-2。

Structure of test modules and packages. The code units under test (left) correspond with the names of the according tests (right).

测试自动发现

在这一点上,有必要花点时间思考一下 py.test 到底是如何识别测试的。py.test 中内置的测试自动发现首先检查当前目录的所有子目录,并将它们的名称与模式进行比较。每个目录

  • test_或开始
  • _test结尾

被进一步检查,包括子目录。在每个目录中,所有

  • test_开始,以.py结束,或者
  • _test.py结尾

被收集并作为测试模块导入。将模块作为测试模块导入与普通的导入有细微的不同,因为 py.test 会在这些模块中更改assert语句以导致测试通过或失败。在所有其他模块(特别是所有导入的非测试模块)中,assert语句正常工作。

测试自动发现不会在模块级别停止。在每个模块中,测试类和测试函数是使用非常相似的模式发现的:

  • 测试类从Test开始
  • 类内部的测试函数以test开始
  • 类外测试函数从test开始

测试自动发现将忽略其他一切,允许我们定义任何我们喜欢的帮助函数/类/模块,只要它们看起来不像测试。

执行测试套件

现在我们已经准备好运行我们的测试套件了。到目前为止,我们只是简单地键入py.test,让测试框架完成所有的工作。在这里,我们将探索几种替代方案。常规的py.test命令列出了测试模块,用“.”标记通过的测试,用“F”标记失败,用“x”标记预期失败,用“E”标记错误:

test_file_output.py ....
test_graphics.py ...
test_moves.py ...................x........x...

当运行一个更长的测试套件时,我们可以用py.test -q甚至py.test -q --tb=no.得到一个更简洁的输出,更容易得到正在发生的事情的概况。通常,结果是三种情况之一:

..........................x.......x...

所有测试都通过了。如果我们只看到点,这是最无聊的情况,我们的测试套件没有给我们任何额外的信息。看到这一点,我们可以马上继续编写更多的代码(或者更好,更多的测试)。

.......EEEE..EEEEEExEEE

大多数测试都因出错而失败。这些错误通常很容易发现。当我们看到这种输出时,原因要么是我们破坏了代码中的一些基本内容(在这种情况下,level fixture 被破坏)。另一个原因可能是配置问题(例如,配置文件放错了位置)。请注意,在执行任何测试之前,py.test 会捕获一个ImportError

...............FFFF.......x..F...x..F

一些测试通过,一些测试失败。这是最有趣的情况。一些失败的测试可能意味着有一个我们忘记修复或者我们甚至不知道的边界情况。无论如何,我们已经获得了新的信息来评估。现在值得详细研究 py.test 产生的输出,并找出导致测试失败的原因。在这种情况下,改变maze_run.moves.move函数中的单个字符导致了缺陷。我鼓励你自己添加微小的缺陷,看看结果如何变化。

py.test -q --tb=no

部分执行

如果我们的一些测试失败了,我们该怎么办?因为我们的测试是独立工作的,所以我们可以采取分而治之的方法:我们一个接一个地检查失败的测试,并逐个修复它们。幸运的是,在 py.test 中,有许多选项来执行我们的测试套件的各个部分。

执行测试模块和包

要运行单个模块,我们将其名称作为参数提供给 py.test:

py.test test_moves.py

通过提供测试包的名称,我们可以在一个目录中运行所有的测试模块:

py.test tests/

执行测试类

要从一个模块中选择一个单独的测试类,使用模式module::class添加它的名称,如下所示:

py.test test_moves.py::TestCrateMoves

执行单一测试

我们可以通过使用模式module::class::test指定已执行测试的名称来进一步缩小已执行测试的范围

py.test test_moves.py::TestCrateMoves::test_move_crate

通过关键字选择测试

选择相似名称的一个更简单的方法是用-k选项指定测试名称中出现的关键字。例如,要运行所有包含“推送”的测试,我们可以编写

py.test -k push

为了查看我们到底选择了哪些测试,我们添加了-v--tb=no选项:

py.test -k push -v --tb=no

这给了我们

test_moves.py::TestCrateMoves::test_push_crate_to_wall PASSED

test_moves.py::TestCrateMoves::test_push_crate_to_crate PASSED

检查故障

为了检查失败或错误的测试,我们可以使用所有的调试技术。一个方便的快捷方式是--pdb选项,一旦断言失败,它就将我们直接弹出到调试器中。然而,默认情况下使用这个选项是不可取的,因为对于每个失败的测试,我们将在调试器中结束一次。一个更好的实践是首先获得整个测试套件的概述,然后使用--pdb选项运行一个更小的子集。例如:

py.test -k push --pdb

重新运行测试

修复代码后,我们需要知道修复是否使失败的测试通过。我们还需要知道先前通过的测试是否仍然通过(或者我们是否把一切都搞砸了)。一旦我们修复了代码中的任何缺陷,我们将不得不再次运行测试类/模块/套件的剩余部分。py.test 的几个选项可以帮助我们进行这样的测试:

  • -x选项在第一次测试失败时停止测试执行。如果我们已经知道许多测试会以类似的方式失败,并且想要开始对其中一个进行更详细的诊断,这是很有帮助的。
  • 当我们已经修复了一些东西,并且希望看到对整个测试套件的影响时,--lf选项只重新运行在先前运行中失败的测试。
  • --ff(失败优先)选项首先执行失败的测试。如果我们发现没有改进,我们可以通过按下Ctrl+C来中止测试运行。

py.test 知道还有很多其他的选择。表 10-1 总结了一些常用选项。

表 10-1。

Frequently Used Options in py.test

| [计]选项 | 描述 | | --- | --- | | `-q` | 简明输出 | | `-s` | 禁用输出捕获 | | `-x` | 第一次失败时停止 | | `-k [keyword]` | 关键字作为子字符串出现在测试或类名中 | | `-v` | 明确列出所有测试 | | `-pdb` | 对失败的测试启动调试器 | | `-x` | 在第一次测试失败时停止测试执行 | | `-lf` | 从上一次运行重新运行失败的测试 | | `-ff` | 首先执行失败的测试,然后执行所有其他测试 | | `-tb=line` | 将回溯缩短到一行 | | `-tb=no` | 关闭追踪 |

计算测试覆盖率

当运行一个测试套件时,我们已经看到了执行了多少测试,以及有多少测试通过或失败。我们还不知道我们的测试执行了多少代码。这就是测试覆盖率的用武之地。在 Python 中,pytest-cov包允许我们检查测试了代码的哪些部分。我们首先使用pip安装pytest-cov用于覆盖率分析的 py.test 插件:

pip install pytest-cov

现在我们可以使用--cov选项运行我们的测试:

py.test --cov

我们可以通过给 pytest-cov 一个参数来显式地计算特定模块或包的覆盖率:

py.test --cov=../maze_run/

注意,前面的命令将运行整个测试套件,而不仅仅是test_crate.py。当然 py.test 的常规选项可以和 coverage 模块结合使用。在后台,覆盖率分析的结果存储在一个隐藏的文件.coverage,中,它的内容不打算被人们阅读。为了方便起见,我们获得了一个包含所有已经执行的 Python 文件的报告。maze_run目录的简要报告如下(完整路径略):

 -----------  coverage: platform linux, python 3.4.0-final-0  -----------
Name                            Stmts   Miss  Cover
--------------------------------------------------
../maze_run/__init__.py            3      0    100%
../maze_run/draw_maze.py          23      5     78%
../maze_run/event_loop.py         13      9     31%
../maze_run/generate_maze.py      37      8     78%
../maze_run/load_tiles.py         18      6     67%
../maze_run/maze_run.py           27      1     96%
../maze_run/moves.py              40     10     75%
../maze_run/util.py                8      2     75%
--------------------------------------------------
TOTAL                             169     41     76%

在这个表中,Stmts是 Python 语句的数量(不包括空行),Miss是没有被任何测试执行的行数,Cover是执行的行数的百分比。76%的总覆盖率已经给了我们一些有用的信息:我们现在有确凿的证据表明我们的测试套件执行了超过一半的代码。这意味着可能会有很多地方,甚至像a = 1/0这样的无意义代码也会不被注意地通过我们的测试套件。显然还有工作要做!

我们可以通过创建 HTML 报告来确定有问题的区域:

coverage html
firefox htmlcov/index.html

新创建的htmlcov/目录包含每个模块的 HTML 页面。在 HTML 页面上,突出显示已执行和未执行的部分(参见图 10-3 )。事实证明,我们的测试套件没有覆盖的大部分代码段是事件循环和每个模块的__main__块。虽然我们的测试主要针对移动板条箱,但事实证明他们几乎执行了整个程序!

A419627_1_En_10_Fig3_HTML.jpg

图 10-3。

HTML output of the coverage tool. The executed code parts are marked by the green bar to the left. The sections shaded in red were not executed. In this case, the module moves.py has been covered mostly by the tests, but we did test whether the exit (’x’) works and the __main__ block was never called.

如何解读一个程序的测试覆盖率?100%的测试覆盖率是什么意思?首先,它仅仅意味着每条指令至少被执行一次。100%的测试覆盖率并不意味着程序中的所有指令都能工作,更不意味着程序整体上是正确的。为什么会这样?有一个简单的解释:可能的执行路径太多了。假设我们的程序包含一个决策(例如一个if语句)。然后,程序中有两条独立的路径。每条路径都可能包含缺陷,因此需要单独测试。如果我们的程序包含一系列八个决策,那么已经有 256 个可能的路径。随着程序复杂性的增长,执行路径的数量也在无限增长。除非程序非常简单,否则我们的测试很可能无法覆盖程序中的所有路径。让我们考虑一个小小的、稍微有点做作的例子。假设我们有(错!)用于计算逻辑异或的代码:

def xor(a, b):
    if a is False and :
        if b is True:
            return True
    if b is False:
        return True

和一个测试函数:

def test_xor():
    assert xor(True, False)
    assert xor(False, True)

这个测试将会覆盖xor函数中 100%的代码。然而,即使是肤浅的解释也会发现,一半的可能输入产生了错误的结果(我们的测试未能覆盖相关的边界情况(True, True)和(False, False))。并且测试覆盖率没有给我们任何功能被破坏的暗示。这个限制是为什么测量测试覆盖率有点危险的原因之一。拥有 100%的测试覆盖率是非常令人迷惑的。这表明我们已经测试了所有的东西,而事实上我们只是确保了每一行都被至少一条路径覆盖。顺便说一下,在 Python 中计算逻辑异或的正确方法是

def xor(a, b):
    return bool(a) != bool(b)

在实践中,测量测试覆盖率仍然是有用的。当然,我们的测试覆盖率越高越好。然而,我们不需要直接瞄准 100%的覆盖率。如果一个程序经常改变,甚至会阻碍开发。通常,我们可以用相对较少的测试和一个小但有用的测试套件达到 50%的测试覆盖率。75%的测试覆盖率开始感觉稳定,90%是严格的,100%是针对那些发展不是很快但需要高度完整性的项目(例如,像 Python 本身这样的语言处理器)。分析测试覆盖的主要好处是,它允许我们识别有问题的区域,并更好地评估风险。

测试套件需要维护

测试套件是软件的一部分,就像完成工作的代码一样。因此,保持测试代码的整洁和保持程序代码本身的整洁一样重要。这意味着将大的函数或文件分割成小的,避免代码重复,并关注编码风格。记录为什么要编写某些测试是一个重要的最佳实践,它使得清理测试更加容易。测试套件像任何程序一样进化:它增长,它变化,并且它也需要不时地重构(重构)。

幸运的是,测试代码通常比常规代码更容易维护。测试函数往往已经很短了。根据经验,尽量保持测试代码非常简单。避免在同一个测试中出现多个断言:许多小测试比几个大测试要好。还要避免多重循环或if条件;在大多数情况下,这些可以移动到一个助手功能或参数化测试。请记住,如果在调试期间测试失败,错误可能出现在测试或代码中。我们不想同时调试两个程序(程序和测试);因此,维护一个非常容易理解的测试套件使得开发更加可靠。

最佳实践

  • 测试套件是给定程序的测试的结构化集合。
  • 测试功能可以分为测试模块、测试类和测试包。
  • 测试模块和包应该与它们测试的代码单元具有相同的名称。
  • 一个测试套件应该位于一个单独的文件夹tests/.
  • py.test 中的测试自动发现通过匹配名称来查找测试。
  • 最有趣的测试结果是只有一小部分测试失败。
  • py.test 提供了许多部分执行测试套件的选项。
  • 测试覆盖率是测试期间执行的代码行的百分比。
  • 测试覆盖率可以用pytest-cov包来计算。
  • 100%的测试覆盖率并不意味着我们已经测试了每一种可能性。
  • 自动化测试是需要清理和重构的程序代码。