为用户和开发人员测试 IPython#

注意

这是从旧 IPython wiki 原封不动复制过来的,目前正在开发中。开发指南中这部分的大部分信息已过时。

概述#

对 IPython 贡献的所有代码都必须经过测试,这一点非常重要。测试应该编写成单元测试、文档测试或 IPython 测试系统可以检测到的其他实体。有关此内容的更多详细信息,请参见下文。

IPython 中的每个子包都应该有自己的 tests 目录,其中包含该子包的所有测试。tests 目录中的所有文件都应该包含单词“tests”,以便测试框架可以找到它们。

在文档字符串中,可以且应该包含示例(使用 IPython 提示符,如 In [1]: 或“经典”python >>>)。测试系统会将它们检测为文档测试并运行它们;如果示例旨在提供信息但显示不可复制的信息(如文件系统数据),它可以控制跳过特定文档测试的部分或全部内容。

如果子包有任何超出 Python 标准库的依赖项,则在未找到依赖项时应跳过该子包的测试。这一点非常重要,这样用户就不会因为没有依赖项而导致测试失败。

我们使用的测试系统是 nose 测试运行器的扩展。特别是,我们开发了一个 nose 插件,它允许我们粘贴原封不动的 IPython 会话并将其作为文档测试进行测试,这对我们来说非常重要。

运行测试套件#

您可以在源下载目录中运行 IPython,而无需在系统范围内安装它或配置任何内容,只需在终端中键入

python2 -c "import IPython; IPython.start_ipython();"

要启动基于 Web 的笔记本,可以使用

python2 -c "import IPython; IPython.start_ipython(['notebook']);"

为了运行测试套件,您至少能够导入 IPython,即使您尚未完全安装面向用户的脚本(在开发环境中很常见)。然后,您可以使用以下命令运行测试

python -c "import IPython; IPython.test()"

一旦您通过完全安装或使用安装了 IPython

python setup.py develop

您将可以使用一个名为 iptest 的系统范围脚本,它运行完整的测试套件。然后,您可以使用以下命令运行套件

iptest  [args]

默认情况下,这会排除对 IPython.parallel 的相对较慢的测试。要运行这些测试,请使用 iptest --all

请注意,iptest 工具将针对 Python 解释器导入的代码运行测试。如果之前运行了命令 python setup.py symlink,那么它将始终通过符号链接成为本地目录中的测试代码。但是,如果尚未针对正在测试的 Python 版本运行此命令,则 iptest 有可能针对已安装版本的 IPython 运行测试。

无论您如何运行,最终您都应该看到类似这样的内容

**********************************************************************
Test suite completed for system with the following information:
{'commit_hash': '144fdae',
 'commit_source': 'repository',
 'ipython_path': '/home/fperez/usr/lib/python2.6/site-packages/IPython',
 'ipython_version': '0.11.dev',
 'os_name': 'posix',
 'platform': 'Linux-2.6.35-22-generic-i686-with-Ubuntu-10.10-maverick',
 'sys_executable': '/usr/bin/python',
 'sys_platform': 'linux2',
 'sys_version': '2.6.6 (r266:84292, Sep 15 2010, 15:52:39) \n[GCC 4.4.5]'}

Tools and libraries available at test time:
   curses matplotlib pymongo qt sqlite3 tornado wx wx.aui zmq

Ran 9 test groups in 67.213s

Status:
OK

如果没有,将显示一条消息,指示哪个测试组失败以及如何单独重新运行该组。例如,这将测试 IPython.utils 子包,-v 选项显示进度指示器

$ iptest IPython.utils -- -v
..........................SS..SSS............................S.S...
.........................................................
----------------------------------------------------------------------
Ran 125 tests in 0.119s

OK (SKIP=7)

由于 IPython 测试机制基于 nose,因此您可以使用所有 nose 语法。-- 之后的选项会传递给 nose。例如,这允许您运行 test_magic 模块中的特定测试 test_rehashx

$ iptest IPython.core.tests.test_magic:test_rehashx -- -vv
IPython.core.tests.test_magic.test_rehashx(True,) ... ok
IPython.core.tests.test_magic.test_rehashx(True,) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.100s

OK

在开发时,nose 的 --pdb--pdb-failures 特别有用,它们分别在错误或失败时将你带入一个交互式 pdb 会话中:iptest mymodule -- --pdb

上面打印的系统信息摘要可从顶级包中访问。如果你遇到 IPython 问题,在邮件列表中报告时包含此信息非常有用;使用

.. code:: python

from IPython import sys_info print sys_info()

并在查询中包含结果信息。

测试拉取请求#

我们有一个脚本,它从 Github 中获取一个拉取请求,将其与 master 合并,并在不同版本的 Python 上运行测试套件。这使用存储库的单独副本,因此你可以在它运行时继续处理代码。要运行它

python tools/test_pr.py -p 1234

数字是 Github 中的拉取请求号;-p 标志使其将结果发布到拉取请求的评论中。任何进一步的参数都传递给 iptest

这需要 requestskeyring 包。

对于开发人员:编写测试#

现在 IPython 有一个合理的测试套件,因此查看可用内容的最佳方式是查看大多数子包中的 tests 目录。但这里有一些指针可以简化此过程。

主要工具:IPython.testing#

IPython.testing 包是测试 IPython(而不是其各个部分的测试)的所有机制所在的位置。特别是,其中的 iptest 模块具有控制测试过程的所有智能。在其中,make_exclude 函数用于构建排除黑名单,这些模块甚至不会被导入进行测试。这很重要,这样由于缺少依赖项而无法导入的内容不会向最终用户提供错误,如上所述。

decorators 模块包含许多有用的装饰器,特别适用于标记应在特定条件下跳过的单个测试(而不是因为缺少主要依赖项而将整个包列入黑名单)。

我们的 nose 插件用于 doctest#

testing 中的 plugin 子包包含一个名为 ipdoctest 的 nose 插件,它向 nose 讲解 IPython 语法,以便你可以使用 IPython 提示符编写 doctest。你还可以使用 # random 标记 doctest 输出,以忽略对应于单个输入的输出(比使用省略号更强大,并有助于将其保留为示例)。如果你希望执行整个文档字符串,但不检查任何输入的任何输出,则可以使用 # all-random 标记。IPython.testing.plugin.dtexample 模块包含如何使用这些内容的示例;作为参考,以下是如何使用 # random

def ranfunc():
"""A function with some random output.

   Normal examples are verified as usual:
   >>> 1+3
   4

   But if you put '# random' in the output, it is ignored:
   >>> 1+3
   junk goes here...  # random

   >>> 1+2
   again,  anything goes #random
   if multiline, the random mark is only needed once.

   >>> 1+2
   You can also put the random marker at the end:
   # random

   >>> 1+2
   # random
   .. or at the beginning.

   More correct input is properly verified:
   >>> ranfunc()
   'ranfunc'
"""
return 'ranfunc'

以及 # all-random 的示例

def random_all():
"""A function where we ignore the output of ALL examples.

Examples:

  # all-random

  This mark tells the testing machinery that all subsequent examples
  should be treated as random (ignoring their output).  They are still
  executed, so if a they raise an error, it will be detected as such,
  but their output is completely ignored.

  >>> 1+3
  junk goes here...

  >>> 1+3
  klasdfj;

In [8]: print 'hello'
world  # random

In [9]: iprand()
Out[9]: 'iprand'
"""
return 'iprand'

在编写文档字符串时,可以使用 @skip_doctest 装饰器来指示文档字符串应被视为 doctest。 # all-random@skip_doctest 之间的区别在于,前者执行示例但忽略输出,而后者不执行任何代码。 @skip_doctest 应用于示例纯属信息性的文档字符串。

如果给定的文档字符串在某些条件下失败,但在其他情况下是一个好的 doctest,则可以使用如下代码,该代码依赖于“null”装饰器,以在文档字符串作为测试时保持其完整性

# The docstring for full_path doctests differently on win32 (different path
# separator) so just skip the doctest there, and use a null decorator
# elsewhere:

doctest_deco = dec.skip_doctest if sys.platform == 'win32' else dec.null_deco

@doctest_deco
def full_path(startPath,files):
    """Make full paths for all the listed files, based on startPath..."""

    # function body follows...

借助我们理解 IPython 语法的 nose 插件,编写测试的一种非常有效的方法是简单地将交互式会话复制并粘贴到文档字符串中。你可以编写这种类型的测试,其中你的文档字符串用作测试,方法是在函数名前加上 doctest_,并使其正文完全为空,除了文档字符串。在 IPython.core.tests.test_magic 中,你可以找到几个这样的示例,但为了完整性,你的代码应如下所示(一个简单的案例)

def doctest_time():
"""
In [10]: %time None
CPU times: user 0.00 s, sys: 0.00 s, total: 0.00 s
Wall time: 0.00 s
"""

此函数仅针对其文档字符串进行分析,但它不被视为单独的测试,这就是其正文应为空的原因。

JavaScript 测试#

我们目前使用 casperjs 来测试笔记本 JavaScript 用户界面。

要单独运行 JS 测试套件,你可以使用 iptest js,它将启动一个新的笔记本服务器并对其进行测试,或者你可以自己打开一个笔记本服务器,然后

cd IPython/html/tests/casperjs;
casperjs test --includes=util.js test_cases

如果你的测试笔记本服务器使用的是除默认端口 (8888) 之外的其他端口,则还必须将其作为参数传递给测试套件。

casperjs test --includes=util.js --port=8889 test_cases

运行单个测试#

为了加快开发速度,你通常会一次通过一个测试。要执行此操作,只需将文件名直接传递给 casperjs test 命令,如下所示

casperjs test --includes=util.js  test_cases/execute_code_cell.js

了解 JavaScript 中的 JavaScript:#

CasperJS 是用 JavaScript 编写的浏览器,因此我们编写 JavaScript 代码来驱动它。Casper 浏览器本身也具有 JavaScript 实现(例如 Firefox 和 Chrome 附带的实现),在测试套件中,我们可以使用 this.evaluate 及其同类项(this.theEvaluate 等)访问它们。此外,由于所有内容都是异步/回调性质的,因此有很多 this.then 调用来定义测试套件中的步骤。部分原因是每个步骤都有一个超时(默认值为 5 或 10 秒)。此外,util.js 中还提供了便捷函数来帮助你等待给定单元格中的输出等。在我们的 JavaScript 测试中,如果你看到 look_like_pep8_naming_convention 函数,它们可能来自 util.js,而来自 casper 的函数 haveCamelCaseNamingConvention

test_cases 中的每个文件看起来都像这样(这是 test_cases/check_interrupt.js

casper.notebook_test(function () {
    this.evaluate(function () {
        var cell = IPython.notebook.get_cell(0);
        cell.set_text('import time\nfor x in range(3):\n    time.sleep(1)');
        cell.execute();
    });


    // interrupt using menu item (Kernel -> Interrupt)
    this.thenClick('li#int_kernel');

    this.wait_for_output(0);

    this.then(function () {
        var result = this.get_output_cell(0);
        this.test.assertEquals(result.ename, 'KeyboardInterrupt', 'keyboard interrupt (mouseclick)');
    });

    // run cell 0 again, now interrupting using keyboard shortcut
    this.thenEvaluate(function () {
        cell.clear_output();
        cell.execute();
    });

    // interrupt using Ctrl-M I keyboard shortcut
    this.thenEvaluate( function() {
        IPython.utils.press_ghetto(IPython.utils.keycodes.I)
    });

    this.wait_for_output(0);

    this.then(function () {
        var result = this.get_output_cell(0);
        this.test.assertEquals(result.ename, 'KeyboardInterrupt', 'keyboard interrupt (shortcut)');
    });
});

有关如何从 casper 测试套件向客户端端 javascript 传递参数的示例,请参阅 casper.wait_for_outputIPython/html/tests/casperjs/util.js 中的实现

测试系统设计说明#

本节是对 IPython 测试需求关键点的说明,在编写系统时使用这些说明,并且应在系统演化时保留这些说明以供参考。

全面测试 IPython 需要修改 nose 和 doctest 的默认行为,因为 IPython 提示符不被识别为确定 Python 输入,并且因为 IPython 允许用户输入无效的 Python(例如 %magics!system commands

我们基本上需要能够测试以下类型的代码

    1. 包含常规测试的纯 Python 文件。这些文件不成问题,因为只要它们符合 nose 用于识别测试的(灵活)惯例,Nose 就会选取它们。

    1. 包含 doctest 的 Python 文件。在此,我们有两种可能

  • 提示符为通常的 >>>,输入为纯 Python。

  • 提示符的格式为 In [1]:,输入可以包含扩展的 IPython 表达式。

在第一种情况下,只要使用 --with-doctest 标志调用 Nose,Nose 就会识别 doctest。但第二种情况可能需要修改或编写一个新的 doctest 插件,该插件适用于 Nose 且支持 IPython。

    1. 包含代码块的 ReStructuredText 文件。对于此类文件,我们对代码块有三种不同的可能

  • 它们使用 >>> 提示符。

  • 它们使用 In [1]: 提示符。

  • 它们是没有任何提示符的纯 Python 代码的独立块。

前两种情况类似于上面的情况 2,不同之处在于在这种情况下,必须使用 docutils 从输入代码块中提取 doctest,而不是从 Python 文档字符串中提取。

在第三种情况下,我们必须有一个惯例来区分要执行的代码块和其他可能是不应该运行的 shell 代码片段或其他示例。一种可能是假设所有缩进的代码块都应该执行,但对于不应该执行的输入有一个特殊的 docutils 指令。

对于我们将执行的那些代码块,使用的惯例只是调用它们,并且如果它们在不引发错误的情况下运行到完成,则认为它们是成功的。这类似于 Nose 对独立测试函数所做的操作,通过放置断言或其他形式的引发异常语句,可以将作为轻量级测试的清晰示例加倍。

    1. 扩展模块在函数和方法文档字符串中包含 doctest。目前,Nose 根本无法正确找到这些文档字符串,因为底层的 doctest DocTestFinder 对象在那里失败了。类似于上面的情况 2,文档字符串可以有纯 python 或 IPython 提示符。

其中,只有 3-c(带有独立代码块的 reST)在这一点上尚未实现。