Python-测试秘籍第二版(全)

Python 测试秘籍第二版(全)

原文:zh.annas-archive.org/md5/98CC341CCD461D299EE4103040C60B7B

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

测试一直是软件开发的一部分。几十年来,全面的测试是由复杂的手动测试程序支持的,而这些程序又由庞大的预算支持;但是在 1998 年发生了一些革命性的事情。在他的《更好的 Smalltalk 指南》中,Smalltalk 大师 Kent Beck 引入了一个名为 SUnit 的自动化测试框架。这引发了一系列测试框架,包括 JUnit、PyUnit 和许多其他针对不同语言和各种平台的框架,被称为 xUnit 运动。当 17 位顶级软件专家在 2001 年签署了《敏捷宣言》时,自动化测试成为了敏捷运动的基石。

测试包括许多不同的风格,包括单元测试、集成测试、验收测试、烟测试、负载测试等等。本书深入探讨了并探索了在使用 Python 的灵活力量的同时在所有重要级别进行测试。它还演示了许多工具。

这本书旨在扩展您对测试的知识,使其不再是您听说过的东西,而是您可以在任何级别应用以满足您在改进软件质量方面的需求。

关于,或者稍微练习了一下,变成了您可以在任何级别应用以满足您在改进软件质量方面的需求。我们希望为您提供工具,以获得更好的软件开发和客户满意度的巨大回报。

这本书适合谁

如果您是一名希望将测试提升到更高水平并希望扩展您的测试技能的 Python 开发人员,那么这本书适合您。假设您具有一些 Python 编程知识。

本书涵盖了什么

第一章《使用 Unittest 开发基本测试》为您快速介绍了 Python 社区中最常用的测试框架。

第二章《使用 Nose 运行自动化测试套件》介绍了最普遍的 Python 测试工具,并向您展示如何编写专门的插件。

第三章《使用 doctest 创建可测试文档》展示了使用 Python 的文档字符串构建可运行的 doctests 以及编写自定义测试运行器的许多不同方法。

第四章《使用行为驱动开发测试客户故事》深入探讨了使用 doctest、mocking 和 Lettuce/Should DSL 编写易于阅读的可测试的客户故事。

第五章《使用验收测试进行高级客户场景》,帮助您进入客户的思维模式,并使用 Pyccuracy 和 Robot Framework 从他们的角度编写测试。

第六章《将自动化测试与持续集成集成》向您展示了如何使用 Jenkins 和 TeamCity 将持续集成添加到您的开发流程中。

第七章《通过测试覆盖率衡量您的成功》探讨了如何创建覆盖率报告并正确解释它们。它还深入探讨了如何将它们与您的持续集成系统结合起来。

第八章《烟/负载测试-测试主要部分》着重介绍了如何创建烟测试套件以从系统中获取脉搏。它还演示了如何将系统置于负载之下,以确保它能够处理当前的负载,并找到未来负载的下一个破坏点。

第九章《新旧系统的良好测试习惯》带您经历了作者从软件测试方面学到的许多不同的经验教训。

第十章使用 Selenium 进行 Web UI 测试,教你如何为他们的软件编写合适的测试集。它将解释要使用的各种测试集和框架。本章不包含在书中,可以在以下链接在线获取:www.packtpub.com/sites/default/files/downloads/Web_UI_Testing_Using_Selenium.pdf

要充分利用本书

您需要在您的计算机上安装 Python。本书使用许多其他 Python 测试工具,但包括详细的步骤,显示如何安装和使用它们。

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便文件直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. www.packtpub.com上登录或注册。

  2. 选择 SUPPORT 选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩或提取文件夹:

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Python-Testing-Cookbook-Second-Edition。我们还有来自丰富书籍和视频目录的其他代码包可用于github.com/PacktPublishing/。去看看吧!

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“您还可以使用pip install virtualenv。”

代码块设置如下:

if __name__== "__main__": 
    suite = unittest.TestLoader().loadTestsFromTestCase(\
              RomanNumeralConverterTest) 
    unittest.TextTestRunner(verbosity=2).run(suite) 

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体设置:

def test_bad_inputs(self): 
    r = self.cvt.convert_to_roman 
    d = self.cvt.convert_to_decimal 
    edges = [("equals", r, "", None),\ 

任何命令行输入或输出都以以下方式编写:

$ python recipe3.py

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“选择一个要测试的类。这被称为被测试的类。”

警告或重要说明会出现在这样的地方。提示和技巧会出现在这样的地方。

章节

在本书中,您会经常看到几个标题(准备就绪如何做...它是如何工作的...还有更多...另请参阅)。

为了清晰地说明如何完成配方,使用以下各节:

准备就绪

本节告诉您配方中可以期望发生什么,并描述如何设置配方所需的任何软件或任何预备设置。

如何做...

本节包含遵循该配方所需的步骤。

它是如何工作的...

本节通常包括对前一节中发生的事情的详细解释。

还有更多...

本节包括有关配方的其他信息,以使您对配方更加了解。

另请参阅

本节提供了有关配方的其他有用信息的链接。

第一章:使用 Unittest 开发基本测试

在本章中,我们将涵盖以下食谱:

  • 断言基础知识

  • 设置和拆卸测试工具

  • 从命令行运行测试用例

  • 运行一部分测试用例方法

  • 链接一系列测试

  • 在测试模块内定义测试套件

  • 重新调整旧的测试代码以在 unittest 中运行

  • 将复杂的测试分解为简单的测试

  • 测试边缘

  • 通过迭代测试角落情况

介绍

测试一直是软件开发的一部分。然而,当 Kent Beck 和 Erich Gamma 为 Java 开发引入了 JUnit(junit.org)时,世界被介绍了一个称为自动化测试的新概念。它基于 Kent 早期与 Smalltalk 和自动化测试的工作。目前,自动化测试已成为软件行业中一个被广泛接受的概念。

Python 版本最初被称为PyUnit,于 1999 年创建,并在 2001 年后添加到 Python 的标准库中,即 Python 2.1。目前,PyUnit 库适用于 Python 的两个版本,即 2.7(docs.python.org/2.7/library/unittest.html)和 3.x(docs.python.org/3.6/library/unittest.html)。从那时起,Python 社区将其称为unittest,这是导入测试代码的库的名称。

Unittest 是 Python 世界中自动化测试的基础。在本章中,我们将探讨测试和断言代码功能的基础知识,构建测试套件,避免测试情况,最后测试边缘和角落情况。

在本章的所有食谱中,我们将使用virtualenvpypi.python.org/pypi/virtualenv)来创建一个受控的 Python 运行环境。Unittest 是标准库的一部分,不需要额外的安装步骤。但在后面的章节中,使用virtualenv将允许我们方便地安装其他测试工具,而不会使我们的默认 Python 安装变得混乱。安装virtualenv的步骤如下:

  1. 要安装virtualenv,可以从前面提到的网站下载,或者如果您有 Easy Install,只需输入:easy_install virtualenv。您也可以使用pip install virtualenv

对于某些系统,您可能需要以root身份安装它,或者使用sudo

  1. 安装virtualenv后,使用它创建一个名为ptcPython Testing Cookbook的缩写)的干净环境,使用--no-site-packages

  2. 激活虚拟 Python 环境。这可能会有所不同,取决于您使用的 shell。看一下这个截图:

  3. 对于 Windows 平台,您可以选择要创建ptc文件夹的文件夹,或者直接在所需的驱动器中创建它。看一下这个截图:

  4. 最后,通过检查pip的路径来验证环境是否处于活动状态。

有关virtualenv的用法和好处的更多信息,请阅读iamzed.com/2009/05/07/a-primer-on-virtualenv

断言基础知识

自动化 unittest 测试用例的基本概念是实例化我们代码的一部分,对其进行操作,并使用断言验证某些结果:

  • 如果结果符合预期,unittest 将计为测试成功

  • 如果结果不匹配,将引发异常,并且 unittest 将计为测试失败

准备工作

Unittest 已添加到 Python 的标准库套件中,不需要任何额外的安装。

如何做...

通过这些步骤,我们将编写一个简单的程序,然后使用 unittest 编写一些自动化测试:

  1. 为这个配方的代码创建一个名为recipe1.py的新文件。选择一个要测试的类。这被称为被测试的类。对于这个配方,我们将选择一个使用简单的罗马数字转换器的类:
class RomanNumeralConverter(object):
    def __init__ (self, roman_numeral): 
        self.roman_numeral = roman_numeral 
        self.digit_map = {"M":1000, "D":500,"C":100,\
                         "L":50, "X":10, "V":5, "I":1} 
     def convert_to_decimal(self): 
        val = 0 
        for char in self.roman_numeral: 
            val += self.digit_map[char] 
        return val 

这个罗马数字转换器应用了简单的加法规则,但它没有特殊的减法模式,比如XL映射到40。目的不是要有最好的罗马数字转换器,而是观察各种测试断言。

  1. 编写一个新的类,并给它加上Test,继承unittest.TestCase。在测试类后面加上Test是一种常见的约定,但不是必需的。扩展unittest.TestCase是需要的,以便连接到 unittest 的标准测试运行器:
import unittest 
class RomanNumeralConverterTest(unittest.TestCase): 
  1. 创建几个以test开头的方法,这样它们就会被 unittest 的测试用例自动捕捉到:
     def test_parsing_millenia(self):
        value =RomanNumeralConverter("M") 
        self.assertEqual(1000, value.convert_to_decimal()) 
     def test_parsing_century(self): 
        value =RomanNumeralConverter("C") 
        self.assertEqual(100, value.convert_to_decimal()) 
     def test_parsing_half_century(self): 
        value =RomanNumeralConverter("L") 
        self.assertEqual(50, value.convert_to_decimal()) 
     def test_parsing_decade(self): 
        value =RomanNumeralConverter("X") 
        self.assertEqual(10, value.convert_to_decimal()) 
     def test_parsing_half_decade(self): 
        value =RomanNumeralConverter("V") 
        self.assertEqual(5, value.convert_to_decimal()) 
     def test_parsing_one(self): 
        value = RomanNumeralConverter("I") 
        self.assertEqual(1, value.convert_to_decimal()) 
     def test_empty_roman_numeral(self): 
        value =RomanNumeralConverter("") 
        self.assertTrue(value.convert_to_decimal() == 0) 
        self.assertFalse(value.convert_to_decimal() > 0) 
     def test_no_roman_numeral(self): 
        value =RomanNumeralConverter(None) 
        self.assertRaises(TypeError, value.convert_to_decimal) 
  1. 使整个脚本可运行,然后使用 unittest 的测试运行器:
if __name__=="__main__": 
    unittest.main()
  1. 从命令行运行文件,如下截图所示:

self.assertEquals()在 Python 3 中已被弃用。

它是如何工作的...

在第一步中,我们选择了一个要测试的类。接下来,我们创建了一个单独的测试类。通过将测试类命名为[class under test]Test,很容易知道哪个类正在被测试。每个测试方法的名称必须以test开头,这样 unittest 会自动捕捉并运行它。要添加更多的测试,只需定义更多的test方法。这些测试利用了各种断言:

  • assertEqual(first, second[, msg]): 比较第一个和第二个表达式,如果它们的值不相同则失败。如果失败,我们可以选择打印特殊消息。

  • assertTrue(expression[, msg]): 测试表达式,如果为假则失败。如果失败,我们可以选择打印特殊消息。

  • assertFalse(expression[, msg]): 测试表达式,如果为真则失败。如果失败,我们可以选择打印特殊消息。

  • assertRaises(exception, callable, ...): 用任何参数运行 callable,对于之后列出的 callable,如果它没有引发异常,则失败。

还有更多...

Unittest 提供了许多断言、失败和其他方便的选项。以下部分展示了如何从这些选项中进行选择和挑选的一些建议。

assertEquals 优于 assertTrue 和 assertFalse

assertEquals断言失败时,错误报告中会打印第一个和第二个值,从而更好地反馈出了问题所在,而assertTrueassertFalse只会报告失败。并非所有可测试的结果都适用于这种情况,但如果可能的话,使用assertEquals

理解相等的概念很重要。当比较整数、字符串和其他标量时,这很简单。但是对于诸如字典、列表和集合之类的集合,情况就不那么理想了。复杂的、自定义的对象可能具有自定义的相等定义。这些复杂的对象可能需要更精细的断言。因此,当使用自定义对象时,最好也包括一些直接针对相等性和不相等性的测试方法。

self.fail([msg])通常可以用断言重写

Unittest 有一个self.fail([msg])操作,可以无条件地导致测试失败,并附带一个可选的消息。之前没有展示这个操作,因为不建议使用。

fail方法通常用于检测异常等特定情况。一个常见的习惯用法如下:

import unittest 
class BadTest(unittest.TestCase): 
  def test_no_roman_number(self): 
    value = RomanNumeralConverter(None) 
    try: 
      value.convert_to_decimal() 
      self.fail("Expected a TypeError") 
    except TypeError: 
      pass 
    if  __name__=="__main__": 
      unittest.main()

这测试了与之前的test_no_roman_numeral相同的行为。这种方法的问题在于当代码正常工作时,fail 方法永远不会被执行。定期不执行的代码有可能变得过时和无效。这也会干扰覆盖率报告。因此,最好使用像我们在前面的例子中使用的assertRaises。对于其他情况,考虑使用其他断言重写测试。

我们的 Python 版本可能会影响我们的选项

Python 官方关于 unittest 的文档显示了许多其他断言;然而,它们取决于我们使用的 Python 版本。有些已经被弃用;其他一些只在以后的版本中可用,比如 Python 3.6。

如果我们的代码必须支持多个版本的 Python,那么我们必须使用最低公共分母。这个配方展示了自 Python 3.6 以来所有版本都可用的核心断言。

设置和拆卸测试工具

Unittest 提供了一种简单的机制来配置系统的状态,当一段代码经过测试时。如果需要,它还允许我们在之后清理事物。当一个特定的测试用例在每个测试方法中使用重复步骤时,通常会需要这样做。

除了引用从一个测试方法到下一个测试方法传递状态的外部变量或资源,每个测试方法都从相同的状态开始。

如何做...

通过以下步骤,我们将为每个测试方法设置和拆卸测试工具:

  1. 为这个配方的代码创建一个名为recipe2.py的新文件。

  2. 选择一个要测试的类。在这种情况下,我们将使用我们的罗马数字转换器的一个稍微改变的版本,其中函数而不是构造函数提供要转换的输入值:

class RomanNumeralConverter(object): 
    def __init__(self): 
        self.digit_map = {"M":1000, "D":500, "C":100,\
                         "L":50, "X":10, "V":5, "I":1} 
    def convert_to_decimal(self, roman_numeral):
        val = 0 
        for char in roman_numeral: 
            val += self.digit_map[char] 
        return val 
  1. 使用与被测试类相同的名称创建一个测试类,在末尾添加Test
import unittest 
class RomanNumeralConverterTest(unittest.TestCase): 
  1. 创建一个setUp方法,用于创建被测试类的实例:
    def setUp(self): 
        print ("Creating a new RomanNumeralConverter...") 
        self.cvt =RomanNumeralConverter()
  1. 创建一个tearDown方法,用于销毁被测试类的实例:
     def tearDown(self): 
        print ("Destroying the RomanNumeralConverter...") 
        self.cvt = None 
  1. 使用self.converter创建所有测试方法:
     def test_parsing_millenia(self):
        self.assertEqual(1000,\
                         self.cvt.convert_to_decimal("M")) 
     def test_parsing_century(self): 
        self.assertEqual(100, \
                          self.cvt.convert_to_decimal("C")) 
     def test_parsing_half_century(self): 
        self.assertEqual(50,\
                         self.cvt.convert_to_decimal("L")) 
     def test_parsing_decade(self): 
        self.assertEqual(10,self.cvt.convert_to_decimal("X")) 
     def test_parsing_half_decade(self): 
        self.assertEqual(5,self.cvt.convert_to_decimal("V")) 
     def test_parsing_one(self): 
        self.assertEqual(1,self.cvt.convert_to_decimal("I")) 
     def test_empty_roman_numeral(self): 
        self.assertTrue(self.cvt.convert_to_decimal() == 0) 
        self.assertFalse(self.cvt.convert_to_decimal() > 0) 
     def test_no_roman_numeral(self): 
        self.assertRaises(TypeError,\
                          self.cvt.convert_to_decimal,None)
  1. 使整个脚本可运行,然后使用 unittest 的测试运行程序:
if __name__=="__main__": 
     unittest.main()
  1. 从命令行运行文件,如下截图所示:

它是如何工作的...

在第一步中,我们选择了一个要测试的类。接下来,我们创建了一个单独的测试类。通过将测试类命名为[class under test]Test,很容易知道哪个类正在被测试。

然后,我们定义了一个setUp方法,unittest 在每个Test方法之前运行。接下来,我们创建了一个tearDown方法,unittest 在每个Test方法之后运行。在这种情况下,我们在每个方法中添加了一个打印语句,以演示 unittest 重新运行这两个方法以进行每个测试方法。实际上,这可能会给我们的测试添加太多噪音。

unittest 的一个不足之处是缺少setUpClass/tearDownClasssetUpModule/tearDownModule,这提供了在比测试方法级别更大的范围内运行代码的机会。这已经添加到unittest2中。

每个测试用例可以有一个 setUp 和一个 tearDown 方法:我们的RomanNumeralConverter非常简单,很容易适应一个单独的测试类。但是测试类只允许一个setUp方法和一个tearDown方法。如果需要不同组合的setUp/tearDown方法来进行各种测试场景,那么这就是编写更多测试类的线索。仅仅因为我们编写了一个setUp方法,并不意味着我们需要一个tearDown方法。在我们的情况下,我们可以跳过销毁RomanNumeralConverter,因为一个新实例将替换它用于每个测试方法。这只是为了演示目的。那些需要tearDown方法的其他用例有哪些其他用途?使用需要某种关闭操作的库是编写tearDown方法的一个很好的候选者。

从命令行运行测试用例

很容易调整测试运行程序,以便在运行时打印出每个测试方法。

如何做...

在接下来的步骤中,我们将以更详细的输出运行测试用例,以便更好地了解事物的运行情况:

  1. 为这个配方的代码创建一个名为recipe3.py的新文件。

  2. 选择一个要测试的类。在这种情况下,我们将使用我们的罗马数字转换器:

class RomanNumeralConverter(object): 
    def __init__(self, roman_numeral): 
        self.roman_numeral = roman_numeral 
        self.digit_map = {"M":1000, "D":500, "C":100, "L":50,\
                           "X":10,"V":5, "I":1} 

    def convert_to_decimal(self):
        val = 0 
        for char in self.roman_numeral:
            val += self.digit_map[char] 
        return val
  1. 使用与被测试类相同的名称创建一个测试类,在末尾添加Test
import unittest
class RomanNumeralConverterTest(unittest.TestCase): 
  1. 创建几个测试方法。对于这个配方,第二个测试故意编码失败:
def test_parsing_millenia(self): 
    value =RomanNumeralConverter("M") 
    self.assertEqual(1000, value.convert_to_decimal()) 

def test_parsing_century(self): 
    "This test method is coded to fail for demo."
     value =RomanNumeralConverter("C") 
     self.assertEqual(10, value.convert_to_decimal()) 
  1. 定义一个测试套件,它自动加载所有测试方法,然后以更高级别的详细程度运行它们:
if __name__== "__main__": 
    suite = unittest.TestLoader().loadTestsFromTestCase(\
              RomanNumeralConverterTest) 
    unittest.TextTestRunner(verbosity=2).run(suite) 
  1. 从命令行运行文件。请注意,在这个截图中,失败的测试方法打印出其 Python 文档字符串:

工作原理...

自动化测试的关键部分是组织测试。基本单元称为测试用例。这些可以组合成测试套件。Python 的 unittest 模块提供了TestLoader().loadTestsFromTestCase,可以自动将所有test*方法加载到一个测试套件中。然后通过 unittest 的TextTestRunner以更高级别的详细程度运行这个测试套件。

TextTestRunner是 unittest 的唯一测试运行器。在本书的后面,我们将看到其他具有不同运行器的测试工具,包括插入不同 unittest 测试运行器的运行器。

前面的截图显示了每个方法以及其模块和类名,以及成功/失败。

还有更多...

这个配方不仅演示了如何提高运行测试的详细程度,还展示了当测试用例失败时会发生什么。它将test方法重命名为嵌入在test方法中的文档字符串,并在所有测试方法报告后打印详细信息。

运行测试用例方法的子集

有时,只运行给定测试用例中的一部分测试方法很方便。这个配方将展示如何从命令行运行整个测试用例,或者选择一个子集。

如何做...

以下步骤显示了如何编写一个命令行脚本来运行测试的子集:

  1. 创建一个名为recipe4.py的新文件,放置这个配方的所有代码。

  2. 选择一个要测试的类。在这种情况下,我们将使用我们的罗马数字转换器:

class RomanNumeralConverter(object):
    def __init__(self, roman_numeral): 
        self.roman_numeral = roman_numeral 
        self.digit_map = {"M":1000, "D":500,\
                        "C":100, "L":50, "X":10, "V":5, "I":1} 

    def convert_to_decimal(self):
        val = 0 
        for char in self.roman_numeral: 
            val+=self.digit_map[char]
        return val
  1. 使用与要测试的类相同的名称创建一个测试类,并在末尾添加Test
import unittest 
class RomanNumeralConverterTest(unittest.TestCase): 
  1. 创建几个test方法:
    def test_parsing_millenia(self):
        value = RomanNumeralConverter("M") 
        self.assertEquals(1000, value.convert_to_decimal()) 

    def test_parsing_century(self):
        value = RomanNumeralConverter("C") 
        self.assertEquals(100, value.convert_to_decimal()) 
  1. 编写一个主运行程序,要么运行整个测试用例,要么接受可变数量的测试方法:
if __name__= "__main__":
    import sys
    suite = unittest.TestSuite()
    if len(sys.argv) == 1:
        suite = unittest.TestLoader().loadTestsFromTestCase(\                                                                       RomanNumeralConverterTest) 
    else: 
        for test_name in sys.argv[1:]:
            suite.addTest(RomanNumeralConverterTest(test_name))

    unittest.TextTestRunner(verbosity=2).run(suite) 
  1. 不带额外命令行参数运行该配方,并查看它运行所有测试,如此截图所示:

工作原理...

对于这个测试用例,我们编写了几个测试方法。但是,我们没有简单地运行所有测试,或者定义一个固定的列表,而是使用 Python 的sys库来解析命令行参数。如果没有额外的参数,它将运行整个测试用例。如果有额外的参数,那么它们被假定为测试方法名称。它使用 unittest 的内置能力在实例化RomanNumeralConverterTest时指定测试方法名称。

将一系列测试链接在一起

Unittest 使得将测试用例链接成TestSuite变得很容易。TestSuite可以像TestCase一样运行,但它还提供了额外的功能来添加单个/多个测试,并对其进行计数。

为什么我们需要这个?将测试链接成一个套件使我们能够将多个测试用例模块汇集到一个测试运行中,以及挑选和选择测试用例的子集。到目前为止,我们通常运行单个类中的所有测试方法。TestSuite给我们提供了一个替代方法来定义一个测试块。

如何做...

在接下来的步骤中,我们将编写多个测试用例类,然后将它们的测试方法加载到套件中,以便我们可以运行它们:

  1. 创建一个名为recipe5.py的新文件,放置我们的示例应用程序和测试用例。

  2. 选择一个要测试的类。在这种情况下,我们将使用我们的罗马数字转换器:

class RomanNumeralConverter(object): 
    def __init__(self): 
            self.digit_map = {"M":1000, "D":500,\
                        "C":100, "L":50, "X":10, "V":5, "I":1} 

    def convert_to_decimal(self, roman_numeral):
            val = 0 
            for char in roman_numeral: 
                val += self.digit_map[char] 
            return val 
  1. 创建两个测试类,它们之间分布着各种测试方法:
import unittest 
class RomanNumeralConverterTest(unittest.TestCase): 
    def setUp(self): 
        self.cvt = RomanNumeralConverter()
    def test_parsing_millenia(self): 
        self.assertEquals(1000, \ 
                    self.cvt.convert_to_decimal("M")) 

    def test_parsing_century(self): 
        self.assertEquals(100, \ 
                    self.cvt.convert_to_decimal("C")) 

class RomanNumeralComboTest(unittest.TestCase):
    def setUp(self):
        self.cvt=RomanNumeralConverter()
    def test_multi_millenia(self):
        self.assertEquals(4000,\
    def test_multi_add_up(self): 
        self.assertEquals(2010, \ 
        self.cvt.convert_to_decimal("MMX"))
  1. 在一个名为recipe5_runner.py的单独文件中创建一个测试运行器,它引入了两个测试用例:
if __name__ == "__main__": 
    import unittest 
    from recipe5 import * 
    suite1 = unittest.TestLoader().loadTestsFromTestCase( \  
                RomanNumeralConverterTest) 
    suite2 = unittest.TestLoader().loadTestsFromTestCase( \ 
                RomanNumeralComboTest) 
    suite = unittest.TestSuite([suite1, suite2])     
    unittest.TextTestRunner(verbosity=2).run(suite)
  1. 执行测试运行器,并从这个截图中观察到测试是如何从两个测试用例中提取出来的。

工作原理...

unittest 模块提供了一种方便的方法,可以找到TestClass中的所有测试方法,并使用其loadTestsFromTestCase将它们捆绑在一起作为一个套件。为了进一步使用测试套件,我们能够将这两个套件组合成一个单一的套件,使用unittest.TestSuite([list...])TestSuite类被设计为像TestCase类一样运行,尽管它不是TestClass的子类,但允许我们使用TextTestRunner来运行它。这个配方显示了详细程度的提高,让我们能够看到确切运行了哪些测试方法,以及它们来自哪个测试用例。

还有更多...

在这个配方中,我们从一个不同的文件中运行了测试,而不是测试用例被定义的文件。这与以前的配方不同,以前的配方中可运行的代码和测试用例都包含在同一个文件中。由于运行器定义了我们要运行的测试,我们可以轻松地创建更多的运行器,结合不同的测试套件。

测试用例的名称应该有意义

在以前的配方中,建议将测试用例命名为[要测试的类]Test。这是为了让读者明白,被测试的类和相关的测试之间有重要的关系。现在我们引入了另一个测试用例,我们需要选择一个不同的名称。名称应该清楚地解释为什么这些特定的测试方法被拆分到一个单独的类中。对于这个配方,这些方法被拆分出来以展示更复杂的罗马数字组合。

在测试模块内定义测试套件

每个测试模块可以提供一个或多个方法,定义不同的测试套件。一个方法可以在给定模块中执行所有测试,另一个方法可以定义一个特定的子集。

如何做...

通过以下步骤,我们将创建一些方法,使用不同的方式定义测试套件:

  1. 创建一个名为recipe6.py的新文件,以放置我们这个配方的代码。

  2. 选择一个要测试的类。在这种情况下,我们将使用我们的罗马数字转换器:

class RomanNumeralConverter(object): 
    def __init__(self): 
        self.digit_map = {"M":1000, "D":500, "C":100, "L":50, "X":10, "V":5, "I":1} 

    def convert_to_decimal(self, roman_numeral): 
    val = 0 
    for char in roman_numeral: 
        val += self.digit_map[char] 
    return val 
  1. 使用与要测试的类相同的名称创建一个测试类,并在末尾添加Test
import unittest 
class RomanNumeralConverterTest(unittest.TestCase): 
  1. 编写一系列测试方法,包括一个setUp方法,为每个测试方法创建一个RomanNumeralConverter的新实例:
import unittest 

class RomanNumeralConverterTest(unittest.TestCase): 
    def setUp(self): 
        self.cvt = RomanNumeralConverter() 

    def test_parsing_millenia(self): 
        self.assertEquals(1000, \ 
             self.cvt.convert_to_decimal("M")) 

    def test_parsing_century(self): 
        self.assertEquals(100, \ 
            self.cvt.convert_to_decimal("C")) 

    def test_parsing_half_century(self): 
        self.assertEquals(50, \ 
            self.cvt.convert_to_decimal("L")) 

    def test_parsing_decade(self): 
        self.assertEquals(10, \ 
            self.cvt.convert_to_decimal("X")) 

    def test_parsing_half_decade(self): 
        self.assertEquals(5, self.cvt.convert_to_decimal("V")) 

    def test_parsing_one(self): 
        self.assertEquals(1, self.cvt.convert_to_decimal("I")) 

    def test_empty_roman_numeral(self):     
        self.assertTrue(self.cvt.convert_to_decimal("") == 0) 
        self.assertFalse(self.cvt.convert_to_decimal("") > 0) 

    def test_no_roman_numeral(self): 
        self.assertRaises(TypeError, \ 
            self.cvt.convert_to_decimal, None) 

    def test_combo1(self): 
        self.assertEquals(4000, \ 
            self.cvt.convert_to_decimal("MMMM")) 

    def test_combo2(self): 
        self.assertEquals(2010, \ 
            self.cvt.convert_to_decimal("MMX")) 

    def test_combo3(self): 
        self.assertEquals(4668, \ 
            self.cvt.convert_to_decimal("MMMMDCLXVIII")) 
  1. 在配方模块中创建一些方法(但不在测试用例中),定义不同的测试套件:
def high_and_low(): 
    suite = unittest.TestSuite() 
    suite.addTest(\ 
        RomanNumeralConverterTest("test_parsing_millenia"))    
    suite.addTest(\ 
        RomanNumeralConverterTest("test_parsing_one")) return suite 
def combos(): 
    return unittest.TestSuite(map(RomanNumeralConverterTest,\    
        ["test_combo1", "test_combo2", "test_combo3"])) 
def all(): 
    return unittest.TestLoader().loadTestsFromTestCase(\   
            RomanNumeralConverterTest) 
  1. 创建一个运行器,它将遍历每个测试套件并通过 unittest 的TextTestRunner运行它们:
if __name__ == "__main__": 
    for suite_func in [high_and_low, combos, all]: 
        print ("Running test suite '%s'" % suite_func.__name__)  
        suite = suite_func()    
        unittest.TextTestRunner(verbosity=2).run(suite)
  1. 运行测试套件的组合,并查看结果。看一下这个截图:

它是如何工作的...

我们选择一个要测试的类,并定义一些测试方法来检查事情。然后我们定义一些模块级别的方法,比如high_and_lowcombosall,来定义测试套件。其中两个包含固定的方法子集,而all动态加载类中的test*方法。最后,我们的模块的主要部分遍历所有这些生成套件的函数的列表,以顺利地创建和运行它们。

还有更多...

我们所有的测试套件都是从配方的主运行器中运行的。但这可能不适用于一个真正的项目。相反,想法是定义不同的套件,并编写一个机制来选择要运行的套件。每个套件都针对不同的目的,有必要允许开发人员选择要运行的套件。这可以通过使用 Python 的 optparse 模块编写一个命令行脚本来完成,以定义命令行标志来选择其中一个套件。

测试套件方法必须在测试类之外

如果我们将这些定义套件的方法作为测试类的成员,我们将不得不实例化测试类。扩展unittest.TestCase的类具有一个专门的init方法,它与仅用于调用非测试方法的实例不兼容。这就是为什么这些方法在测试类之外的原因。虽然这些方法可以在其他模块中,但在包含测试代码的模块内定义它们非常方便,以保持它们的接近性。

为什么有不同的测试套件?

如果我们一开始就运行所有测试项目会怎样?听起来是个好主意,对吧?但是如果运行整个测试套件的时间增长到一个小时以上怎么办?在一定的阈值之后,开发人员往往会停止运行测试,没有比未运行的测试套件更糟糕的了。通过定义测试的子集,可以轻松地在白天运行备用套件,然后也许每天运行一次全面的测试套件。请记住以下几点:

  • all是全面的测试套件

  • high_and_low是测试边缘情况的一个例子

  • combos是用于显示事物通常工作的值的随机抽样

定义我们的测试套件是一个判断。每隔一段时间重新评估每个测试套件也是值得的。如果一个测试套件运行成本太高,考虑将一些更昂贵的测试移到另一个套件中。

optparse 正在被淘汰,并被 argparse 替代

虽然optparse是向 Python 脚本添加命令行标志的一种方便方式,但它不会永远可用。Python 2.7 已经弃用了这个模块,并在argparse中继续开发。

重新调整旧的测试代码以在 unittest 中运行

有时,我们可能已经开发了演示代码来测试我们的系统。我们不必重写它以在 unittest 中运行。相反,将它连接到测试框架并进行一些小的更改即可轻松运行。

如何做...

通过这些步骤,我们将深入捕获那些没有使用 unittest 编写的测试代码,并以最小的努力重新用途化它们以在 unittest 中运行:

  1. 创建一个名为recipe7.py的文件,用于放置我们将要测试的应用程序代码。

  2. 选择一个要测试的类。在这种情况下,我们将使用我们的罗马数字转换器:

class RomanNumeralConverter(object): 
    def __init__(self): 
        self.digit_map = {"M":1000, "D":500, "C":100, "L":50, "X":10, "V":5, "I":1} 

    def convert_to_decimal(self, roman_numeral): 
        val = 0 
        for char in roman_numeral: 
            val += self.digit_map[char] 
        return val 
  1. 创建一个名为recipe7_legacy.py的新文件,其中包含不使用 unittest 模块的测试代码。

  2. 创建一组遗留测试,根据 Python 的assert函数编码,而不是使用 unittest,以及一个运行器:

from recipe7 import * 
class RomanNumeralTester(object): 
  def   init  (self): 
    self.cvt = RomanNumeralConverter() 
  def simple_test(self):
    print ("+++ Converting M to 1000")
    assert self.cvt.convert_to_decimal("M") == 1000
  def combo_test1(self): 
    print ("+++ Converting MMX to 2010") 
    assert self.cvt.convert_to_decimal("MMXX") == 2010 
  def combo_test2(self): 
    print ("+++ Converting MMMMDCLXVIII to 4668") 
    val = self.cvt.convert_to_decimal("MMMMDCLXVII")         
    self.check(val, 4668) 
  def other_test(self): 
    print ("+++ Converting MMMM to 4000") 
    val = self.cvt.convert_to_decimal("MMMM") 
    self.check(val, 4000) 
  def check(self, actual, expected): 
    if (actual != expected): 
      raise AssertionError("%s doesn't equal %s" % \ 
            (actual,  expected)) 
  def test_the_system(self): 
    self.simple_test() 
    self.combo_test1() 
    self.combo_test2() 
    self.other_test() 
if __name == "__main__": 
  tester = RomanNumeralTester() 
  tester.test_the_system()

这组遗留测试旨在代表我们团队在 unittest 成为一个选择之前开发的遗留测试代码。

  1. 运行遗留测试。这种情况有什么问题?所有测试方法都运行了吗?我们有没有捕捉到所有的 bug?看一下这个截图:

  1. 创建一个名为recipe7_pyunit.py的新文件。

  2. 创建一个 unittest 测试集,将每个遗留测试方法包装在 unittest 的FunctionTestCase中:

from recipe7 import * 
from recipe7_legacy import * import unittest 

if __name__ == "__main__":  
    tester = RomanNumeralTester() 
    suite = unittest.TestSuite() 
    for test in [tester.simple_test, tester.combo_test1, \ 
            tester.combo_test2, tester.other_test]: 
        testcase = unittest.FunctionTestCase(test)   
        suite.addTest(testcase) 
    unittest.TextTestRunner(verbosity=2).run(suite)
  1. 运行 unittest 测试。这次所有测试都运行了吗?哪个测试失败了?bug 在哪里?看一下这个截图:

它是如何工作的...

Python 提供了一个方便的断言语句来测试条件。当条件为真时,代码继续执行。当条件为假时,它会引发AssertionError。在第一个测试运行器中,我们有几个测试,使用assert语句或引发AssertionError来检查结果。

unittest 提供了一个方便的类,unittest.FunctionTestCase,它将绑定的函数包装为 unittest 测试用例。如果抛出AssertionErrorFunctionTestCase会捕获它,将其标记为测试失败,然后继续下一个测试用例。如果抛出任何其他类型的异常,它将被标记为测试错误。在第二个测试运行器中,我们使用FunctionTestCase包装每个这些遗留测试方法,并将它们链接在一起,以便 unittest 运行。

通过运行第二个测试运行,可以看到第三个测试方法中隐藏着一个 bug。我们之前并不知道这个 bug,因为测试套件被过早中断了。

Python 的assert语句的另一个不足之处可以从前面的截图中的第一个失败中看出。当断言失败时,几乎没有关于被比较的值的信息。我们只有它失败的代码行。截图中的第二个断言更有用,因为我们编写了一个自定义检查器,它抛出了一个自定义的AssertionError

还有更多...

Unittest 不仅仅是运行测试。它有一个内置的机制来捕获错误和失败,然后尽可能多地继续运行我们的测试套件。这有助于我们在给定的测试运行中摆脱更多的错误并修复更多的问题。当测试套件增长到需要花费几分钟甚至几小时才能运行时,这一点尤为重要。

错误在哪里?

它们存在于测试方法中,并且基本上是通过对被转换的罗马数字进行轻微修改而产生的,如代码所示:

def combo_test1(self): 
    print ("+++ Converting MMX to 2010") 
    assert self.cvt.convert_to_decimal("MMXX") == 2010 
def combo_test2(self): 
    print ("+++ Converting MMMMDCLXVIII to 4668")
    val = self.cvt.convert_to_decimal("MMMMDCLXVII") 
    self.check(val, 4668) 

combo_test1测试方法打印出正在转换MMX,但实际上尝试转换MMXXcombo_test2测试方法打印出正在转换MMMMDCLXVIII,但实际上尝试转换MMMMDCLXVII

这是一个刻意的例子,但你是否曾经遇到过同样小的错误,让你疯狂地试图追踪它们?关键是,显示追踪它们有多容易或多困难取决于如何检查值。Python 的assert语句在告诉我们在哪里比较值方面并不是很有效。自定义的check方法在指出combo_test2的问题方面要好得多。

这突显了使用注释或打印语句来反映断言所做的事情的问题。它们很容易失去同步,开发人员可能会在追踪错误时遇到一些问题。避免这种情况被称为DRY原则(不要重复自己)。

FunctionTestCase 是一个临时措施

FunctionTestCase是一个测试用例,它提供了一种快速迁移基于 Python 的assert语句的测试的简单方法,因此它们可以与 unittest 一起运行。但事情不应该止步于此。如果我们花时间将RomanNumeralTester转换为 unittest 的TestCase,那么我们就可以使用TestCase提供的其他有用功能,比如各种assert*方法。这是一个很好的投资。FunctionTestCase只是降低了迁移到 unittest 的门槛。

将模糊测试拆分为简单测试

Unittest 提供了通过一系列断言来测试代码的手段。我经常感到诱惑,想在单个测试方法中测试代码的许多方面。如果任何部分失败,哪一部分失败就变得模糊了。最好将事情分解成几个较小的测试方法,这样当被测试的代码的某些部分失败时,就很明显了。

如何做到...

通过这些步骤,我们将调查将太多内容放入单个测试方法时会发生什么:

  1. 创建一个名为recipe8.py的新文件,用于放置此配方的应用代码。

  2. 选择一个要测试的类。在这种情况下,我们将使用罗马数字转换器的另一种版本,它可以双向转换:

class RomanNumeralConverter(object): 
    def __init__(self): 
        self.digit_map = {"M":1000, "D":500, "C":100, "L":50, "X":10, "V":5, "I":1} 

    def convert_to_decimal(self, roman_numeral): 
        val = 0 
        for char in roman_numeral: 
        val += self.digit_map[char] 
    return val 

    def convert_to_roman(self, decimal): 
        val = "" 
    while decimal > 1000: 
        val += "M" 
        decimal -= 1000 
    while decimal > 500: 
        val += "D"
        decimal -= 500 
    while decimal > 100: 
        val += "C" 
        decimal -= 100 
    while decimal > 50: 
        val += "L" 
        decimal -= 50 
    while decimal > 10: 
        val += "X" 
        decimal -= 10 
    while decimal > 5: 
        val += "V" 
        decimal -= 5 
    while decimal > 1: 
        val += "I" 
        decimal -= 1 
    return val 
  1. 创建一个名为recipe8_obscure.py的新文件,以放置一些更长的测试方法。

  2. 创建一些结合了几个测试断言的测试方法:

import unittest 
from recipe8 import * 

class RomanNumeralTest(unittest.TestCase): 
    def setUp(self): 
        self.cvt = RomanNumeralConverter() 

    def test_convert_to_decimal(self): 
        self.assertEquals(0, self.cvt.convert_to_decimal(""))     
        self.assertEquals(1, self.cvt.convert_to_decimal("I"))    
        self.assertEquals(2010, \ 
            self.cvt.convert_to_decimal("MMX")) 
        self.assertEquals(4000, \ 
            self.cvt.convert_to_decimal("MMMM")) 
    def test_convert_to_roman(self): 
        self.assertEquals("", self.cvt.convert_to_roman(0)) 
        self.assertEquals("II", self.cvt.convert_to_roman(2))     
        self.assertEquals("V", self.cvt.convert_to_roman(5))    
        self.assertEquals("XII", \ 
            self.cvt.convert_to_roman(12)) 
        self.assertEquals("MMX", \ 
            self.cvt.convert_to_roman(2010)) 
        self.assertEquals("MMMM", \ 
            self.cvt.convert_to_roman(4000))

if __name__ == "__main__":  
    unittest.main()
  1. 运行模糊测试。为什么会失败?错误在哪里?报告说II不等于I,所以似乎有些问题。这是唯一的错误吗?创建另一个名为recipe8_clear.py的文件,以创建一组更精细的测试方法。看一下这个截图:

  2. 将断言拆分为单独的测试方法,以提供更高的输出保真度:

import unittest 
from recipe8 import * 

class RomanNumeralTest(unittest.TestCase): 
    def setUp(self): 
        self.cvt = RomanNumeralConverter() 

    def test_to_decimal1(self): 
        self.assertEquals(0, self.cvt.convert_to_decimal("")) 

    def test_to_decimal2(self): 
        self.assertEquals(1, self.cvt.convert_to_decimal("I")) 

    def test_to_decimal3(self): 
        self.assertEquals(2010, \ 
            self.cvt.convert_to_decimal("MMX")) 

    def test_to_decimal4(self): 
        self.assertEquals(4000, \ 
            self.cvt.convert_to_decimal("MMMM")) 

    def test_convert_to_roman1(self): 
        self.assertEquals("", self.cvt.convert_to_roman(0)) 

    def test_convert_to_roman2(self): 
        self.assertEquals("II", self.cvt.convert_to_roman(2)) 

    def test_convert_to_roman3(self): 
        self.assertEquals("V", self.cvt.convert_to_roman(5)) 

    def test_convert_to_roman4(self): 
        self.assertEquals("XII", \ 
                    self.cvt.convert_to_roman(12)) 

    def test_convert_to_roman5(self): 
        self.assertEquals("MMX", \ 
                    self.cvt.convert_to_roman(2010)) 

    def test_convert_to_roman6(self): 
        self.assertEquals("MMMM", \ 
                    self.cvt.convert_to_roman(4000)) 

if __name__ == "__main__": 
unittest.main() 
  1. 运行更清晰的测试套件。现在错误的位置更清晰了吗?为了获得更高程度的测试失败,我们做出了什么样的交易?这样做值得吗?参考这个截图:

它是如何工作的...

在这种情况下,我们创建了一个修改后的罗马数字转换器,可以双向转换。然后我们开始创建测试方法来练习这些事情。由于这些测试都是简单的一行断言,将它们放在同一个测试方法中非常方便。

在第二个测试用例中,我们将每个断言放入一个单独的测试方法中。运行它会暴露出这个罗马数字转换器中存在多个错误。

还有更多...

当我们开始编写测试时,将所有这些断言捆绑到一个测试方法中非常方便。毕竟,如果一切正常,那就没有坏处,对吧?但是如果一切都正常呢;我们要如何处理?一个晦涩的错误报告!

错误在哪里?

晦涩的测试运行器可能不够清晰。我们只能依靠II != I这并不多。线索是它只差一个。清晰的测试运行器提供更多线索。我们看到V != IIII, XII != XI,还有更多。这些失败显示了每个都差一个。

错误涉及 while 检查中的各种布尔条件:

while decimal > 1000: 
while decimal > 500: 
while decimal > 100: 
while decimal > 50: 
while decimal > 10: 
while decimal > 5: 
while decimal > 1:

它应该测试大于等于,而不是测试大于。这会导致它在计算最后一个罗马数字之前跳出。

测试方法的合适大小是多少?

在这个示例中,我们将事物分解为每个测试一个断言。但我不建议沿着这样的思路思考。

如果我们再仔细看一些,每个测试方法还涉及对罗马数字 API 的单一使用。对于转换器,在练习代码时只有一个结果需要检查。对于其他系统,输出可能更复杂。在同一个测试方法中使用多个断言来检查通过进行单次调用的结果是完全合理的。

当我们继续对罗马数字 API 进行更多调用时,它应该提示我们考虑将其拆分为一个新的测试方法。

这引发了一个问题:什么是代码单元?关于代码单元的定义以及什么样的单元测试才算是好的,一直存在着很多争论。有很多不同的观点。希望阅读本章并将其与本书中涵盖的其他测试策略进行权衡,将有助于您加强自己的观点,最终提高自己的测试技能。

单元测试与集成测试

Unittest 可以轻松帮助我们编写单元测试和集成测试。单元测试可以测试较小的代码块。在编写单元测试时,最好将测试保持尽可能小和细粒度。将测试分解为许多较小的测试通常是检测和定位错误的更好方法。

当我们提升到更高级别(如集成测试)时,有意义的是在一个测试方法中测试多个步骤。但只有在有足够的低级单元测试时才建议这样做。这将为我们提供一些线索,表明它是在单元级别出现了问题,还是存在一系列步骤导致了错误。

集成测试通常扩展到诸如外部系统之类的事物。例如,许多人认为单元测试不应该连接到数据库,与 LDAP 服务器通信或与其他系统交互。

仅仅因为我们使用了 unittest 并不意味着我们正在编写的测试就是单元测试。在本书的后面,我们将讨论 unittest 可以用来编写许多类型的测试,包括集成测试、冒烟测试以及其他类型的测试。

测试边缘情况

当我们编写自动化测试时,我们选择输入并断言预期的输出。测试输入的极限是很重要的,以确保我们的代码可以处理好和坏的输入。这也被称为测试边界情况

如何做...

当我们深入研究这个示例时,我们将寻找好的边界进行测试:

  1. 为这个示例创建一个名为recipe9.py的新文件。

  2. 选择一个要测试的类。在这个示例中,我们将使用我们的罗马数字转换器的另一个变体。这个变体不处理大于4000的值:

class RomanNumeralConverter(object): 
    def __init__(self): 
      self.digit_map = {"M":1000, "D":500, "C":100, "L":50, "X":10, "V":5, "I":1} 
    def convert_to_decimal(self, roman_numeral): 
        val = 0 
        for char in roman_numeral: 
            val += self.digit_map[char] 
        if val > 4000: 
        raise Exception("We don't handle values over 4000") 
    return val

    def convert_to_roman(self, decimal): 
        if decimal > 4000: 
            raise Exception("We don't handle values over 4000") 
        val = "" 
        mappers = [(1000,"M"), (500,"D"), (100,"C"), (50,"L"), 
(10,"X"), (5,"V"), (1,"I")] 
        for (mapper_dec, mapper_rom) in mappers: 
            while decimal >= mapper_dec: 
                val += mapper_rom 
                decimal -= mapper_dec 
        return val 
  1. 创建一个测试用例,设置罗马数字转换器的实例:
import unittest 

class RomanNumeralTest(unittest.TestCase): 
    def setUp(self): 
      self.cvt = RomanNumeralConverter() 
  1. 添加几个测试方法,以测试转换为罗马数字表示法的边缘情况:
def test_to_roman_bottom(self): 
    self.assertEquals("I", self.cvt.convert_to_roman(1))  

def test_to_roman_below_bottom(self): 
    self.assertEquals("", self.cvt.convert_to_roman(0)) 

def test_to_roman_negative_value(self): 
    self.assertEquals("", self.cvt.convert_to_roman(-1)) 

def test_to_roman_top(self): 
    self.assertEquals("MMMM", \ 
                self.cvt.convert_to_roman(4000)) 

def test_to_roman_above_top(self): 
    self.assertRaises(Exception, \ 
                self.cvt.convert_to_roman, 4001) 
  1. 添加几个测试方法,以便测试转换为十进制表示法的边缘情况:
def test_to_decimal_bottom(self): 
    self.assertEquals(1, self.cvt.convert_to_decimal("I")) 

def test_to_decimal_below_bottom(self): 
    self.assertEquals(0, self.cvt.convert_to_decimal("")) 

def test_to_decimal_top(self):  
    self.assertEquals(4000, \ 
                self.cvt.convert_to_decimal("MMMM")) 

def test_to_decimal_above_top(self):      
    self.assertRaises(Exception, \ 
                self.cvt.convert_to_decimal, "MMMMI")
  1. 添加一些测试,以测试将十进制数转换为罗马数字的层次:
def test_to_roman_tier1(self): 
    self.assertEquals("V", self.cvt.convert_to_roman(5)) 

def test_to_roman_tier2(self): 
    self.assertEquals("X", self.cvt.convert_to_roman(10)) 

def test_to_roman_tier3(self): 
    self.assertEquals("L", self.cvt.convert_to_roman(50)) 

def test_to_roman_tier4(self): 
    self.assertEquals("C", self.cvt.convert_to_roman(100)) 

def test_to_roman_tier5(self): 
    self.assertEquals("D", self.cvt.convert_to_roman(500)) 

def test_to_roman_tier6(self): 
    self.assertEquals("M", \ 
                self.cvt.convert_to_roman(1000)) 
  1. 添加一些测试,输入意外值到罗马数字转换器:
def test_to_roman_bad_inputs(self): 
    self.assertEquals("", self.cvt.convert_to_roman(None))     
    self.assertEquals("I", self.cvt.convert_to_roman(1.2)) 

def test_to_decimal_bad_inputs(self):   
    self.assertRaises(TypeError, \ 
                self.cvt.convert_to_decimal, None) 
    self.assertRaises(TypeError, \ 
                self.cvt.convert_to_decimal, 1.2) 
  1. 添加一个单元测试运行器:
if __name__ == "__main__": 
  unittest.main() 
  1. 运行测试用例。看一下这个屏幕截图:

工作原理...

我们有一个专门的罗马数字转换器,只能转换到MMMM4000的值。我们编写了几个测试方法来测试它。我们立即测试的边缘是14000。我们还为这之后的一步编写了一些测试:04001。为了使事情完整,我们还测试了-1

还有更多...

算法的一个关键部分涉及处理各种层次的罗马数字(5、10、50、100、500 和 1000)。这些可以被认为是微边缘,所以我们编写了测试来检查代码是否也处理了这些情况。你认为我们应该测试一下微边缘之外的情况吗?

建议我们应该。许多错误是由于编码大于而不是大于或等于(或反之)等等而引发的。在边界之外进行测试,向两个方向进行测试,是确保事情正如预期的完美方式。我们还需要检查错误的输入,所以我们尝试转换Nonefloat

上面的陈述提出了一个重要的问题:我们应该测试多少种无效类型?因为 Python 是动态的,我们可以期望许多输入类型。那么,什么是合理的呢?如果我们的代码依赖于字典查找,比如我们的罗马数字 API 的某些部分,那么确认我们正确处理KeyError可能就足够了。如果所有不同类型的输入都导致KeyError,那么我们就不需要输入很多不同类型。

识别边缘很重要

识别系统的边缘很重要,因为我们需要知道我们的软件能够处理这些边界。我们还需要知道它能够处理这些边界的两侧,即好值和坏值。这就是为什么我们需要检查40004001以及01。这是软件经常出错的地方。

测试意外条件

这听起来有点别扭吗?预料之外的情况?我们的代码涉及将整数和字符串来回转换。所谓的意外情况,是指当有人使用我们的库时传递了我们没有预料到的边界或将其连接到接收比我们预期的更广泛类型的输入时传递的输入类型。

一个常见的误用情况是当我们的 API 的用户针对一个集合(如列表)进行操作,并意外地传递整个列表,而不是通过迭代传递单个值。另一个经常出现的情况是当我们的 API 的用户由于其代码中的某些其他错误而传递None。知道我们的 API 足够强大,能够处理这些情况是很好的。

通过迭代测试边界情况

在开发代码时,通常会发现新的边界情况输入。能够将这些输入捕获在可迭代的数组中,使得添加相关的测试方法变得容易。

如何做...

在这个示例中,我们将看一种不同的测试边界情况的方法:

  1. 为我们在这个示例中的代码创建一个名为recipe10.py的新文件。

  2. 选择一个类进行测试。在这个示例中,我们将使用我们的罗马数字转换器的另一个变体。这个变体不处理大于4000的值:

class RomanNumeralConverter(object): 
    def __init__(self): 
        self.digit_map = {"M":1000, "D":500, "C":100, "L":50, "X":10, "V":5, "I":1} 

    def convert_to_decimal(self, roman_numeral): 
        val = 0 
        for char in roman_numeral: 
            val += self.digit_map[char] 
        if val > 4000: 
            raise Exception(\ 
                "We don't handle values over 4000") 
        return val 

    def convert_to_roman(self, decimal): 
        if decimal > 4000: 
            raise Exception(\ 
                "We don't handle values over 4000") 
        val = ""  
        mappers = [(1000,"M"), (500,"D"), (100,"C"), (50,"L"), 
(10,"X"), (5,"V"), (1,"I")] 
        for (mapper_dec, mapper_rom) in mappers: 
            while decimal >= mapper_dec: 
                val += mapper_rom 
                decimal -= mapper_dec 
        return val 
  1. 创建一个测试类来测试罗马数字转换器:
import unittest 

class RomanNumeralTest(unittest.TestCase): 
    def setUp(self): 
        self.cvt = RomanNumeralConverter()
  1. 编写一个测试方法,测试罗马数字转换器的边缘情况:
def test_edges(self): 
    r = self.cvt.convert_to_roman 
    d = self.cvt.convert_to_decimal 
    edges = [("equals", r, "I", 1),\ 
          ("equals", r, "", 0),\ 
          ("equals", r, "", -1),\ 
          ("equals", r, "MMMM", 4000),\ 
          ("raises", r, Exception, 4001),\ 
          ("equals", d, 1, "I"),\ 
          ("equals", d, 0, ""),\ 
          ("equals", d, 4000, "MMMM"),\
          ("raises", d, Exception, "MMMMI") 
         ] 
    [self.checkout_edge(edge) for edge in edges
  1. 创建一个测试方法,测试从十进制到罗马数字的转换层次:
def test_tiers(self):
    r = self.cvt.convert_to_roman
    edges = [("equals", r, "V", 5),\
         ("equals", r, "VIIII", 9),\
         ("equals", r, "X", 10),\
         ("equals", r, "XI", 11),\
         ("equals", r, "XXXXVIIII", 49),\
         ("equals", r, "L", 50),\
         ("equals", r, "LI", 51),\
         ("equals", r, "LXXXXVIIII", 99),\
         ("equals", r, "C", 100),\
         ("equals", r, "CI", 101),\
         ("equals", r, "CCCCLXXXXVIIII", 499),\
         ("equals", r, "D", 500),\
         ("equals", r, "DI", 501),\
         ("equals", r, "M", 1000)\
        ]
    [self.checkout_edge(edge) for edge in edges]
  1. 创建一个测试方法,测试一组无效输入:
def test_bad_inputs(self): 
    r = self.cvt.convert_to_roman 
    d = self.cvt.convert_to_decimal 
    edges = [("equals", r, "", None),\ 
        ("equals", r, "I", 1.2),\ 
        ("raises", d, TypeError, None),\ 
        ("raises", d, TypeError, 1.2)\ 
       ] 
    [self.checkout_edge(edge) for edge in edges]
  1. 编写一个实用方法,迭代边缘情况并根据每个边缘运行不同的断言:
def checkout_edge(self, edge): 
    if edge[0] == "equals": 
      f, output, input = edge[1], edge[2], edge[3]    
      print("Converting %s to %s..." % (input, output))    
      self.assertEquals(output, f(input)) 
    elif edge[0] == "raises": 
      f, exception, args = edge[1], edge[2], edge[3:]    
      print("Converting %s, expecting %s" % \ 
                      (args, exception)) 
      self.assertRaises(exception, f, *args)
  1. 通过将测试用例加载到TextTestRunner中使脚本可运行。
  if __name__ == "__main__": 
    suite = unittest.TestLoader().loadTestsFromTestCase( \    
                RomanNumeralTest) 
    unittest.TextTestRunner(verbosity=2).run(suite)
  1. 运行测试用例,如此截图所示:

它是如何工作的...

我们有一个专门的罗马数字转换器,只能转换值到MMMM4000。我们写测试的即时边缘是14000。我们还为这之后的一步写了一些测试:04001。为了使事情完整,我们还对-1进行了测试。

但我们以稍有不同的方式编写了测试。我们不是将每个测试输入/输出组合作为单独的测试方法来编写,而是将输入和输出值捕捉在嵌入列表中的元组中。然后我们将其提供给我们的测试迭代器checkout_edge。因为我们需要assertEqualassertRaise调用,所以元组还包括等于或引发以标记使用哪种断言。

最后,为了灵活处理罗马数字和十进制的转换,我们还将我们的罗马数字 API 的convert_to_romanconvert_to_decimal函数的句柄嵌入到每个元组中。

如下所示,我们抓住了convert_to_roman并将其存储在r中。然后我们将其嵌入到突出显示的元组的第三个元素中,允许checkout_edge函数在需要时调用它:

def test_bad_inputs(self): 
    r = self.cvt.convert_to_roman 
    d = self.cvt.convert_to_decimal 
    edges = [("equals", r, "", None),\ 
         ("equals", r, "I", 1.2),\ 
         ("raises", d, TypeError, None),\ 
         ("raises", d, TypeError, 1.2)\ 
        ] 

    [self.checkout_edge(edge) for edge in edges] 

还有更多...

算法的一个关键部分涉及处理罗马数字的各个层次(5、10、50、100、500 和 1000)。这些可以被视为迷你边缘,因此我们编写了一个单独的测试方法,其中包含要检查的输入/输出值的列表。在测试边缘配方中,我们没有包括这些迷你边缘之前和之后的测试,例如546。现在只需要一行数据来捕捉这个测试,我们在这个配方中有了它。其他所有的都是这样做的(除了 1000)。

最后,我们需要检查错误的输入,因此我们创建了另一种测试方法,尝试将Nonefloat转换为罗马数字并从中转换。

这是否违背了配方-将模糊测试分解为简单测试?

在某种程度上是这样的。如果测试数据条目中的某个地方出现问题,那么整个测试方法将失败。这就是为什么这个配方将事物分解成了三个测试方法而不是一个大的测试方法来覆盖它们所有的原因之一。这是一个关于何时将输入和输出视为更多数据而不是测试方法的判断。如果你发现相同的测试步骤序列重复出现,考虑一下是否有意义将这些值捕捉在某种表结构中,比如在这个配方中使用的列表中。

这与配方相比如何-测试边缘?

如果不明显的话,这些是在测试边缘配方中使用的完全相同的测试。问题是,你觉得哪个版本更可读?两者都是完全可以接受的。将事物分解为单独的方法使其更精细化,更容易发现问题。将事物收集到数据结构中,就像我们在这个配方中所做的那样,使其更简洁,并可能会激励我们编写更多的测试组合,就像我们为转换层所做的那样。

在我自己的观点中,当测试具有简单输入和输出的算法函数时,更适合使用这种方法来以简洁的格式编写整个测试输入的电池。例如,数学函数,排序算法或者转换函数。

当测试更逻辑和命令式的函数时,另一种方法可能更有用。例如,与数据库交互,导致系统状态发生变化或其他类型的副作用的函数,这些副作用没有封装在返回值中,将很难使用这种方法捕捉。

另请参阅

  • 将模糊测试分解为简单测试

  • 测试边缘

第二章:使用 Nose 运行自动化测试套件

在本章中,我们将涵盖以下示例:

  • 用测试变得多管闲事

  • 将鼻子嵌入 Python 中

  • 编写一个 nose 扩展来基于正则表达式选择测试

  • 编写一个 nose 扩展来生成 CSV 报告

  • 编写一个项目级脚本,让您运行不同的测试套件

介绍

在上一章中,我们看了几种利用 unittest 创建自动化测试的方法。现在,我们将看看不同的方法来收集测试并运行它们。Nose 是一个有用的实用程序,用于发现测试并运行它们。它灵活,可以从命令行或嵌入式脚本运行,并且可以通过插件进行扩展。由于其可嵌入性和高级工具(如项目脚本),可以构建具有测试选项的工具。

nose 提供了 unittest 没有的东西吗?关键的东西包括自动测试发现和有用的插件 API。有许多 nose 插件,提供从特殊格式的测试报告到与其他工具集成的一切。我们将在本章和本书的后面部分更详细地探讨这一点。

有关 nose 的更多信息,请参阅:somethingaboutorange.com/mrl/projects/nose

我们需要激活我们的虚拟环境,然后为本章的示例安装 nose。

创建一个虚拟环境,激活它,并验证工具是否正常工作:

接下来,使用pip install nose,如下面的截图所示:

用测试变得多管闲事

当提供一个包、一个模块或一个文件时,nose 会自动发现测试。

如何做...

通过以下步骤,我们将探讨 nose 如何自动发现测试用例并运行它们:

  1. 创建一个名为recipe11.py的新文件,用于存储此示例的所有代码。

  2. 创建一个用于测试的类。对于这个示例,我们将使用一个购物车应用程序,让我们加载物品,然后计算账单:

class ShoppingCart(object):
      def __init__(self):
          self.items = []
      def add(self, item, price):
          self.items.append(Item(item, price))
          return self
     def item(self, index):
          return self.items[index-1].item
     def price(self, index):
          return self.items[index-1].price
     def total(self, sales_tax):
          sum_price = sum([item.price for item in self.items])
          return sum_price*(1.0 + sales_tax/100.0)
     def __len__(self):
          return len(self.items)
class Item(object):
     def __init__(self, item, price):
          self.item = item
          self.price = price
  1. 创建一个测试用例,练习购物车应用程序的各个部分:
import unittest
class ShoppingCartTest(unittest.TestCase):
     def setUp(self):
        self.cart = ShoppingCart().add("tuna sandwich", 15.00)
     def test_length(self):
        self.assertEquals(1, len(self.cart))
     def test_item(self):
        self.assertEquals("tuna sandwich", self.cart.item(1))
     def test_price(self):
        self.assertEquals(15.00, self.cart.price(1))
     def test_total_with_sales_tax(self):
        self.assertAlmostEquals(16.39,
        self.cart.total(9.25), 2)
  1. 使用命令行nosetests工具按文件名和模块运行此示例:

工作原理...

我们首先创建了一个简单的应用程序,让我们用Items加载ShoppingCart。这个应用程序让我们查找每个物品及其价格。最后,我们可以计算包括销售税在内的总账单金额。

接下来,我们编写了一些测试方法,以使用 unittest 来练习所有这些功能。

最后,我们使用了命令行nosetests工具,它发现测试用例并自动运行它们。这样可以避免手动编写测试运行器来加载测试套件。

还有更多...

为什么不编写测试运行器如此重要?使用nosetests我们能获得什么?毕竟,unittest 给了我们嵌入自动发现测试运行器的能力,就像这样:

if __name__ == "__main__": 
    unittest.main()

如果测试分布在多个模块中,同一段代码块是否能够工作?不行,因为unittest.main()只查找当前模块。要扩展到多个模块,我们需要开始使用 unittest 的loadTestsFromTestCase方法或其他自定义套件来加载测试。我们如何组装套件并不重要。当我们有遗漏测试用例的风险时,nosetests方便地让我们搜索所有测试,或者根据需要搜索一部分测试。

在项目中常见的情况是将测试用例分布在许多模块之间。我们通常不会编写一个大的测试用例,而是根据各种设置、场景和其他逻辑分组将其分解为较小的测试用例。根据正在测试的模块拆分测试用例是一种常见做法。关键是,手动加载真实世界测试套件的所有测试用例可能会变得费力。

Nose 是可扩展的

自动发现测试并不是使用 nose 的唯一原因。在本章的后面,我们将探讨如何编写插件来自定义它发现的内容以及测试运行的输出。

Nose 是可嵌入的

nose 提供的所有功能都可以通过命令行或 Python 脚本来使用。我们还将在本章中进一步探讨这一点。

另请参阅

第一章中的断言基础食谱,使用 Unittest 开发基本测试

将 nose 嵌入 Python 中

将 nose 嵌入 Python 脚本中非常方便。这不仅让我们创建更高级的测试工具,还允许开发人员将测试添加到现有工具中。

如何做...

通过这些步骤,我们将探索在 Python 脚本中使用 nose 的 API 来运行一些测试:

  1. 创建一个名为recipe12.py的新文件,以包含此示例中的代码。

  2. 创建一个要测试的类。对于这个示例,我们将使用一个购物车应用程序,它让我们加载物品然后计算账单:

class ShoppingCart(object):
   def __init__(self):
      self.items = []
   def add(self, item, price):
      self.items.append(Item(item, price))
      return self
   def item(self, index):
      return self.items[index-1].item
   def price(self, index):
      return self.items[index-1].price
   def total(self, sales_tax):
      sum_price = sum([item.price for item in self.items])
      return sum_price*(1.0 + sales_tax/100.0)
   def __len__(self):
      return len(self.items)
class Item(object):
   def __init__(self, item, price):
      self.item = item
      self.price = price
  1. 创建一个包含多个测试方法的测试用例:
import unittest
class ShoppingCartTest(unittest.TestCase):
   def setUp(self): 
      self.cart = ShoppingCart().add("tuna sandwich", 15.00)
   def test_length(self):
      self.assertEquals(1, len(self.cart))
   def test_item(self):
      self.assertEquals("tuna sandwich", self.cart.item(1))
   def test_price(self):
      self.assertEquals(15.00, self.cart.price(1))
   def test_total_with_sales_tax(self):
      self.assertAlmostEquals(16.39,
      self.cart.total(9.25), 2)
  1. 创建一个名为recipe12_nose.py的脚本,以使用 nose 的 API 来运行测试。

  2. 使脚本可运行,并使用 nose 的run()方法来运行选定的参数:

if __name__ == "__main__":
    import nose
    nose.run(argv=["", "recipe12", "--verbosity=2"])
  1. 从命令行运行测试脚本并查看详细输出:

它是如何工作的...

在测试运行代码中,我们使用了nose.run()。没有参数时,它简单地依赖于sys.argv并像命令行nosetests一样运行。但在这个示例中,我们插入了当前模块的名称以及增加的详细信息。

还有更多...

Unittest 有unittest.main(),它也发现并运行测试用例。这有什么不同?unittest.main()旨在在运行它的同一模块中发现测试用例。nose.run()函数旨在让我们传入命令行参数或以编程方式加载它们。

例如,看看以下步骤;我们必须完成它们以提高 unittest 的详细程度:

if __name__ == "__main__": 
    import unittest 
    from recipe12 import * 
    suite = unittest.TestLoader().loadTestsFromTestCase( 
                                        ShoppingCartTest) 
    unittest.TextTestRunner(verbosity=2).run(suite) 

我们必须导入测试用例,使用测试加载器创建测试套件,然后通过TextTestRunner运行它。

要使用 nose 做同样的事情,我们只需要这些:

if __name__ == "__main__": 
    import nose 
    nose.run(argv=["", "recipe12", "--verbosity=2"]) 

这更加简洁。我们可以在这里使用nosetests的任何命令行选项。当我们使用 nose 插件时,这将非常方便,我们将在本章和本书的其余部分中更详细地探讨。

编写一个 nose 扩展来基于正则表达式选择测试

像 nose 这样的开箱即用的测试工具非常有用。但最终,我们会达到一个选项不符合我们需求的地步。Nose 具有编写自定义插件的强大能力,这使我们能够微调 nose 以满足我们的需求。这个示例将帮助我们编写一个插件,允许我们通过匹配测试方法的方法名使用正则表达式来选择性地选择测试方法,当我们运行nosetests时。

准备工作

我们需要加载easy_install以安装即将创建的 nose 插件。如果您还没有它,请访问pypi.python.org/pypi/setuptools下载并按照网站上的指示安装该软件包。

如果您刚刚安装了它,那么您将需要执行以下操作:

  • 重新构建用于运行本书中代码示例的virtualenv

  • 使用pip重新安装nose

如何做...

通过以下步骤,我们将编写一个 nose 插件,通过正则表达式选择要运行的测试方法:

  1. 创建一个名为recipe13.py的新文件,以存储此示例的代码。

  2. 创建一个购物车应用程序,我们可以围绕它构建一些测试:

class ShoppingCart(object):
   def __init__(self):
     self.items = []
   def add(self, item, price):
     self.items.append(Item(item, price))
     return self
   def item(self, index):
     return self.items[index-1].item
   def price(self, index):
     return self.items[index-1].price
   def total(self, sales_tax):
     sum_price = sum([item.price for item in self.items])
     return sum_price*(1.0 + sales_tax/100.0)
   def __len__(self):
     return len(self.items)
class Item(object):
   def __init__(self, item, price):
     self.item = item
     self.price = price
  1. 创建一个包含多个测试方法的测试用例,包括一个不以单词test开头的方法:
import unittest
class ShoppingCartTest(unittest.TestCase):
   def setUp(self):
     self.cart = ShoppingCart().add("tuna sandwich", 15.00)
   def length(self):
     self.assertEquals(1, len(self.cart))
   def test_item(self):
     self.assertEquals("tuna sandwich", self.cart.item(1))
   def test_price(self):
     self.assertEquals(15.00, self.cart.price(1))
   def test_total_with_sales_tax(self):
     self.assertAlmostEquals(16.39,
     self.cart.total(9.25), 2)
  1. 使用命令行中的nosetests运行模块,并打开verbosity。有多少个测试方法被运行?我们定义了多少个测试方法?

  1. 创建一个名为recipe13_plugin.py的新文件,为此配方编写一个鼻子插件。

  2. 捕获sys.stderr的句柄以支持调试和详细输出:

import sys 
err = sys.stderr 
  1. 通过子类化nose.plugins.Plugin创建一个名为RegexPicker的鼻子插件:
import nose
import re
from nose.plugins import Plugin
class RegexPicker(Plugin):
   name = "regexpicker"
   def __init__(self):
      Plugin.__init__(self)
      self.verbose = False

我们的鼻子插件需要一个类级别的名称。这用于定义with-<name>命令行选项。

  1. 覆盖Plugin.options并添加一个选项,在命令行上提供模式:
def options(self, parser, env):
    Plugin.options(self, parser, env)
    parser.add_option("--re-pattern",
       dest="pattern", action="store",
       default=env.get("NOSE_REGEX_PATTERN", "test.*"),
       help=("Run test methods that have a method name matching this regular expression"))
  1. 覆盖Plugin.configuration,使其获取模式和详细信息:
def configure(self, options, conf):
     Plugin.configure(self, options, conf)
     self.pattern = options.pattern
     if options.verbosity >= 2:
        self.verbose = True
        if self.enabled:
           err.write("Pattern for matching test methods is %sn" % self.pattern)

当我们扩展Plugin时,我们继承了一些其他功能,例如self.enabled,当使用鼻子的-with-<name>时会打开。

  1. 覆盖Plugin.wantedMethod,使其接受与我们的正则表达式匹配的测试方法:
def wantMethod(self, method):
   wanted =
     re.match(self.pattern, method.func_name) is not None
   if self.verbose and wanted:
      err.write("nose will run %sn" % method.func_name)
   return wanted

编写一个测试运行器,通过运行与我们之前运行的相同的测试用例来以编程方式测试我们的插件:

if __name__ == "__main__":
     args = ["", "recipe13", "--with-regexpicker", "--re-pattern=test.*|length", "--verbosity=2"]
     print "With verbosity..."
     print "===================="
     nose.run(argv=args, plugins=[RegexPicker()])
     print "Without verbosity..."
     print "===================="
     args = args[:-1]
     nose.run(argv=args, plugins=[RegexPicker()])
  1. 执行测试运行器。查看以下截图中的结果,这次运行了多少个测试方法?

  1. 创建一个setup.py脚本,允许我们安装并注册我们的插件到nosetests
import sys
try:
        import ez_setup
        ez_setup.use_setuptools()
except ImportError:
        pass
from setuptools import setup
setup(
        name="RegexPicker plugin",
        version="0.1",
        author="Greg L. Turnquist",
        author_email="Greg.L.Turnquist@gmail.com",
        description="Pick test methods based on a regular expression",
        license="Apache Server License 2.0",
        py_modules=["recipe13_plugin"],
        entry_points = {
            'nose.plugins': [
                'recipe13_plugin = recipe13_plugin:RegexPicker'
               ]
        }
)
  1. 安装我们的新插件:

  1. 从命令行使用--with-regexpicker运行nosetests

它是如何工作的...

编写鼻子插件有一些要求。首先,我们需要类级别的name属性。它在几个地方使用,包括定义用于调用我们的插件的命令行开关--with-<name>

接下来,我们编写options。没有要求覆盖Plugin.options,但在这种情况下,我们需要一种方法来为我们的插件提供正则表达式。为了避免破坏Plugin.options的有用机制,我们首先调用它,然后使用parser.add_option为我们的额外参数添加一行:

  • 第一个未命名的参数是参数的字符串版本,我们可以指定多个参数。如果我们想要的话,我们可以有-rp-re-pattern

  • Dest:这是存储结果的属性的名称(请参阅 configure)。

  • Action:这指定参数值的操作(存储,追加等)。

  • Default:这指定在未提供值时存储的值(请注意,我们使用test.*来匹配标准的 unittest 行为)。

  • Help:这提供了在命令行上打印的帮助信息。

鼻子使用 Python 的optparse.OptionParser库来定义选项。

要了解有关 Python 的optparse.OptionParser的更多信息,请参阅docs.python.org/library/optparse.html

然后,我们编写configure。没有要求覆盖Plugin.configure。因为我们有一个额外的选项--pattern,我们需要收集它。我们还想通过verbosity(一个标准的鼻子选项)来打开一个标志。

在编写鼻子插件时,我们可以做很多事情。在我们的情况下,我们想要聚焦于测试选择。有几种加载测试的方法,包括按模块和文件名。加载后,它们通过一个方法运行,该方法会投票赞成或反对它们。这些投票者被称为want*方法,它们包括wantModulewantNamewantFunctionwantMethod,还有一些其他方法。我们实现了wantMethod,在这里我们使用 Python 的re模块测试method.func_name是否与我们的模式匹配。want*方法有三种返回值类型:

  • True:这个测试是需要的。

  • False:此测试不需要(并且不会被另一个插件考虑)。

  • None:插件不关心另一个插件(或鼻子)是否选择。

通过不从want*方法返回任何内容来简洁地实现这一点。

wantMethod只查看在类内定义的函数。nosetests旨在通过许多不同的方法查找测试,并不仅限于搜索unittest.TestCase的子类。如果在模块中找到了测试,但不是作为类方法,那么这种模式匹配就不会被使用。为了使这个插件更加健壮,我们需要很多不同的测试,并且可能需要覆盖其他want*测试选择器。

还有更多...

这个食谱只是浅尝插件功能。它侧重于测试选择过程。

在本章后面,我们将探讨生成专门报告的方法。这涉及使用其他插件钩子,在每次测试运行后收集信息以及在测试套件耗尽后生成报告。Nose 提供了一组强大的钩子,允许详细定制以满足我们不断变化的需求。

插件应该是nose.plugins.Plugin的子类。

Plugin中内置了很多有价值的机制。子类化是开发插件的推荐方法。如果不这样做,您可能需要添加您没有意识到 nose 需要的方法和属性(当您子类化时会自动获得)。

一个很好的经验法则是子类化 nose API 的部分,而不是覆盖它。

nose API 的在线文档有点不完整。它倾向于假设太多的知识。如果我们覆盖了,但我们的插件没有正确工作,可能很难调试发生了什么。

不要子类化nose.plugins.IPluginInterface

这个类仅用于文档目的。它提供了关于我们的插件可以访问的每个钩子的信息。但它不是为了子类化真正的插件而设计的。

编写一个 nose 扩展来生成 CSV 报告

这个食谱将帮助我们编写一个生成自定义报告的插件,列出 CSV 文件中的成功和失败。它用于演示如何在每个测试方法完成后收集信息。

准备工作

我们需要加载easy_install以安装我们即将创建的 nose 插件。如果您还没有它,请访问pypi.python.org/pypi/setuptools下载并按照网站上的指示安装该软件包。

如果您刚刚安装了它,那么您将不得不执行以下操作:

  • 重新构建您用于运行本书中代码示例的virtualenv

  • 使用easy_install重新安装 nose

如何做...

  1. 创建一个名为recipe14.py的新文件,用于存储此食谱的代码。

  2. 创建一个购物车应用程序,我们可以围绕它构建一些测试:

class ShoppingCart(object):
   def __init__(self):
     self.items = [] 
   def add(self, item, price):
     self.items.append(Item(item, price))
     return self
   def item(self, index):
     return self.items[index-1].item
   def price(self, index):
     return self.items[index-1].price
   def total(self, sales_tax):
     sum_price = sum([item.price for item in self.items])
     return sum_price*(1.0 + sales_tax/100.0)
   def __len__(self):
     return len(self.items)
class Item(object):
   def __init__(self, item, price):
     self.item = item
     self.price = price
  1. 创建一个包含多个测试方法的测试用例,包括一个故意设置为失败的测试方法:
import unittest
class ShoppingCartTest(unittest.TestCase):
    def setUp(self):
      self.cart = ShoppingCart().add("tuna sandwich", 15.00)
    def test_length(self):
      self.assertEquals(1, len(self.cart))
    def test_item(self):
      self.assertEquals("tuna sandwich", self.cart.item(1))
    def test_price(self):
      self.assertEquals(15.00, self.cart.price(1))
    def test_total_with_sales_tax(self):
      self.assertAlmostEquals(16.39,
      self.cart.total(9.25), 2)
    def test_assert_failure(self):
      self.fail("You should see this failure message in the report.")
  1. 从命令行使用nosetests运行模块。查看下面的截图输出,是否存在 CSV 报告?

  1. 创建一个名为recipe14_plugin.py的新文件,用于存储我们的新 nose 插件。

  2. 通过子类化nose.plugins.Plugin创建一个名为CsvReport的 nose 插件:

import nose
import re
from nose.plugins import Plugin
class CsvReport(Plugin):
    name = "csv-report"
    def __init__(self):
      Plugin.__init__(self)
      self.results = []

我们的 nose 插件需要一个类级别的name。这用于定义-with-<name>命令行选项。

  1. 覆盖Plugin.options并添加一个选项,在命令行上提供报告的文件名:
def options(self, parser, env):
  Plugin.options(self, parser, env)
  parser.add_option("--csv-file",
    dest="filename", action="store",
    default=env.get("NOSE_CSV_FILE", "log.csv"),
    help=("Name of the report"))
  1. 通过让它从选项中获取文件名来覆盖Plugin.configuration
def configure(self, options, conf):
  Plugin.configure(self, options, conf)
  self.filename = options.filename

当我们扩展Plugin时,我们会继承一些其他功能,比如self.enabled,当使用 nose 的-with-<name>时会打开。

  1. 覆盖addSuccessaddFailureaddError以在内部列表中收集结果:
def addSuccess(self, *args, **kwargs):
  test = args[0]
  self.results.append((test, "Success"))
def addError(self, *args, **kwargs):
  test, error = args[0], args[1]
  self.results.append((test, "Error", error))
def addFailure(self, *args, **kwargs):
  test, error = args[0], args[1]
  self.results.append((test, "Failure", error))
  1. 覆盖finalize以生成 CSV 报告:
def finalize(self, result):
   report = open(self.filename, "w")
   report.write("Test,Success/Failure,Detailsn")
   for item in self.results:
       if item[1] == "Success":
           report.write("%s,%sn" % (item[0], item[1]))
       else:
           report.write("%s,%s,%sn" % (item[0],item[1], item[2][1]))
    report.close()
  1. 编写一个测试运行器,通过运行与我们之前运行的相同的测试用例来以编程方式测试我们的插件:
if __name__ == "__main__":
   args = ["", "recipe14", "--with-csv-report", "--csv-file=recipe14.csv"]
nose.run(argv=args, plugin=[CsvReport()])
  1. 执行测试运行器。查看下一个截图输出,现在是否有测试报告?

  1. 使用您喜欢的电子表格打开并查看报告:

  1. 创建一个setup.py脚本,允许我们安装并注册我们的插件到nosetests
import sys
try:
   import ez_setup
   ez_setup.use_setuptools()
except ImportError:
   pass
from setuptools import setup
setup(
   name="CSV report plugin",
   version="0.1",
   author="Greg L. Turnquist",
   author_email="Greg.L.Turnquist@gmail.com",
   description="Generate CSV report",
   license="Apache Server License 2.0",
   py_modules=["recipe14_plugin"],
   entry_points = {
       'nose.plugins': [
           'recipe14_plugin = recipe14_plugin:CsvReport'
         ]
   }
)
  1. 安装我们的新插件:

  1. 从命令行运行nosetests,使用--with-csv-report

在上一个截图中,注意我们有先前的日志文件recipe14.csv和新的日志文件log.csv

它是如何工作的...

编写 nose 插件有一些要求。首先,我们需要类级别的name属性。它在几个地方使用,包括定义用于调用我们的插件的命令行开关,--with-<name>

接下来,我们编写options。没有必要覆盖Plugin.options。但在这种情况下,我们需要一种方法来提供我们的插件将写入的 CSV 报告的名称。为了避免破坏Plugin.options的有用机制,我们首先调用它,然后使用parser.add_option添加我们额外参数的行:

  • 未命名的参数是参数的字符串版本

  • dest:这是存储结果的属性的名称(参见 configure)

  • action:这告诉参数值要执行的操作(存储、追加等)

  • default:这告诉了当没有提供值时要存储什么值

  • help:这提供了在命令行上打印的帮助信息

Nose 使用 Python 的optparse.OptionParser库来定义选项。

要了解更多关于optparse.OptionParser的信息,请访问docs.python.org/optparse.html

然后,我们编写configure。同样,没有必要覆盖Plugin.configure。因为我们有一个额外的选项--csv-file,我们需要收集它。

在这个配方中,我们希望在测试方法完成时捕获测试用例和错误报告。为此,我们实现addSuccessaddFailureaddError,因为 nose 在以编程方式调用或通过命令行调用这些方法时发送的参数不同,所以我们必须使用 Python 的*args

  • 这个元组的第一个槽包含test,一个nose.case.Test的实例。简单地打印它对我们的需求就足够了。

  • 这个元组的第二个槽包含error,是sys.exc_info()的 3 元组实例。它仅包括在addFailureaddError中。

  • nose 网站上没有更多关于这个元组的槽的文档。我们通常忽略它们。

还有更多...

这个配方深入探讨了插件功能。它侧重于在测试方法成功、失败或导致错误后进行的处理。在我们的情况下,我们只是收集结果以放入报告中。我们还可以做其他事情,比如捕获堆栈跟踪,将失败的邮件发送给开发团队,或者向 QA 团队发送页面,让他们知道测试套件已经完成。

有关编写 nose 插件的更多详细信息,请阅读编写nose扩展的配方,以根据正则表达式选择测试。

编写一个项目级别的脚本,让您运行不同的测试套件

Python 以其多范式的特性,使得构建应用程序并提供脚本支持变得容易。

这个配方将帮助我们探索构建一个项目级别的脚本,允许我们运行不同的测试套件。我们还将展示一些额外的命令行选项,以创建用于打包、发布、注册和编写自动文档的钩子。

如何做...

  1. 创建一个名为recipe15.py的脚本,使用 Python 的getopt库解析一组选项:
import getopt
import glob
import logging
import nose
import os
import os.path
import pydoc
import re
import sys
def usage():
    print
    print "Usage: python recipe15.py [command]"
    print
    print "t--help"
    print "t--test"
    print "t--suite [suite]"
    print "t--debug-level [info|debug]"
    print "t--package"
    print "t--publish"
    print "t--register"
    print "t--pydoc"
    print
try:
    optlist, args = getopt.getopt(sys.argv[1:],
                    "ht", 
                    ["help", "test", "suite=",
                    "debug-level=", "package",
                    "publish", "register", "pydoc"])
except getopt.GetoptError:
    # print help information and exit:
    print "Invalid command found in %s" % sys.argvusage()
    sys.exit(2)
  1. 创建一个映射到-test的函数:
def test(test_suite, debug_level):
    logger = logging.getLogger("recipe15")
    loggingLevel = debug_level
    logger.setLevel(loggingLevel)
    ch = logging.StreamHandler()
    ch.setLevel(loggingLevel)
    formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s -
%(message)s")
    ch.setFormatter(formatter)
    logger.addHandler(ch)
    nose.run(argv=["", test_suite, "--verbosity=2"])
  1. 创建支持packagepublishregister的存根函数:
def package():
    print "This is where we can plug in code to run " +
    "setup.py to generate a bundle."
def publish():
    print "This is where we can plug in code to upload " +
          "our tarball to S3 or some other download site."
def register():
    print "setup.py has a built in function to " +
          "'register' a release to PyPI. It's " +
          "convenient to put a hook in here."
    # os.system("%s setup.py register" % sys.executable)
  1. 创建一个函数,使用 Python 的pydoc模块自动生成文档:
def create_pydocs():
    print "It's useful to use pydoc to generate docs."
    pydoc_dir = "pydoc"
    module = "recipe15_all"
    __import__(module)
    if not os.path.exists(pydoc_dir):
        os.mkdir(pydoc_dir)
    cur = os.getcwd()
    os.chdir(pydoc_dir)
    pydoc.writedoc("recipe15_all")
    os.chdir(cur)
  1. 添加一些代码,定义调试级别,然后解析选项以允许用户进行覆盖:
debug_levels = {"info":logging.INFO, "debug":logging.DEBUG}
# Default debug level is INFO
debug_level = debug_levels["info"]
for option in optlist:
    if option[0] in ("--debug-level"):
        # Override with a user-supplied debug level
        debug_level = debug_levels[option[1]]
  1. 添加一些代码,扫描命令行选项以查找-help,如果找到,则退出脚本:
# Check for help requests, which cause all other
# options to be ignored.
for option in optlist:
if option[0] in ("--help", "-h"):
   usage()
   sys.exit(1)
  1. 通过迭代每个命令行选项并根据选择了哪些选项来调用其他函数来完成它:
# Parse the arguments, in order
for option in optlist:
    if option[0] in ("--test"):
       print "Running recipe15_checkin tests..."
       test("recipe15_checkin", debug_level)
    if option[0] in ("--suite"):
       print "Running test suite %s..." % option[1]
       test(option[1], debug_level)
    if option[0] in ("--package"):
       package()
    if option[0] in ("--publish"):
       publish()
    if option[0] in ("--register"):
       register()
    if option[0] in ("--pydoc"):
       create_pydocs()
  1. 使用-help运行recipe15.py脚本:

  1. 创建一个名为recipe15_checkin.py的新文件来创建一个新的测试套件。

  2. 重用获取nosywith**testing食谱中的测试用例来定义一个check``in测试套件:

import recipe11 

class Recipe11Test(recipe11.ShoppingCartTest): 
    pass 
  1. 使用-test -package -publish -register -pydoc运行recipe15.py脚本。在下面的屏幕截图中,您是否注意到它如何按照在命令行上提供的相同顺序来执行每个选项?

  1. 检查在pydoc目录中生成的报告:

  1. 创建一个名为recipe15_all.py的新文件来定义另一个新的测试套件。

  2. 重用本章早期食谱的测试代码来定义一个all测试套件:

import recipe11
import recipe12
import recipe13
import recipe14
class Recipe11Test(recipe11.ShoppingCartTest):
    pass
class Recipe12Test(recipe12.ShoppingCartTest):
    pass
class Recipe13Test(recipe13.ShoppingCartTest):
    pass
class Recipe14Test(recipe14.ShoppingCartTest):
    pass
  1. 使用-suite=recipe15_all运行recipe15.py脚本:

它是如何工作的...

该脚本使用 Python 的getopt库,该库是模仿 C 编程语言的getopt()函数而建立的。这意味着我们使用 API 来定义一组命令,然后迭代选项,调用相应的函数:

访问docs.python.org/library/getopt.html了解更多关于getopt库的详细信息。

  • usage:这是一个为用户提供帮助的函数。

  • :选项定义包含在以下块中:

optlist, args = getopt.getopt(sys.argv[1:],
                "ht",
                ["help", "test", "suite=",
                "debug-level=", "package",
                "publish", "register", "pydoc"])

我们解析除第一个参数之外的所有参数,因为这是可执行文件本身:

  • "ht"定义了短选项:-h-t

  • 该列表定义了长选项。带有"="的选项接受参数。没有"="的选项是标志。

  • 如果收到不在列表中的选项,就会抛出异常;我们打印出usage(),然后退出。

  • 测试:这激活了记录器,如果我们的应用程序使用 Python 的logging库,这将非常有用。

  • :这生成 tarballs。我们创建了一个存根,但通过运行setup.py sdist|bdist提供一个快捷方式会很方便。

  • 发布:它的功能是将 tarballs 推送到部署站点。我们创建了一个存根,但将其部署到 S3 站点或其他地方是有用的。

  • 注册:这是与 PyPI 注册。我们创建了一个存根,但提供一个快捷方式运行setup.py register会很方便。

  • create_pydocs:这些是自动生成的文档。基于代码生成 HTML 文件非常方便。

定义了这些功能后,我们可以迭代解析的选项。对于这个脚本,有一个如下的顺序:

  1. 检查是否有调试覆盖。我们默认为logging.INFO,但提供切换到logging.DEBUG的能力。

  2. 检查是否调用了-h-help。如果是,打印出usage()信息,然后退出,不再解析。

  3. 最后,迭代选项并调用它们对应的函数。

为了练习,我们首先使用-help选项调用了这个脚本。这打印出了我们的命令选择。

然后我们使用所有选项调用它来演示功能。当我们使用-test时,脚本被编码为执行check in套件。这个简短的测试套件模拟了运行一个更快的测试,旨在查看事情是否正常。

最后,我们使用-suite=recipe15_all调用了脚本。这个测试套件模拟了运行一个更完整的测试套件,通常需要更长时间。

还有更多...

该脚本提供的功能可以很容易地通过已经构建的命令来处理。我们在本章前面看过nosetests,并且知道它可以灵活地接受参数来选择测试。

使用setup.py生成 tarballs 并注册发布也是 Python 社区中常用的功能。

那么,为什么要写这个脚本呢?因为我们可以通过一个单一的命令脚本利用所有这些功能,setup.py包含了一组预先构建的命令,涉及打包和上传到 Python 项目索引。执行其他任务,比如生成pydocs,部署到像 Amazon S3 桶这样的位置,或者任何其他系统级任务,都不包括在内。这个脚本演示了如何轻松地引入其他命令行选项,并将它们与项目管理功能链接起来。

我们还可以方便地嵌入pydoc的使用。基本上,任何满足项目管理需求的 Python 库也可以被嵌入。

在一个现有的项目中,我开发了一个脚本,以统一的方式将版本信息嵌入到一个模板化的setup.py以及由pydocsphinxDocBook生成的文档中。这个脚本让我不必记住管理项目所需的所有命令。

为什么我不扩展distutils来创建自己的命令?这是一个品味的问题。我更喜欢使用getopt,并在distutils框架之外工作,而不是创建和注册新的子命令。

为什么使用getopt而不是optparse

Python 有几种处理命令行选项解析的选项。getopt可能是最简单的。它旨在快速定义短选项和长选项,但它有限制。它需要自定义编码帮助输出,就像我们在使用函数中所做的那样。

它还需要对参数进行自定义处理。optparse提供了更复杂的选项,比如更好地处理参数和自动构建帮助。但它也需要更多的代码来实现功能。optparse也计划在未来被argparse取代。

你可以尝试用optparse写一个这个脚本的替代版本,来评估哪一个是更好的解决方案。

第三章:使用 doctest 创建可测试的文档

在本章中,我们将介绍以下配方:

  • 记录基础知识

  • 捕获堆栈跟踪

  • 从命令行运行 doctest

  • 为 doctest 编写测试工具

  • 过滤测试噪音

  • 打印出所有文档,包括状态报告。

  • 测试边缘情况

  • 通过迭代测试边缘情况

  • 使用 doctest 进行调试

  • 更新项目级脚本以运行本章的 doctest

介绍

Python 提供了一种在函数内部嵌入注释的有用能力,可以从 Python shell 中访问。这些被称为文档字符串

文档字符串不仅提供了嵌入信息的能力,还提供了可运行的代码示例。

有一句古谚说“注释不是代码”。这是因为注释不经过语法检查,通常不会被维护。因此,它们携带的信息随着时间的推移可能会失去其价值。doctest通过将注释转换为代码来解决这个问题,这可以有很多有用的用途。

在本章中,我们将探讨使用doctest开发测试、文档和项目支持的不同方法。不需要特殊设置,因为doctest是 Python 标准库的一部分。

记录基础知识

Python 提供了一种在代码中放置注释的开箱即用的能力,称为文档字符串。查看源代码和从 Python shell 交互检查代码时,可以阅读文档字符串。在本配方中,我们将演示如何使用这些交互式文档字符串作为可运行的测试。

这提供了什么?它为用户提供了易于阅读的代码示例。这些代码示例不仅易于阅读,而且可以运行,这意味着我们可以确保文档保持最新。

如何做...

通过以下步骤,我们将创建一个应用程序,其中包含可运行的文档字符串注释,并看看如何执行这些测试:

  1. 创建一个名为recipe16.py的新文件,以放置我们为此配方编写的所有代码。

  2. 创建一个函数,使用递归将十进制数转换为任何其他进制:

def convert_to_basen(value, base):
    import math
    def _convert(remaining_value, base, exp):
        def stringify(value):
            if value > 9:
                return chr(value + ord('a')-10)
            else:
                return str(value)
        if remaining_value >= 0 and exp >= 0:
            factor = int(math.pow(base, exp))
            if factor <= remaining_value:
                multiple = remaining_value / factor
                return stringify(multiple) + \
                  _convert(remaining_value-multiple*factor, \
                    base, exp-1)
        else:
            return "0" + \
                _convert(remaining_value, base, exp-1)
        else:
            return ""
    return "%s/%s" % (_convert(value, base, \
                int(math.log(value, base))), base)
  1. 在外部函数的下面添加一个文档字符串,如下面代码的突出部分所示。这个文档字符串声明包括使用该函数的几个示例:
def convert_to_basen(value, base):
    """Convert a base10 number to basen
 >>> convert_to_basen(1, 2)
 '1/2'
 >>> convert_to_basen(2, 2)
 '10/2'
 >>> convert_to_basen(3, 2)
 '11/2'
 >>> convert_to_basen(4, 2)
 '100/2'
 >>> convert_to_basen(5, 2)
 '101/2'
 >>> convert_to_basen(6, 2)
 '110/2'
 >>> convert_to_basen(7, 2)
 '111/2'
 >>> convert_to_basen(1, 16)
 '1/16'
 >>> convert_to_basen(10, 16)
 'a/16'
 >>> convert_to_basen(15, 16)
 'f/16'
 >>> convert_to_basen(16, 16)
 '10/16'
 >>> convert_to_basen(31, 16)
 '1f/16'
 >>> convert_to_basen(32, 16)
 '20/16'
 """
    import math
  1. 添加一个测试运行器块,调用 Python 的doctest模块:
if __name__ == "__main__":
    import doctest
    doctest.testmod()
  1. 从交互式 Python shell 导入配方并查看其文档。看看这个截图:

  1. 从命令行运行代码。在下面的截图中,请注意没有任何内容被打印出来。这就是当所有测试都通过时会发生的情况。看看这个截图:

  1. 从命令行运行代码,使用-v增加详细程度。在下面的截图中,我们看到了一部分输出,显示了运行的内容和预期的内容。在调试doctest时,这可能很有用:

它是如何工作的...

doctest模块查找文档字符串中的 Python 代码块,并像真正的代码一样运行它。>>>是我们在使用交互式 Python shell 时看到的相同提示。>>>后面的行显示了预期的输出。doctest运行它看到的语句,然后将实际输出与预期输出进行比较。

在本章的后面,我们将看到如何捕获堆栈跟踪、错误,并添加额外的代码,相当于测试装置。

还有更多...

doctest在匹配预期输出和实际结果时非常挑剔:

  • 多余的空格或制表符可能会导致出现问题。

  • 诸如字典之类的结构很难测试,因为 Python 不能保证项目的顺序。在每次测试运行时,项目可能以不同的顺序存储。简单地打印出一个字典肯定会出错。

  • 强烈建议不要在预期输出中包含对象引用。这些值每次运行测试时也会变化。

捕获堆栈跟踪

一个常见的谬论是我们只应该为成功的代码路径编写测试。我们还需要针对包括生成堆栈跟踪的错误条件编写代码。通过这个示例,我们将探讨如何在文档测试中模式匹配堆栈跟踪,从而允许我们确认预期的错误。

如何做...

通过以下步骤,我们将看到如何使用doctest来验证错误条件:

  1. 为此示例中的所有代码创建一个名为recipe17.py的新文件。

  2. 创建一个函数,使用递归将十进制数转换为任何其他进制:

def convert_to_basen(value, base):
    import math
    def _convert(remaining_value, base, exp):
        def stringify(value):
            if value > 9:
                return chr(value + ord('a')-10)
            else:
                return str(value)
        if remaining_value >= 0 and exp >= 0:
            factor = int(math.pow(base, exp))
            if factor <= remaining_value:
                multiple = remaining_value / factor
                return stringify(multiple) + \
                    _convert(remaining_value-multiple*factor, \
                                base, exp-1)
            else:
                return "0" + \
                    _convert(remaining_value, base, exp-1)
        else:
            return ""
    return "%s/%s" % (_convert(value, base, \
                int(math.log(value, base))), base)
  1. 在外部函数声明的下方添加一个文档字符串,其中包含两个预期生成堆栈跟踪的示例:
def convert_to_basen(value, base):
    """Convert a base10 number to basen.

 >>> convert_to_basen(0, 2)
 Traceback (most recent call last):
 ...
 ValueError: math domain error

 >>> convert_to_basen(-1, 2)
 Traceback (most recent call last):
 ...
 ValueError: math domain error
 """
    import math
  1. 添加一个测试运行器块,调用 Python 的doctest模块:
if __name__ == "__main__":
    import doctest
    doctest.testmod()
  1. 从命令行运行代码。在下面的截图中,请注意没有打印任何内容。这是当所有测试都通过时发生的情况:

  1. 使用-v从命令行运行代码以增加详细信息。在下面的截图中,我们可以看到0-1生成了数学域错误。这是由于使用math.log来找到起始指数:

它是如何工作的...

doctest模块查找文档字符串中的 Python 代码块,并像真正的代码一样运行它。>>>是我们在交互式 Python shell 中使用时看到的相同提示。>>>后面的行显示了预期的输出。doctest运行它看到的语句,然后将实际输出与预期输出进行比较。

关于堆栈跟踪,堆栈跟踪提供了大量详细信息。模式匹配整个跟踪是无效的。通过使用省略号,我们能够跳过堆栈跟踪的中间部分,只匹配区分部分:ValueError: math domain error

这是有价值的,因为我们的用户不仅会看到它如何处理良好的值,还会观察到在提供坏值时可以期望什么错误。

从命令行运行doctest

我们已经看到了如何通过在文档字符串中嵌入可运行的代码片段来开发测试。但是对于这些测试中的每一个,我们都必须使模块可运行。如果我们想要从命令行运行除了我们的doctest之外的其他内容怎么办?我们将不得不摆脱doctest.testmod()语句!

好消息是,从 Python 2.6 开始,有一个命令行选项可以在不编写运行器的情况下运行特定模块的doctest

python -m doctest -v example.py命令将导入example.py并通过doctest.testmod()运行它。根据文档,如果模块是包的一部分并导入其他子模块,则可能会失败。

如何做...

在以下步骤中,我们将创建一个简单的应用程序。我们将添加一些 doctests,然后从命令行运行它们,而无需编写特殊的测试运行器:

  1. 创建一个名为recipe18.py的新文件,用于存储为此示例编写的代码。

  2. 创建一个函数,使用递归将十进制数转换为任何其他进制:

def convert_to_basen(value, base):
    import math
    def _convert(remaining_value, base, exp):
        def stringify(value):
            if value > 9:
                return chr(value + ord('a')-10)
            else:
                return str(value)
        if remaining_value >= 0 and exp >= 0:
            factor = int(math.pow(base, exp))
            if factor <= remaining_value:
                multiple = remaining_value / factor
                return stringify(multiple) + \
                  _convert(remaining_value-multiple*factor, \
                                base, exp-1)
            else:
                return "0" + \
                       _convert(remaining_value, base, exp-1)
        else:
            return ""
    return "%s/%s" % (_convert(value, base, \
                         int(math.log(value, base))), base)
  1. 在外部函数声明的下方添加一个文档字符串,其中包含一些测试:
def convert_to_basen(value, base):
    """Convert a base10 number to basen.
 >>> convert_to_basen(10, 2)
 '1010/2'
 >>> convert_to_basen(15, 16)
 'f/16'
 >>> convert_to_basen(0, 2)
 Traceback (most recent call last):
 ...
 ValueError: math domain error
 >>> convert_to_basen(-1, 2)
 Traceback (most recent call last):
 ...
 ValueError: math domain error
 """
    import math
  1. 使用-m doctest从命令行运行代码。如下面的截图所示,没有输出表示所有测试都已通过:

  1. 使用-v从命令行运行代码以增加详细信息。如果我们忘记包含-m doctest会发生什么?使用-v选项可以帮助我们避免这种情况,因为它给我们一种温暖的感觉,让我们知道我们的测试正在工作。看一下这个截图:

它是如何工作的...

在上一章中,我们正在使用模块的__main__块来运行其他测试套件。如果我们想在这里做同样的事情怎么办?我们必须选择__main__是用于单元测试、doctests 还是两者兼而有之!如果我们甚至不想通过__main__运行测试,而是运行我们的应用程序怎么办?

这就是为什么 Python 添加了使用 -m doctest 从命令行直接调用测试的选项。

你难道不想知道你的测试是否正在运行或工作吗?测试套件是否真的在做它承诺的事情?使用其他工具,通常我们必须嵌入打印语句,或者故意失败,只是为了知道事情被正确地捕获了。看起来doctest中的-v选项提供了一个方便的快速浏览正在发生的事情的方式,不是吗?

doctest编写一个测试工具

到目前为止,我们编写的测试非常简单,因为我们正在测试的函数很简单。有两个输入和一个输出,没有副作用。不需要创建对象。这对我们来说并不是最常见的用例。通常,我们有与其他对象交互的对象。

doctest 模块支持创建对象、调用方法和检查结果。通过这个示例,我们将更详细地探讨这个问题。

doctest的一个重要方面是它找到文档字符串的各个实例,并在本地上下文中运行它们。在一个文档字符串中声明的变量不能在另一个文档字符串中使用。

如何做...

  1. 创建一个名为recipe19.py的新文件,包含这个示例的代码。

  2. 编写一个简单的购物车应用程序:

class ShoppingCart(object):
    def __init__(self):
        self.items = []
    def add(self, item, price):
        self.items.append(Item(item, price))
        return self
    def item(self, index):
        return self.items[index-1].item
    def price(self, index):
        return self.items[index-1].price
    def total(self, sales_tax):
        sum_price = sum([item.price for item in self.items])
        return sum_price*(1.0 + sales_tax/100.0)
    def __len__(self):
        return len(self.items)
class Item(object):
    def __init__(self, item, price):
        self.item = item
        self.price = price
  1. ShoppingCart类声明之前,在模块顶部插入一个文档字符串:
"""
This is documentation for the this entire recipe.
With it, we can demonstrate usage of the code.

>>> cart = ShoppingCart().add("tuna sandwich", 15.0)
>>> len(cart)
1
>>> cart.item(1)
'tuna sandwich'
>>> cart.price(1)
15.0
>>> print (round(cart.total(9.25), 2))
16.39
"""
class ShoppingCart(object):
...
  1. 使用-m doctest-v进行运行:

  1. 将我们刚刚从recipe19.py中编写的所有代码复制到一个名为recipe19b.py的新文件中。

  2. recipe19b.py中,在模块顶部定义cart变量后添加另一个文档字符串:

def item(self, index):
    """
    >>> cart.item(1)
    'tuna sandwich'
    """
    return self.items[index-1].item
  1. 运行这个示例的变体。为什么它失败了?cart不是在之前的文档字符串中声明的吗?看一下这个截图:

工作原理...

doctest模块查找每个文档字符串。对于它找到的每个文档字符串,它都会创建模块全局变量的浅拷贝,然后运行代码并检查结果。除此之外,每个创建的变量都是局部作用域的,当测试完成时会被清除。这意味着我们稍后添加的第二个文档字符串无法看到我们在第一个文档字符串中创建的cart。这就是为什么第二次运行失败的原因。

与一些 unittest 示例中使用的setUp方法相比,doctest没有等价的方法。如果doctest没有setUp选项,那么这个示例有什么价值呢?它突显了所有开发人员在使用之前必须了解的doctest的一个关键限制。

还有更多...

doctest 模块提供了一种非常方便的方式来为我们的文档添加可测试性。但这并不能替代完整的测试框架,比如 unittest。正如前面所述,没有setUp的等价物。在文档字符串中嵌入的 Python 代码也没有语法检查。

doctest 的正确级别与 unittest(或者我们可能选择的任何其他测试框架)混合在一起是一个判断的问题。

过滤测试噪音

各种选项帮助doctest忽略噪音,比如在测试用例中的空白。这是有用的,因为它允许我们更好地结构化预期的结果,以便用户更容易阅读。

我们还可以标记一些可以跳过的测试。这可以用在我们想要记录已知问题,但尚未修补系统的地方。

当我们试图进行全面测试但专注于系统的其他部分时,这两种情况都很容易被解释为噪音。在这个示例中,我们将深入研究如何放宽doctest的严格检查。我们还将看看如何忽略整个测试,无论是临时的还是永久的。

如何做...

通过以下步骤,我们将尝试过滤测试结果并放宽doctest的某些限制:

  1. 为这个示例的代码创建一个名为recipe20.py的新文件。

  2. 创建一个递归函数,将十进制数转换为其他进制:

def convert_to_basen(value, base):
    import math
    def _convert(remaining_value, base, exp):
        def stringify(value):
            if value > 9:
                return chr(value + ord('a')-10)
            else:
                return str(value)

        if remaining_value >= 0 and exp >= 0:
            factor = int(math.pow(base, exp))
            if factor <= remaining_value:
                multiple = remaining_value / factor
                return stringify(multiple) + \
                  _convert(remaining_value-multiple*factor, \
                                base, exp-1)
            else:
                return "0" + \
                       _convert(remaining_value, base, exp-1)
        else:
            return ""
    return "%s/%s" % (_convert(value, base, \
                         int(math.log(value, base))), base)
  1. 添加一个包含一系列值的测试来练习的文档字符串,以及记录一个尚未实现的未来功能:
def convert_to_basen(value, base):
    """Convert a base10 number to basen.

 >>> [convert_to_basen(i, 16) for i in range(1,16)] #doctest:
+NORMALIZE_WHITESPACE
 ['1/16', '2/16', '3/16', '4/16', '5/16', '6/16', '7/16', '8/16',
 '9/16',  'a/16', 'b/16', 'c/16', 'd/16', 'e/16', 'f/16']

 FUTURE: Binary may support 2's complement in the future, but not
now.
 >>> convert_to_basen(-10, 2) #doctest: +SKIP
 '0110/2'
 """
    import math
  1. 添加一个测试运行程序:
if __name__ == "__main__":
    import doctest
    doctest.testmod()
  1. 以详细模式运行测试用例,如此截图所示:

  1. recipe20.py中的代码复制到一个名为recipe20b.py的新文件中。

  2. 通过更新文档字符串来编辑recipe20b.py,包括另一个测试,显示我们的函数不会转换0

def convert_to_basen(value, base):
    """Convert a base10 number to basen.
    >>> [convert_to_basen(i, 16) for i in range(1,16)] #doctest:
+NORMALIZE_WHITESPACE
    ['1/16', '2/16', '3/16', '4/16', '5/16', '6/16', '7/16', '8/16',
    '9/16',  'a/16', 'b/16', 'c/16', 'd/16', 'e/16', 'f/16']
    FUTURE: Binary may support 2's complement in the future, but not
now.
    >>> convert_to_basen(-10, 2) #doctest: +SKIP
    '0110/2'
    BUG: Discovered that this algorithm doesn't handle 0\. Need to patch
it.
 TODO: Renable this when patched.
 >>> convert_to_basen(0, 2)
 '0/2'
 """
    import math
  1. 运行测试用例。注意这个版本的配方有什么不同之处,以及为什么它失败了?看一下这个截图:

  1. recipe20b.py中的代码复制到一个名为recipe20c.py的新文件中。

  2. 编辑recipe20c.py并更新文档字符串,指示我们现在将跳过测试:

def convert_to_basen(value, base): 
    """Convert a base10 number to basen. 

    >>> [convert_to_basen(i, 16) for i in range(1,16)] #doctest: +NORMALIZE_WHITESPACE 
    ['1/16', '2/16', '3/16', '4/16', '5/16', '6/16', '7/16', '8/16', 
    '9/16',  'a/16', 'b/16', 'c/16', 'd/16', 'e/16', 'f/16'] 

    FUTURE: Binary may support 2's complement in the future, but not now. 
    >>> convert_to_basen(-10, 2) #doctest: +SKIP 
    '0110/2' 

    BUG: Discovered that this algorithm doesn't handle 0\. Need to patch it. 
    TODO: Renable this when patched. 
    >>> convert_to_basen(0, 2) #doctest: +SKIP 
    '0/2' 
    """ 
    import math
  1. 运行测试用例。看一下这个截图:

它是如何工作的...

在这个配方中,我们重新审视了从十进制转换为任意进制数字的函数。第一个测试显示它在一个范围内运行。通常,Python 会将这个结果数组放在一行上。为了使其更易读,我们将输出分布在两行上。我们还在值之间放了一些任意的空格,以使列更好地对齐。

这是doctest绝对不会支持的事情,因为它严格的模式匹配性质。通过使用#doctest: +NORMALIZE_WHITESPACE,我们能够要求doctest放宽这个限制。仍然有约束。例如,预期数组中的第一个值不能有任何空格在它前面(相信我,我试过了,为了最大的可读性!)但是将数组包装到下一行不再破坏测试。

我们还有一个测试用例,实际上只是作为文档。它指示了一个未来的要求,显示了我们的函数如何处理负二进制值。通过添加#doctest: +SKIP,我们能够命令doctest跳过这个特定的实例。

最后,我们看到了一个情景,我们发现我们的代码不能处理0。由于算法通过取对数得到最高指数,存在一个数学问题。我们通过一个测试来捕获这个边缘情况。然后我们确认代码以经典的测试驱动设计TDD)方式失败。最后一步将是修复代码以处理这个边缘情况。但我们决定,以一种有点牵强的方式,我们没有足够的时间在当前的迭代中修复代码。为了避免破坏我们的持续集成CI)服务器,我们用一个TO-DO语句标记测试,并添加#doctest: +SKIP

还有更多...

我们用#doctest: +SKIP标记的两种情况都是最终我们希望移除SKIP标记并让它们运行的情况。可能还有其他情况我们永远不会移除SKIP。代码演示可能有很大的波动,可能无法轻易测试而不使其难以阅读。例如,返回字典的函数更难测试,因为结果的顺序会变化。我们可以弯曲它以通过测试,但我们可能会失去文档的价值,以便呈现给读者。

打印出所有的文档,包括状态报告

由于本章涉及文档和测试,让我们构建一个脚本,它接受一组模块并打印出一个完整的报告,显示所有文档以及运行任何给定的测试。

这是一个有价值的配方,因为它向我们展示了如何使用 Python 的 API 来收集一个基于代码的可运行报告。这意味着文档是准确的,也是最新的,反映了我们代码的当前状态。

如何做...

在接下来的步骤中,我们将编写一个应用程序和一些doctests。然后我们将构建一个脚本来收集一个有用的报告:

  1. 创建一个名为recipe21_report.py的新文件,用于包含收集报告的脚本。

  2. 通过导入 Python 的inspect库来创建一个脚本,作为深入模块的基础:from inspect import*

  3. 添加一个函数,专注于打印出一个项目的__doc__字符串或打印出未找到文档的消息:

def print_doc(name, item):
    if item.__doc__:
        print "Documentation for %s" % name
        print "-------------------------------"
        print item.doc
        print "-------------------------------"
    else:
        print "Documentation for %s - None" % name
  1. 添加一个函数,根据给定模块打印出文档。确保这个函数查找类、方法和函数,并打印出它们的文档:
def print_docstrings(m, prefix=""):
    print_doc(prefix + "module %s" % m.__name__, m)

    for (name, value) in getmembers(m, isclass):
        if name == '__class__': continue
        print_docstrings(value, prefix=name + ".")
    for (name, value) in getmembers(m, ismethod):
        print_doc("%s%s()" % (prefix, name), value)
    for (name, value) in getmembers(m, isfunction):
        print_doc("%s%s()" % (prefix, name), value)
  1. 添加一个解析命令行字符串并迭代每个提供的模块的运行器:
if __name__ == "__main__":
    import sys
    import doctest

    for arg in sys.argv[1:]:
        if arg.startswith("-"): continue
        print "==============================="
        print "== Processing module %s" % arg
        print "==============================="
        m = __import__(arg)
        print_docstrings(m)
        print "Running doctests for %s" % arg
        print "-------------------------------"
        doctest.testmod(m)
  1. 创建一个新文件recipe21.py,其中包含一个我们将对之前的脚本运行的应用程序和测试。

  2. recipe21.py中,创建一个购物车应用程序,并填充它的文档字符串和doctests。这是整个食谱的文档。有了它,我们可以演示代码的用法:

>>> cart = ShoppingCart().add("tuna sandwich", 15.0)
>>> len(cart)
1
>>> cart.item(1)
'tuna sandwich'
>>> cart.price(1)
15.0
>>> print round(cart.total(9.25), 2)
16.39
"""

class ShoppingCart(object):
    """
    This object is used to store the goods.
    It conveniently calculates total cost including
    tax.
    """
    def __init__(self):
        self.items = []
    def add(self, item, price):
        "Add an item to the internal list."
        self.items.append(Item(item, price))
        return self
    def item(self, index):
        "Look up the item. The cart is a 1-based index."
        return self.items[index-1].item
    def price(self, index):
        "Look up the price. The cart is a 1-based index."
        return self.items[index-1].price
    def total(self, sales_tax):
        "Add up all costs, and then apply a sales tax."
        sum_price = sum([item.price for item in self.items])
        return sum_price*(1.0 + sales_tax/100.0)
    def __len__(self):
        "Support len(cart) operation."
        return len(self.items)

class Item(object):
    def __init__(self, item, price):
        self.item = item
        self.price = price
  1. 使用-v对这个模块运行报告脚本,并查看屏幕输出:
===============================
== Processing module recipe21
===============================
Documentation for module recipe21
-------------------------------
This is documentation for the this entire recipe.
With it, we can demonstrate usage of the code.
>>> cart = ShoppingCart().add("tuna sandwich", 15.0)
>>> len(cart)
1
>>> cart.item(1)
'tuna sandwich'
>>> cart.price(1)
15.0
>>> print round(cart.total(9.25), 2)
16.39
-------------------------------
Documentation for Item.module Item - None
Documentation for Item.__init__() - None
Documentation for ShoppingCart.module ShoppingCart
-------------------------------
 This object is used to store the goods.
 It conveniently calculates total cost including
 tax.
…
Running doctests for recipe21
-------------------------------
Trying:
 cart = ShoppingCart().add("tuna sandwich", 15.0)
Expecting nothing
ok
Trying:
 len(cart)
Expecting:
 1
ok
5 tests in 10 items.
5 passed and 0 failed.
Test passed.

它是如何工作的...

这个脚本很小,但它收集了很多有用的信息。

通过使用 Python 的标准inspect模块,我们能够从模块级别开始深入研究。查找文档字符串的反射方式是通过访问对象的__doc__属性。它包含在模块、类、方法和函数中。它们存在于其他地方,但我们在这个食谱中限制了我们的重点。

我们以详细模式运行它,以显示测试实际上被执行。我们手动解析了命令行选项,但doctest自动查找-v来决定是否打开详细输出。为了防止我们的模块处理器捕捉到这一点并尝试将其处理为另一个模块,我们添加了一行来跳过任何-xyz风格的标志:

 if arg.startswith("-"): continue 

还有更多...

我们可以花更多时间来增强这个脚本。例如,我们可以使用 HTML 标记将其导出,使其可以在 Web 浏览器中查看。我们还可以找到第三方库以其他方式导出它。

我们还可以在哪里寻找文档字符串以及如何处理它们上进行改进。在我们的情况下,我们只是将它们打印到屏幕上。一个更可重用的方法是返回包含所有信息的某种结构。然后,调用者可以决定是打印到屏幕上,将其编码为 HTML,还是生成 PDF 文档。

这并不是必要的,因为这个食谱的重点是看如何将 Python 提供的这些强大的开箱即用选项混合到一个快速和有用的工具中。

测试边缘

测试需要在我们的代码边界上进行练习,直到超出范围限制。在这个食谱中,我们将深入定义和测试使用doctest的边缘。

如何做...

通过以下步骤,我们将看到如何编写测试软件边缘的代码:

  1. 创建一个名为recipe22.py的新文件,并使用它来放置这个食谱的所有代码。

  2. 创建一个将十进制数转换为 2 进制到 36 进制之间任何进制的函数:

def convert_to_basen(value, base):
    if base < 2 or base > 36:
        raise Exception("Only support bases 2-36")

    import math
    def _convert(remaining_value, base, exp):
        def stringify(value):
            if value > 9:
                return chr(value + ord('a')-10)
            else:
                return str(value)

        if remaining_value >= 0 and exp >= 0:
            factor = int(math.pow(base, exp))
            if factor <= remaining_value:
                multiple = remaining_value / factor
                return stringify(multiple) + \
                  _convert(remaining_value-multiple*factor, \
                                base, exp-1)
            else:
                return "0" + \
                       _convert(remaining_value, base, exp-1)
        else:
            return ""

    return "%s/%s" % (_convert(value, base, \
                         int(math.log(value, base))), base)
  1. 在我们的函数声明下面添加一个文档字符串,其中包括显示 2 进制边缘、36 进制边缘和无效的 37 进制的测试:
def convert_to_basen(value, base):
    """Convert a base10 number to basen.

    These show the edges for base 2.
    >>> convert_to_basen(1, 2)
    '1/2'
    >>> convert_to_basen(2, 2)
    '10/2'
    >>> convert_to_basen(0, 2)
    Traceback (most recent call last):
       ...
    ValueError: math domain error

    These show the edges for base 36.
    >>> convert_to_basen(1, 36)
    '1/36'
    >>> convert_to_basen(35, 36)
    'z/36'
    >>> convert_to_basen(36, 36)
    '10/36'
    >>> convert_to_basen(0, 36)
    Traceback (most recent call last):
       ...
    ValueError: math domain error

    These show the edges for base 37.
    >>> convert_to_basen(1, 37)
    Traceback (most recent call last):
       ...
    Exception: Only support bases 2-36
    >>> convert_to_basen(36, 37)
    Traceback (most recent call last):
       ...
    Exception: Only support bases 2-36
    >>> convert_to_basen(37, 37)
    Traceback (most recent call last):
       ...
    Exception: Only support bases 2-36
    >>> convert_to_basen(0, 37)   
    Traceback (most recent call last):
       ...
    Exception: Only support bases 2-36
    """
    if base < 2 or base > 36:
  1. 添加一个测试运行器:
if __name__ == "__main__":
    import doctest
    doctest.testmod()
  1. 按照这个屏幕截图展示的方式运行这个食谱:

它是如何工作的...

这个版本有一个处理 2 进制到 36 进制的限制。

对于 36 进制,它使用az。这与使用af的 16 进制进行比较。在 10 进制中,35表示为 36 进制中的z

我们包括了几个测试,包括 2 进制和 36 进制的1。我们还测试了在回卷之前的最大值和下一个值,以显示回卷。对于 2 进制,这是12。对于 36 进制,这是3536

正如我们还包括了测试 0 来显示我们的函数不处理任何基数,我们还测试了无效的 36 进制。

还有更多...

对于有效的输入,我们的软件能够正常工作是很重要的。同样重要的是,我们的软件对于无效的输入能够按预期工作。我们有文档可以在用户使用我们的软件时查看,记录了这些边缘情况。而且,由于 Python 的doctest模块,我们可以测试它,确保我们的软件表现正确。

另请参阅

在第一章中提到的测试边缘部分,使用 Unittest 开发基本测试

通过迭代测试边缘情况

随着我们继续开发我们的代码,边缘情况将会出现。通过在可迭代列表中捕获边缘情况,我们需要编写的代码更少,以捕获另一个测试场景。这可以提高我们测试新场景的效率。

如何做...

  1. 创建一个名为recipe23.py的新文件,并用它来存储这个配方的所有代码。

  2. 创建一个将十进制转换为任何其他进制的函数:

def convert_to_basen(value, base):
    import math

    def _convert(remaining_value, base, exp):
        def stringify(value):
            if value > 9:
                return chr(value + ord('a')-10)
            else:
                return str(value)

        if remaining_value >= 0 and exp >= 0:
            factor = int(math.pow(base, exp))
            if factor <= remaining_value:
                multiple = remaining_value / factor
                return stringify(multiple) + \
                  _convert(remaining_value-multiple*factor, \
                                base, exp-1)
            else:
                return "0" + \
                       _convert(remaining_value, base, exp-1)
        else:
            return ""

    return "%s/%s" % (_convert(value, base, \
                         int(math.log(value, base))), base)
  1. 添加一些包含一系列输入值以生成一系列预期输出的doctest实例。包括一个失败的实例:
def convert_to_basen(value, base):
    """Convert a base10 number to basen.

    Base 2
    >>> inputs = [(1,2,'1/2'), (2,2,'11/2')]
    >>> for value,base,expected in inputs:
    ...     actual = convert_to_basen(value,base)
    ...     assert actual == expected, 'expected: %s actual: %s' %
(expected, actual)

    >>> convert_to_basen(0, 2)
    Traceback (most recent call last):
       ...
    ValueError: math domain error

    Base 36.
    >>> inputs = [(1,36,'1/36'), (35,36,'z/36'), (36,36,'10/36')]
    >>> for value,base,expected in inputs:
    ...     actual = convert_to_basen(value,base)
    ...     assert actual == expected, 'expected: %s actual: %s' %
(expected, value)

    >>> convert_to_basen(0, 36)
    Traceback (most recent call last):
       ...
    ValueError: math domain error
    """
    import math
  1. 添加一个测试运行器:
if __name__ == "__main__":
    import doctest
    doctest.testmod()
  1. 运行这个配方:

在前面的截图中,关键信息在这一行上:AssertionError: expected: 11/2 actual: 10/2。这个测试失败有点牵强吗?当然是。但是看到一个显示有用输出的测试用例并不是。重要的是要验证我们的测试是否给了我们足够的信息来修复测试或代码。

它是如何工作的...

我们创建了一个数组,每个条目都包含输入数据和预期输出。这为我们提供了一种简单的方式来查看一组测试用例。

然后,我们遍历了每个测试用例,计算了实际值,并通过 Python 的assert运行了它。一个需要的重要部分是自定义消息'expected: %s actual: %s'。没有它,我们将永远得不到告诉我们哪个测试用例失败的信息。

如果一个测试用例失败会怎么样?

如果数组中的一个测试失败了,那么该代码块将退出并跳过其余的测试。这是为了拥有更简洁的一组测试而进行的权衡。

这种类型的测试更适合于 doctest 还是 unittest?

以下是一些标准,可以帮助您决定是否值得将这些测试放入doctest中:

  • 代码一目了然吗?

  • 当用户查看文档字符串时,是否有清晰、简洁、有用的信息?

如果在文档中没有这个的价值,而且它会使代码混乱,那么这是一个明显的提示,表明这个测试块属于一个单独的测试模块。

另请参阅

在第一章的通过迭代测试边缘情况部分,使用 Unittest 开发基本测试

用 doctest 变得爱管闲事

到目前为止,我们要么是用测试运行器附加模块,要么是在命令行上输入python -m doctest <module>来执行我们的测试。

在上一章中,我们介绍了强大的nose库(有关详细信息,请参阅somethingaboutorange.com/mrl/projects/nose)。

简要回顾一下,nose 具有以下功能:

  • 为我们提供了方便的测试发现工具nosetests

  • 可插拔,有大量的插件可用

  • 包括一个针对查找 doctests 并运行它们的内置插件

准备工作

我们需要激活我们的虚拟环境(virtualenv),然后为这个配方安装 nose:

  1. 创建一个虚拟环境,激活它,并验证工具是否正常工作。看一下这个截图:

  1. 使用pip,按照截图中显示的方式安装nose

这个配方假设您已经构建了本章中的所有先前的配方。如果您只构建了其中一些,您的结果可能会有所不同。

如何做...

  1. 对这个文件夹中的所有模块运行nosetests -with-doctest。您可能会注意到它打印了一个非常简短的.....F.F...F,表示有三个测试失败了。

  2. 运行nosetests -with-doctest -v以获得更详细的输出。在下面的截图中,注意到失败的测试与本章前面的示例中失败的测试是相同的。还有一个有价值的地方是看到了<module>.<method>格式,要么是ok要么是FAIL

  1. 按照屏幕截图中显示的方式,对recipe19.py文件以及recipe19模块运行nosetests -with-doctest,以不同的组合方式进行测试:

它是如何工作的...

nosetests旨在发现测试用例,然后运行它们。使用这个插件时,当它发现一个文档字符串时,它会使用doctest库来进行程序化测试。

doctest插件是基于这样的假设构建的,即 doctests 不在与其他测试(如 unittest)相同的包中。这意味着它只会运行从非测试包中找到的 doctests。

nosetests并不复杂,也不难使用,nosetests旨在成为一个方便使用的工具,让测试触手可及。在这个示例中,我们已经看到了如何使用nosetests来获取到目前为止在本章中构建的所有 doctest。

更新项目级别的脚本以运行本章的 doctests

这个示例将帮助我们探索构建一个项目级别的脚本,允许我们运行不同的测试套件。我们还将专注于如何在我们的doctest中运行它。

如何做...

通过以下步骤,我们将创建一个命令行脚本,以允许我们管理一个包括运行doctest的项目:

  1. 创建一个名为recipe25.py的新文件,以放置本示例的所有代码。

  2. 添加代码,使用 Python 的getopt库解析一组选项:

import getopt
import glob
import logging
import nose
import os
import os.path
import re
import sys
def usage():
    print ()
    print ("Usage: python recipe25.py [command]")
    print ()
    print ("\t--help")
    print ("\t--doctest")
    print ("\t--suite [suite]")
    print ("\t--debug-level [info|debug]")
    print ("\t--package")
    print ("\t--publish")
    print ("\t--register")
    print ()

try:
    optlist, args = getopt.getopt(sys.argv[1:],
            "h",
           ["help", "doctest", "suite=", \
            "debug-level=", "package", \
            "publish", "register"])
except getopt.GetoptError:
    # print help information and exit:
    print "Invalid command found in %s" % sys.argv
    usage()
    sys.exit(2)
  1. 创建一个映射到-test的函数:
def test(test_suite, debug_level):
    logger = logging.getLogger("recipe25")
    loggingLevel = debug_level
    logger.setLevel(loggingLevel)
    ch = logging.StreamHandler()
    ch.setLevel(loggingLevel)
    formatter = logging.Formatter("%(asctime)s - %(name)s - 
(levelname)s - %(message)s")
    ch.setFormatter(formatter)
    logger.addHandler(ch)

    nose.run(argv=["", test_suite, "--verbosity=2"])
  1. 创建一个映射到-doctest的函数:
def doctest(test_suite=None):
    args = ["", "--with-doctest"]
    if test_suite is not None:
        print ("Running doctest suite %s" % test_suite)
        args.extend(test_suite.split(','))
        nose.run(argv=args)
    else:
        nose.run(argv=args)
  1. 创建支持packagepublishregister的存根函数:
def package(): 
    print ("This is where we can plug in code to run " + \
          "setup.py to generate a bundle.")

def publish():
    print ("This is where we can plug in code to upload " + \
          "our tarball to S3 or some other download site.")

def register():
    print ("setup.py has a built in function to " + \
          "'register' a release to PyPI. It's " + \
          "convenient to put a hook in here.")
    # os.system("%s setup.py register" % sys.executable)
  1. 添加一些代码来检测选项列表是否为空。如果是,让它打印出帮助菜单并退出脚本:
if len(optlist) == 0:
    usage()
    sys.exit(1)
  1. 添加一些代码来定义调试级别,然后解析选项以允许用户进行覆盖:
debug_levels = {"info":logging.INFO, "debug":logging.DEBUG}
# Default debug level is INFO
debug_level = debug_levels["info"]

for option in optlist:
    if option[0] in ("--debug-level"):
        # Override with a user-supplied debug level
        debug_level = debug_levels[option[1]]
  1. 添加一些代码,扫描命令行选项以查找-help,如果找到,则退出脚本:
# Check for help requests, which cause all other
# options to be ignored.
for option in optlist:
    if option[0] in ("--help", "-h"):
    usage()
    sys.exit(1)
  1. 添加代码来检查是否选择了--doctest。如果是,让它专门扫描--suite并通过doctest()方法运行它。否则,通过-suite运行test()方法:
ran_doctests = False
for option in optlist:
    # If --doctest is picked, then --suite is a
    # suboption.
    if option[0] in ("--doctest"):
        suite = None
        for suboption in optlist:
            if suboption[0] in ("--suite"):
                suite = suboption[1]
        print "Running doctests..."
        doctest(suite)
        ran_doctests = True

if not ran_doctests:
    for option in optlist:
        if option[0] in ("--suite"):
            print "Running test suite %s..." % option[1]
            test(option[1], debug_level)
  1. 通过迭代每个命令行选项来完成,并根据所选的选项调用其他函数:
# Parse the arguments, in order
for option in optlist:
    if option[0] in ("--package"):
        package()

    if option[0] in ("--publish"):
        publish()

    if option[0] in ("--register"):
        register()
  1. 按照屏幕截图中显示的方式使用--help运行脚本:

  1. 使用--doctest运行脚本。注意以下屏幕截图中的前几行输出。它显示了测试的通过和失败以及详细的输出。看一下这个屏幕截图:

输出要长得多。为了简洁起见,已经对其进行了修剪。

  1. 按照屏幕截图中显示的方式,使用-doctest -suite=recipe16,recipe17.py运行脚本:

我们故意使用recipe16.pyrecipe17.py来演示它是如何与模块名和文件名一起工作的。

它是如何工作的...

这个脚本使用了 Python 的getopt库,它是模仿getopt()函数的(有关更多详细信息,请参阅docs.python.org/library/getopt.html)。

我们已经连接了以下函数:

  • Usage:提供帮助给用户的函数。

  • Key:关键选项定义包括在以下块中:

optlist, args = getopt.getopt(sys.argv[1:],
        "h",
       ["help", "doctest", "suite=", \
        "debug-level=", "package", \
        "publish", "register"])
    • 我们解析除第一个外的所有参数,第一个是可执行文件。
  • "h"定义了短选项:-h

  • 列表定义了长选项。带有"="的选项接受一个参数。没有参数的是标志。

  • 如果收到的选项不在列表中,就会抛出异常,我们打印出usage(),然后退出。

  • doctest:它使用-with-doctest通过 nose 运行模块。

  • packagepubilshregister:这些与上一章中描述的函数类似。

定义了这些函数后,我们现在可以迭代解析的选项。对于这个脚本,有一个顺序:

  1. 检查是否有调试覆盖。我们默认为logging.INFO,但我们提供切换到logging.DEBUG的能力。

  2. 检查是否调用了-h-help。如果是,打印出usage()信息,然后退出,不再解析。

  3. 因为-suite可以单独用于运行 unittest 测试,或作为-doctest的子选项,我们必须解析一下,并弄清楚是否使用了-doctest

  4. 最后,迭代选项,并调用它们对应的函数。

为了练习,我们首先用-help选项调用这个脚本,打印出我们的命令选择。

然后我们用-doctest调用它,看它如何找到这个文件夹中的所有 doctests。在我们的例子中,我们找到了本章的所有配方,包括三个测试失败。

最后,我们用-doctest -suite=recipe16,recipe17.py调用脚本。这显示了我们如何选择由逗号分隔的测试子集。通过这个例子,我们看到 nose 可以通过模块名(recipe16.py)或文件名(recipe17.py)来处理。

还有更多...

这个脚本提供的功能可以很容易地通过已经构建的命令来处理。我们在本章前面看过nosetestsdoctest,并看到它如何接受参数来灵活地选择测试。

在 Python 社区中,使用setup.py生成 tarballs 和注册发布也是一个常用的功能。

那么为什么要编写这个脚本呢?因为我们可以利用一个命令来利用所有这些功能。

第四章:使用行为驱动开发测试客户故事

在本章中,我们将涵盖以下配方:

  • 测试的命名听起来像句子和故事

  • 测试单独的 doctest 文档

  • 使用 doctest 编写可测试的故事

  • 使用 doctest 编写可测试的小说

  • 使用 Voidspace Mock 和 nose 编写可测试的故事

  • 使用 mockito 和 nose 编写可测试的故事

  • 使用 Lettuce 编写可测试的故事

  • 使用 Should DSL 编写简洁的 Lettuce 断言

  • 更新项目级别的脚本以运行本章的 BDD 测试

介绍

行为驱动开发BDD)是由 Dan North 作为对测试驱动开发TDD)的回应而创建的。它专注于用自然语言编写自动化测试,以便非程序员可以阅读。

“程序员想知道从哪里开始,要测试什么,不要测试什么,一次测试多少,如何命名他们的测试,以及如何理解为什么测试失败。我越深入 TDD,就越觉得自己的旅程不是逐渐掌握的过程,而是一系列的盲目尝试。我记得当时想,‘要是当时有人告诉我该多好!’的次数远远多于我想,‘哇,一扇门打开了。’我决定一定有可能以一种直接进入好东西并避开所有陷阱的方式来呈现 TDD。” – Dan North

要了解更多关于 Dan North 的信息,请访问:dannorth.net/introducing-bdd/

我们之前在单元测试配方中编写的测试的风格是testThistestThat。BDD 采取了摆脱程序员的说法,而转向更加以客户为导向的视角。

Dan North 接着指出 Chris Stevenson 为 Java 的 JUnit 编写了一个专门的测试运行器,以不同的方式打印测试结果。让我们来看一下以下的测试代码:

public class FooTest extends TestCase  {
    public void testIsASingleton() {}
    public void testAReallyLongNameIsAGoodThing() {}
}

当通过 AgileDox 运行此代码(agiledox.sourceforge.net/)时,将以以下格式打印出来:

Foo
-is a singleton
-a really long name is a good thing

AgileDox 做了几件事,比如:

  • 它打印出测试名称,去掉测试后缀

  • 从每个测试方法中去掉测试前缀

  • 它将剩余部分转换成一个句子

AgileDox 是一个 Java 工具,所以我们不会在本章中探讨它。但是有许多 Python 工具可用,我们将看一些,包括 doctest、Voidspace Mock、mockito和 Lettuce。所有这些工具都为我们提供了以更自然的语言编写测试的手段,并赋予客户、QA 和测试团队开发基于故事的测试的能力。

所有 BDD 的工具和风格都可以轻松填满一整本书。本章旨在介绍 BDD 的哲学以及一些强大、稳定的工具,用于有效地测试我们系统的行为。

对于本章,让我们为每个配方使用相同的购物车应用程序。创建一个名为cart.py的文件,并添加以下代码:

class ShoppingCart(object):
    def __init__(self):
       self.items = []
    def add(self, item, price):
       for cart_item in self.items:
           # Since we found the item, we increment
           # instead of append
           if cart_item.item == item: 
              cart_item.q += 1
              return self
       # If we didn't find, then we append 
       self.items.append(Item(item, price))
       return self
    def item(self, index):
        return self.items[index-1].item
    def price(self, index):
        return self.items[index-1].price * self.items[index-1].q
    def total(self, sales_tax):
        sum_price=sum([item.price*item.q for item in self.items])
        return sum_price*(1.0 + sales_tax/100.0)
    def __len__(self):
        return sum([item.q for item in self.items])
class Item(object):
    def __int__(self,item,price,q=1):
        self.item=item
        self.price=price
        self.q=q

考虑以下关于这个购物车的内容:

  • 它是基于一的,意味着第一个项目和价格在[1],而不是[0]

  • 它包括具有相同项目的多个项目

  • 它将计算总价格,然后添加税收

这个应用程序并不复杂。相反,它为我们提供了在本章中测试各种客户故事和场景的机会,这些故事和场景不一定局限于简单的单元测试。

命名测试听起来像句子和故事

测试方法应该读起来像句子,测试用例应该读起来像章节的标题。这是 BDD 的哲学的一部分,目的是使测试对非程序员易于阅读。

准备工作

对于这个配方,我们将使用本章开头展示的购物车应用程序。

如何做…

通过以下步骤,我们将探讨如何编写一个自定义的nose插件,以 BDD 风格的报告提供结果:

  1. 创建一个名为recipe26.py的文件来包含我们的测试用例。

  2. 创建一个 unittest 测试,其中测试用例表示一个带有一个物品的购物车,测试方法读起来像句子:

import unittest
from cart import *
class CartWithOneItem(unittest.TestCase):
      def setUp(self):
          self.cart = ShoppingCart().add("tuna sandwich", 15.00)
      def test_when_checking_the_size_should_be_one_based(self):
          self.assertEquals(1, len(self.cart))
      def test_when_looking_into_cart_should_be_one_based(self):
          self.assertEquals("tuna sandwich", self.cart.item(1))
          self.assertEquals(15.00, self.cart.price(1))
      def test_total_should_have_in_sales_tax(self):
          self.assertAlmostEquals(15.0*1.0925, \
                              self.cart.total(9.25), 2)
  1. 添加一个 unittest 测试,其中测试用例表示一个带有两个物品的购物车,测试方法读起来像句子:
class CartWithTwoItems(unittest.TestCase):
     def setUp(self):
         self.cart = ShoppingCart()\
                       .add("tuna sandwich", 15.00)\
                       .add("rootbeer", 3.75) 
    def test_when_checking_size_should_be_two(self):
        self.assertEquals(2, len(self.cart))
    def test_items_should_be_in_same_order_as_entered(self):
       self.assertEquals("tuna sandwich", self.cart.item(1))
       self.assertAlmostEquals(15.00, self.cart.price(1), 2)
       self.assertEquals("rootbeer", self.cart.item(2)) 
       self.assertAlmostEquals(3.75, self.cart.price(2), 2)
   def test_total_price_should_have_in_sales_tax(self):
       self.assertAlmostEquals((15.0+3.75)*1.0925,self.cart.total(9.25),2)
  1. 添加一个 unittest 测试,其中测试用例表示一个没有物品的购物车,测试方法读起来像句子:
class CartWithNoItems(unittest.TestCase): 
    def setUp(self):
       self.cart = ShoppingCart()
   def test_when_checking_size_should_be_empty(self): 
      self.assertEquals(0, len(self.cart))
   def test_finding_item_out_of_range_should_raise_error(self):
      self.assertRaises(IndexError, self.cart.item, 2)
   def test_finding_price_out_of_range_should_raise_error(self): 
      self.assertRaises(IndexError, self.cart.price, 2)
   def test_when_looking_at_total_price_should_be_zero(self):
      self.assertAlmostEquals(0.0, self.cart.total(9.25), 2)
   def test_adding_items_returns_back_same_cart(self): 
      empty_cart = self.cart
      cart_with_one_item=self.cart.add("tuna sandwich",15.00)
      self.assertEquals(empty_cart, cart_with_one_item) 
      cart_with_two_items = self.cart.add("rootbeer", 3.75) 
      self.assertEquals(empty_cart, cart_with_one_item)
      self.assertEquals(cart_with_one_item, cart_with_two_items)

BDD 鼓励使用非常描述性的句子作为方法名。其中有几个方法名被缩短以适应本书的格式,但有些仍然太长。

  1. 创建另一个名为recipe26_plugin.py的文件,以包含我们定制的 BDD 运行程序。

  2. 创建一个nose插件,可以用作–with-bdd来打印结果:

import sys
err = sys.stderr
import nose
import re
from nose.plugins import Plugin
class BddPrinter(Plugin): 
     name = "bdd"
     def __init__(self): 
         Plugin.__init__(self) 
         self.current_module = None
  1. 创建一个处理程序,打印出模块或测试方法,剔除多余的信息:
def beforeTest(self, test): 
    test_name = test.address()[-1]
    module, test_method = test_name.split(".") 
    if self.current_module != module:
       self.current_module = module
    fmt_mod = re.sub(r"([A-Z])([a-z]+)", r"\1\2 ", module)
    err.write("\nGiven %s" % fmt_mod[:-1].lower()) 
    message = test_method[len("test"):]
    message = " ".join(message.split("_")) err.write("\n- %s" % message)
  1. 为成功、失败和错误消息创建一个处理程序:
def addSuccess(self, *args, **kwargs): 
    test = args[0]
    err.write(" : Ok")
def addError(self, *args, **kwargs): 
    test, error = args[0], args[1] 
    err.write(" : ERROR!\n")
def addFailure(self, *args, **kwargs): 
    test, error = args[0], args[1] 
    err.write(" : Failure!\n")
  1. 创建一个名为recipe26_plugin.py的新文件,其中包含一个用于执行此示例的测试运行程序。

  2. 创建一个测试运行程序,将测试用例引入并通过nose运行,以易于阅读的方式打印结果:

if __name__ == "__main__": 
   import nose
   from recipe26_plugin import *
   nose.run(argv=["", "recipe26", "--with-bdd"], plugins=[BddPrinter()])
  1. 运行测试运行程序。看一下这个截图:

  1. 在测试用例中引入一些错误,并重新运行测试运行程序,看看这如何改变输出:
 def test_when_checking_the_size_should_be_one_based(self):
        self.assertEquals(2, len(self.cart))
...
    def test_items_should_be_in_same_order_as_entered(self): 
        self.assertEquals("tuna sandwich", self.cart.item(1)) 
        self.assertAlmostEquals(14.00, self.cart.price(1), 2) 
        self.assertEquals("rootbeer", self.cart.item(2)) 
        self.assertAlmostEquals(3.75, self.cart.price(2), 2)
  1. 再次运行测试。看一下这个截图:

工作原理...

测试用例被写成名词,描述正在测试的对象。CartWithTwoItems描述了围绕预先填充了两个物品的购物车的一系列测试方法。

测试方法写成句子。它们用下划线串联在一起,而不是空格。它们必须以test_为前缀,这样 unittest 才能捕捉到它们。test_items_should_be_in_the_same_order_as_entered应该表示应该按输入顺序排列的物品。

这个想法是,我们应该能够通过将这两者结合在一起来快速理解正在测试的内容:给定一个带有两个物品的购物车,物品应该按输入顺序排列。

虽然我们可以通过这种思维过程阅读测试代码,但是在脑海中减去下划线和test前缀的琐事,这对我们来说可能会成为真正的认知负担。为了使其更容易,我们编写了一个快速的nose插件,将驼峰式测试拆分并用空格替换下划线。这导致了有用的报告格式。

使用这种快速工具鼓励我们编写详细的测试方法,这些方法在输出时易于阅读。反馈不仅对我们有用,而且对我们的测试团队和客户也非常有效,可以促进沟通、对软件的信心,并有助于生成新的测试故事。

还有更多...

这里显示的示例测试方法被故意缩短以适应本书的格式。不要试图使它们尽可能短。相反,试着描述预期的输出。

插件无法安装。这个插件是为了快速生成报告而编写的。为了使其可重用,特别是与nosetests一起使用。

测试单独的 doctest 文档

BDD 不要求我们使用任何特定的工具。相反,它更注重测试的方法。这就是为什么可以使用 Python 的doctest编写 BDD 测试场景。doctest不限于模块的代码。通过这个示例,我们将探讨创建独立的文本文件来运行 Python 的doctest库。

如果这是doctest,为什么它没有包含在上一章的示例中?因为在单独的测试文档中编写一组测试的上下文更符合 BDD 的哲学,而不是可供检查的可测试 docstrings。

准备工作

对于这个示例,我们将使用本章开头展示的购物车应用程序。

如何做...

通过以下步骤,我们将探讨在doctest文件中捕获各种测试场景,然后运行它们:

  1. 创建一个名为recipe27_scenario1.doctest的文件,其中包含doctest风格的测试,以测试购物车的操作:
This is a way to exercise the shopping cart 
from a pure text file containing tests.
First, we need to import the modules 
>>> from cart import *
Now, we can create an instance of a cart 
>>> cart = ShoppingCart()
Here we use the API to add an object. Because it returns back the cart, we have to deal with the output
>>> cart.add("tuna sandwich", 15.00) #doctest:+ELLIPSIS 
<cart.ShoppingCart object at ...>
Now we can check some other outputs
>>> cart.item(1) 
'tuna sandwich' 
>>> cart.price(1) 
15.0
>>> cart.total(0.0) 
15.0

注意到文本周围没有引号。

  1. recipe27_scenario2.doctest文件中创建另一个场景,测试购物车的边界,如下所示:
This is a way to exercise the shopping cart 
from a pure text file containing tests.
First, we need to import the modules 
>>> from cart import *
Now, we can create an instance of a cart 
>>> cart = ShoppingCart()
Now we try to access an item out of range, expecting an exception.
>>> cart.item(5)
Traceback (most recent call last): 
...
IndexError: list index out of range
We also expect the price method to fail in a similar way.
>>> cart.price(-2)
Traceback (most recent call last): 
...
IndexError: list index out of range
  1. 创建一个名为recipe27.py的文件,并放入查找以.doctest结尾的文件并通过doctest中的testfile方法运行它们的测试运行器代码:
if __name__ == "__main__":
   import doctest
   from glob import glob
   for file in glob("recipe27*.doctest"):
      print ("Running tests found in %s" % file) 
      doctest.testfile(file)
  1. 运行测试套件。查看以下代码:

  1. 使用-v运行测试套件,如下截图所示:

它是如何工作的...

doctest提供了方便的testfile函数,它将像处理文档字符串一样处理一块纯文本。这就是为什么与我们在文档字符串中有多个doctest时不需要引号的原因。这些文本文件不是文档字符串。

实际上,如果我们在文本周围包含三引号,测试将无法正常工作。让我们以第一个场景为例,在文件的顶部和底部放上""",并将其保存为recipe27_bad_ scenario.txt。现在,让我们创建一个名为recipe27.py的文件,并创建一个替代的测试运行器来运行我们的坏场景,如下所示:

if __name__ == "__main__":
   import doctest
   doctest.testfile("recipe27_bad_scenario.txt")

我们得到以下错误消息:

它已经混淆了尾部三引号作为预期输出的一部分。最好直接将它们去掉。

还有更多...

将文档字符串移动到单独的文件中有什么好处?这难道不是我们在第二章中讨论的使用 doctest 创建可测试文档中所做的相同的事情吗?是和不是。是,从技术上讲是一样的:doctest正在处理嵌入在测试中的代码块。

但 BDD 不仅仅是一个技术解决方案。它是由可读 客户端 场景的哲学驱动的。BDD 旨在测试系统的行为。行为通常由面向客户的场景定义。当我们的客户能够轻松理解我们捕捉到的场景时,这是非常鼓励的。当客户能够看到通过和失败,并且反过来看到已经完成的实际状态时,这是进一步增强的。

通过将测试场景与代码解耦并将它们放入单独的文件中,我们可以为我们的客户使用doctest创建可读的测试的关键要素。

这难道不违背了文档字符串的可用性吗?

在第二章中,使用 Nose 运行自动化测试套件,有几个示例展示了在文档字符串中嵌入代码使用示例是多么方便。它们很方便,因为我们可以从交互式 Python shell 中读取文档字符串。你认为将其中一些内容从代码中提取到单独的场景文件中有什么不同吗?你认为有些doctest在文档字符串中会很有用,而其他一些可能在单独的场景文件中更好地为我们服务吗?

使用 doctest 编写可测试的故事

doctest文件中捕捉一个简洁的故事是 BDD 的关键。BDD 的另一个方面是提供一个包括结果的可读报告。

准备工作

对于这个示例,我们将使用本章开头展示的购物车应用程序。

如何做...

通过以下步骤,我们将看到如何编写自定义的doctest运行器来生成我们自己的报告:

  1. 创建一个名为recipe28_cart_with_no_items.doctest的新文件,用于包含我们的doctest场景。

  2. 创建一个doctest场景,演示购物车的操作,如下所示:

This scenario demonstrates a testable story.
First, we need to import the modules 
>>> from cart import *
>>> cart = ShoppingCart()
#when we add an item
>>> cart.add("carton of milk", 2.50) #doctest:+ELLIPSIS 
<cart.ShoppingCart object at ...>
#the first item is a carton of milk 
>>> cart.item(1)
'carton of milk'
#the first price is $2.50 
>>> cart.price(1)
2.5
#there is only one item 
>>> len(cart)
This shopping cart lets us grab more than one of a particular item.
#when we add a second carton of milk
>>> cart.add("carton of milk", 2.50) #doctest:+ELLIPSIS 
<cart.ShoppingCart object at ...>
#the first item is still a carton of milk 
>>> cart.item(1)
'carton of milk'
#but the price is now $5.00 
>>> cart.price(1)
5.0
#and the cart now has 2 items 
>>> len(cart)
2
#for a total (with 10% taxes) of $5.50 
>>> cart.total(10.0)
5.5
  1. 创建一个名为recipe28.py的新文件,用于包含我们自定义的doctest运行器。

  2. 通过子类化DocTestRunner创建一个客户doctest运行器,如下所示:

import doctest
class BddDocTestRunner(doctest.DocTestRunner): 
      """
      This is a customized test runner. It is meant 
      to run code examples like DocTestRunner,
      but if a line preceeds the code example 
      starting with '#', then it prints that 
      comment.
      If the line starts with '#when', it is printed 
      out like a sentence, but with no outcome.
      If the line starts with '#', but not '#when'
      it is printed out indented, and with the outcome.
      """
  1. 添加一个report_start函数,查找示例之前以#开头的注释,如下所示:
def report_start(self, out, test, example):
    prior_line = example.lineno-1
    line_before = test.docstring.splitlines()[prior_line] 
    if line_before.startswith("#"):
       message = line_before[1:]
       if line_before.startswith("#when"):
          out("* %s\n" % message) 
          example.silent = True 
          example.indent = False
       else:
         out(" - %s: " % message) 
         example.silent = False 
         example.indent = True
   else:
     example.silent = True 
     example.indent = False

   doctest.DocTestRunner(out, test, example)
  1. 添加一个有条件地打印出okreport_success函数,如下所示:
def report_success(self, out, test, example, got):
    if not example.silent:
       out("ok\n")
    if self._verbose:
       if example.indent: out(" ") 
          out(">>> %s\n" % example.source[:-1])
  1. 添加一个有条件地打印出FAILreport_failure函数,如下所示:
def report_failure(self, out, test, example, got):
    if not example.silent:
       out("FAIL\n")
    if self._verbose:
       if example.indent: out(" ") 
           out(">>> %s\n" % example.source[:-1])
  1. 添加一个运行器,用我们的自定义运行器替换doctest.DocTestRunner,然后查找要运行的doctest文件,如下所示:
if __name__ == "__main__":
   from glob import glob
   doctest.DocTestRunner = BddDocTestRunner
   for file in glob("recipe28*.doctest"):
       given = file[len("recipe28_"):]
       given = given[:-len(".doctest")]
       given = " ".join(given.split("_"))
       print ("===================================")
       print ("Given a %s..." % given)
       print ("===================================")
       doctest.testfile(file)
  1. 使用运行器来执行我们的场景。看一下这个截图:

  1. 使用带有-v的运行器来执行我们的场景,如此截图所示:

  1. 修改测试场景,使其中一个预期结果失败,使用以下代码:
#there is only one item 
>>> len(cart)
4668

注意,我们已将预期结果从1更改为4668,以确保失败。

  1. 再次使用带有-v的运行器,并查看结果。看一下这个截图:

它是如何工作的...

doctest提供了一种方便的方法来编写可测试的场景。首先,我们编写了一系列我们希望购物车应用程序证明的行为。为了使事情更加完善,我们添加了许多详细的评论,以便任何阅读此文档的人都能清楚地理解事情。

这为我们提供了一个可测试的场景。但它让我们缺少一个关键的东西:简洁的报告

不幸的是,doctest不会为我们打印出所有这些详细的评论。

为了使其从 BDD 的角度可用,我们需要能够嵌入选择性的注释,当测试序列运行时打印出来。为此,我们将子类化doctest.DocTestRunner并插入我们版本的处理文档字符串的方法。

还有更多...

DocTestRunner方便地为我们提供了文档字符串的处理方法,以及代码示例开始的确切行号。我们编写了BddDocTestRunner来查看其前一行,并检查它是否以#开头,这是我们自定义的文本在测试运行期间打印出来的标记。

#when注释被视为原因。换句话说,when引起一个或多个效果。虽然doctest仍将验证与when相关的代码;但出于 BDD 目的,我们并不真正关心结果,因此我们会默默地忽略它。

任何其他#注释都被视为效果。对于这些效果中的每一个,我们会去掉#,然后缩进打印出句子,这样我们就可以轻松地看到它与哪个when相关联。最后,我们打印出okFAIL来指示结果。

这意味着我们可以向文档添加所有我们想要的细节。但对于测试块,我们可以添加将被打印为原因#when)或效果(#其他)的语句。

使用 doctest 编写可测试的小说

运行一系列故事测试展示了代码的预期行为。我们之前在使用 doctest 编写可测试的故事配方中已经看到了如何构建一个可测试的故事并生成有用的报告。

通过这个配方,我们将看到如何使用这种策略将多个可测试的故事串联起来,形成一个可测试的小说。

准备工作

对于此配方,我们将使用本章开头显示的购物车应用程序。

我们还将重用本章中使用 doctest 编写可测试故事中定义的BddDocTestRunner,但我们将稍微修改它,实施以下步骤。

如何做...

  1. 创建一个名为recipe29.py的新文件。

  2. 将包含BddDocTestRunner的代码从使用 doctest 编写可测试的故事配方复制到recipe29.py中。

  3. 修改__main__可运行程序,仅搜索此配方的doctest场景,如下所示的代码:

if __name__ == "__main__":
   from glob import glob
   doctest.DocTestRunner = BddDocTestRunner
   for file in glob("recipe29*.doctest"):
 given = file[len("recipe29_"):] 
       given = given[:-len(".doctest")]
       given = " ".join(given.split("_"))
       print ("===================================")
       print ("Given a %s..." % given)
       print ("===================================")
       doctest.testfile(file)
  1. 创建一个名为recipe29_cart_we_will_load_with_identical_items.doctest的新文件。

  2. 向其中添加一个场景,通过添加相同对象的两个实例来测试购物车:

>>> from cart import *
>>> cart = ShoppingCart()
#when we add an item
>>> cart.add("carton of milk", 2.50) #doctest:+ELLIPSIS
<cart.ShoppingCart object at ...>
#the first item is a carton of milk
>>> cart.item(1)
'carton of milk'
#the first price is $2.50
>>> cart.price(1)
2.5
#there is only one item
>>> len(cart)
1
This shopping cart let's us grab more than one of a particular item.
#when we add a second carton of milk
>>> cart.add("carton of milk", 2.50) #doctest:+ELLIPSIS
<cart.ShoppingCart object at ...>
#the first item is still a carton of milk
>>> cart.item(1) 
'carton of milk'
#but the price is now $5.00
>>> cart.price(1)
5.0
#and the cart now has 2 items
>>> len(cart)
2
#for a total (with 10% taxes) of $5.50
>>> cart.total(10.0)
5.5

  1. 创建另一个名为recipe29_cart_we_will_load_with_two_different_items.docstest的文件。

  2. 在该文件中,创建另一个场景,测试通过添加两个不同实例的购物车,如下所示的代码:

>>> from cart import *
>>> cart = ShoppingCart()
#when we add a carton of milk...
>>> cart.add("carton of milk", 2.50) #doctest:+ELLIPSIS 
<cart.ShoppingCart object at ...>
#when we add a frozen pizza...
>>> cart.add("frozen pizza", 3.00) #doctest:+ELLIPSIS
 <cart.ShoppingCart object at ...>
#the first item is the carton of milk
>>> cart.item(1)
'carton of milk'
#the second item is the frozen pizza
>>> cart.item(2)
'frozen pizza'
#the first price is $2.50
>>> cart.price(1)
2.5
#the second price is $3.00
>>> cart.price(2)
3.0
#the total with no tax is $5.50
>>> cart.total(0.0)
5.5
#the total with 10% tax is $6.05
>>> print (round(cart.total(10.0), 2) )
6.05
  1. 创建一个名为recipe29_cart_that_we_intend_to_keep_empty.doctest的新文件。

  2. 在那个文件中,创建一个第三个场景,测试购物车添加了空值,但尝试访问范围之外的值,如下面的代码所示:

>>>from cart import *
#when we create an empty shopping cart 
>>> cart = ShoppingCart()
#accessing an item out of range generates an exception
>>> cart.item(5)
Traceback (most recent call last):
...
IndexError: list index out of range
#accessing a price with a negative index causes an exception
>>> cart.price(-2)
Traceback (most recent call last):
...
IndexError: list index out of range
#calculating a price with no tax results in $0.00
>>> cart.total(0.0)
0.0
#calculating a price with a tax results in $0.00
>>> cart.total(10.0)
0.0
  1. 使用 runner 来执行我们的场景。看一下这个截图:

它是如何工作的...

我们重用了上一个食谱中开发的测试运行器。关键是扩展场景,以确保我们完全覆盖了预期的场景。

我们需要确保我们能处理以下情况:

  • 一个有两个相同物品的购物车

  • 一个有两个不同物品的购物车

  • 一个空购物车的退化情况

还有更多...

编写测试的一个有价值的部分是选择有用的名称。在我们的情况下,每个可测试的故事都以一个空购物车开始。然而,如果我们将每个场景命名为给定一个空购物车,这将导致重叠,并且不会产生一个非常有效的报告。

因此,我们根据我们故事的意图来命名它们:

recipe29_cart_we_will_load_with_identical_items.doctest
recipe29_cart_we_will_load_with_two_different_items.doctest
recipe29_cart_that_we_intend_to_keep_empty.doctest

这导致:

  • 给定一个我们将装满相同物品的购物车

  • 给定一个我们将装满两个不同物品的购物车

  • 给定一个我们打算保持为空的购物车

这些场景的目的更加清晰。

命名场景很像软件开发的某些方面,更像是一种工艺而不是科学。调整性能往往更具科学性,因为它涉及到一个测量和调整的迭代过程。但是命名场景以及它们的原因和影响往往更像是一种工艺。它涉及与所有利益相关者的沟通,包括 QA 和客户,这样每个人都可以阅读和理解故事。

不要感到害怕。准备好接受变化

开始编写你的故事。让它们起作用。然后与利益相关者分享。反馈很重要,这就是使用基于故事的测试的目的。准备好接受批评和建议的改变。

准备好接受更多的故事请求。事实上,如果你的一些客户或 QA 想要编写他们自己的故事,也不要感到惊讶。这是一个积极的迹象。

如果你是第一次接触这种类型的客户互动,不要担心。你将培养宝贵的沟通技能,并与利益相关者建立牢固的专业关系。与此同时,你的代码质量肯定会得到提高。

使用 Voidspace Mock 和 nose 编写可测试的故事

当我们的代码通过方法和属性与其他类交互时,这些被称为协作者。使用 Voidspace Mock(www.voidspace.org.uk/python/mock/)来模拟协作者,由 Michael Foord 创建,为 BDD 提供了一个关键工具。模拟提供了与存根提供的固定状态相比的固定行为。虽然模拟本身并不定义 BDD,但它们的使用与 BDD 的想法密切重叠。

为了进一步展示测试的行为性质,我们还将使用pinocchio项目中的spec插件(darcs.idyll.org/~t/projects/pinocchio/doc)。

正如项目网站上所述,Voidspace Mock 是实验性的。本书是使用版本 0.7.0 beta 3 编写的。在达到稳定的 1.0 版本之前,可能会发生更多的 API 更改的风险。鉴于这个项目的高质量、优秀的文档和博客中的许多文章,我坚信它应该在本书中占有一席之地。

准备工作

对于这个食谱,我们将使用本章开头展示的购物车应用程序,并进行一些轻微的修改:

  1. 创建一个名为recipe30_cart.py的新文件,并复制本章介绍中创建的cart.py中的所有代码。

  2. 修改__init__以添加一个额外的用于持久性的storer属性:

class ShoppingCart(object):
     def __init__(self, storer=None):
        self.items = []
        self.storer = storer
  1. 添加一个使用storer保存购物车的store方法:
    def store(self):
        return self.storer.store_cart(self)
  1. 添加一个retrieve方法,通过使用storer更新内部的items
    def restore(self, id):
       self.items = self.storer.retrieve_cart(id).items 
       return self

storer的 API 的具体细节将在本食谱的后面给出。

我们需要激活我们的虚拟环境,然后为这个示例安装 Voidspace Mock:

  1. 创建一个虚拟环境,激活它,并验证工具是否正常工作。看一下下面的截图:

  1. 通过输入pip install mock来安装 Voidspace Mock。

  2. 通过输入pip install http://darcs.idyll.org/~t/projects/pinocchio-latest.tar.gz来安装 Pinocchio 的最新版本。

  3. 这个版本的 Pinocchio 引发了一些警告。为了防止它们,我们还需要通过输入pip install figleaf来安装figleaf

如何做到这一点...

通过以下步骤,我们将探讨如何使用模拟来编写可测试的故事:

  1. recipe30_cart.py中,创建一个具有存储和检索购物车空方法的DataAccess类:
class DataAccess(object):
     def store_cart(self,cart):
         pass
     def retrieve_cart(self,id):
         pass
  1. 创建一个名为recipe30.py的新文件来编写测试代码。

  2. 创建一个自动化的 unittest,通过模拟DataAccess的方法来测试购物车:

import unittest
from copy import deepcopy 
from recipe30_cart import *
from mock import Mock
class CartThatWeWillSaveAndRestoreUsingVoidspaceMock(unittest. TestCase):
      def test_fill_up_a_cart_then_save_it_and_restore_it(self):
          # Create an empty shopping cart
          cart = ShoppingCart(DataAccess())
          # Add a couple of items 
          cart.add("carton of milk", 2.50) 
          cart.add("frozen pizza", 3.00)
          self.assertEquals(2, len(cart))
          # Create a clone of the cart for mocking 
          # purposes.
          original_cart = deepcopy(cart)
          # Save the cart at this point in time into a database 
          # using a mock
          cart.storer.store_cart = Mock()
          cart.storer.store_cart.return_value = 1 
          cart.storer.retrieve_cart = Mock() 
          cart.storer.retrieve_cart.return_value = original_cart
          id = cart.store()
          self.assertEquals(1, id)
          # Add more items to cart 
          cart.add("cookie dough", 1.75) 
          cart.add("ginger ale", 3.25)
          self.assertEquals(4, len(cart))
          # Restore the cart to the last point in time 
          cart.restore(id)
          self.assertEquals(2, len(cart))
          cart.storer.store_cart.assert_called_with(cart)
          cart.storer.retrieve_cart.assert_called_with(1)
  1. 使用nosetestsspec插件运行测试:

它是如何工作的...

模拟是确认方法调用的测试替身,这是行为。这与存根不同,存根提供了预先准备的数据,允许我们确认状态。

许多模拟库都是基于记录/回放模式的。它们首先要求测试用例在使用时记录模拟将受到的每个行为。然后我们将模拟插入到我们的代码中,允许我们的代码对其进行调用。最后,我们执行回放,Mock 库将比较我们期望的方法调用和实际发生的方法调用。

记录/回放模拟的一个常见问题是,如果我们漏掉了一个方法调用,我们的测试就会失败。当试图模拟第三方系统或处理可能与复杂系统状态相关联的可变调用时,捕获所有方法调用可能变得非常具有挑战性。

Voidspace Mock 库通过使用action/assert模式而不同。我们首先生成一个模拟对象,并定义我们希望它对某些操作做出反应。然后,我们将其插入到我们的代码中,使我们的代码对其进行操作。最后,我们断言模拟发生了什么,只选择我们关心的操作。没有必要断言模拟体验的每个行为。

为什么这很重要?记录/回放要求我们记录代码、第三方系统和调用链中所有其他层次的方法调用。坦率地说,我们可能并不需要这种行为的确认水平。通常,我们主要关注的是顶层的交互。操作/断言让我们减少我们关心的行为调用。我们可以设置我们的模拟对象来生成必要的顶层操作,基本上忽略较低层次的调用,而记录/回放模拟会强制我们记录这些调用。

在这个示例中,我们模拟了DataAccess操作store_cartretrieve_cart。我们定义了它们的return_value,并在测试结束时断言它们被调用了以下:

cart.storer.store_cart.assert_called_with(cart)
cart.storer.retrieve_cart.assert_called_with(1)

cart.storer是我们用模拟注入的内部属性。

模拟方法意味着用模拟对象替换对真实方法的调用。

存根方法意味着用存根对象替换对真实方法的调用。

还有更多...

因为这个测试用例侧重于从购物车的角度进行存储和检索,我们不必定义真实的DataAccess调用。这就是为什么我们在它们的方法定义中简单地放置了pass

这方便地让我们在不强迫选择购物车存储在关系数据库、NoSQL 数据库、平面文件或任何其他文件格式的情况下,处理持久性的行为。这表明我们的购物车和数据持久性很好地解耦。

告诉我更多关于 spec nose 插件!

我们很快地浏览了nose的有用的spec插件。它提供了与我们在命名测试,使其听起来像句子和故事部分手工编码的基本功能相同。它将测试用例名称和测试方法名称转换为可读的结果。它给了我们一个可运行的spec。这个插件可以与 unittest 一起使用,不关心我们是否使用了 Voidspace Mock。

为什么我们没有重用食谱“命名测试,使其听起来像句子和故事”中的插件?

另一个表达这个问题的方式是我们为什么首先编写了那个食谱的插件?使用测试工具的一个重要点是理解它们的工作原理,以及如何编写我们自己的扩展。命名测试,使其听起来像句子和故事部分不仅讨论了命名测试的哲学,还探讨了编写nose插件以支持这种需求的方法。在这个食谱中,我们的重点是使用 Voidspace Mock 来验证某些行为,而不是编写nose插件。通过现有的spec插件轻松生成漂亮的 BDD 报告。

另请参阅

使用 mockito 和 nose 编写可测试的故事。

使用 mockito 和 nose 编写可测试的故事

当我们的代码通过方法和属性与其他类交互时,这些被称为协作者。使用mockitocode.google.com/p/mockitocode.google.com/p/mockito-python)模拟协作者为 BDD 提供了一个关键工具。模拟提供了预先定义的行为,而存根提供了预先定义的状态。虽然单独的模拟本身并不定义 BDD,但它们的使用与 BDD 的思想密切相关。

为了进一步展示测试的行为性质,我们还将使用pinocchio项目中的spec插件(darcs.idyll.org/~t/projects/ pinocchio/doc)。

准备工作

对于这个食谱,我们将使用本章开头展示的购物车应用程序,并进行一些轻微的修改:

  1. 创建一个名为recipe31_cart.py的新文件,并复制本章开头创建的cart.py中的所有代码。

  2. 修改__init__以添加一个额外的用于持久化的storer属性:

class ShoppingCart(object):
    def __init__(self, storer=None):
    self.items = []
    self.storer = storer
  1. 添加一个使用storer来保存购物车的store方法:
   def store(self):
       return self.storer.store_cart(self)
  1. 添加一个retrieve方法,通过使用storer来更新内部的items
  def restore(self, id):
      self.items = self.storer.retrieve_cart(id).items
      return self

存储器的 API 的具体信息将在本食谱的后面给出。

我们需要激活我们的虚拟环境,然后为这个食谱安装mockito

  1. 创建一个虚拟环境,激活它,并验证工具是否正常工作:

  1. 通过输入pip install mockito来安装mockito

使用与使用 Voidspace Mock 和 nose 编写可测试的故事食谱相同的步骤安装pinocchiofigleaf

如何做...

通过以下步骤,我们将探讨如何使用模拟来编写可测试的故事:

  1. recipe31_cart.py中,创建一个DataAccess类,其中包含用于存储和检索购物车的空方法:
class DataAccess(object):
     def store_cart(self, cart):
         pass
     def retrieve_cart(self, id):
         pass
  1. 为编写测试代码创建一个名为recipe31.py的新文件。

  2. 创建一个自动化的单元测试,通过模拟DataAccess的方法来测试购物车:

import unittest
from copy import deepcopy
from recipe31_cart import *
from mockito import *
class CartThatWeWillSaveAndRestoreUsingMockito(unittest.TestCase):
      def test_fill_up_a_cart_then_save_it_and_restore_it(self):
          # Create an empty shopping cart
          cart = ShoppingCart(DataAccess())
          # Add a couple of items
          cart.add("carton of milk", 2.50)
          cart.add("frozen pizza", 3.00)
          self.assertEquals(2, len(cart))
         # Create a clone of the cart for mocking
         # purposes.
         original_cart = deepcopy(cart)
         # Save the cart at this point in time into a database
         # using a mock
         cart.storer = mock()
         when(cart.storer).store_cart(cart).thenReturn(1)
         when(cart.storer).retrieve_cart(1). \   
                             thenReturn(original_cart)
         id = cart.store()
         self.assertEquals(1, id)
         # Add more items to cart
         cart.add("cookie dough", 1.75)
         cart.add("ginger ale", 3.25)
         self.assertEquals(4, len(cart))
         # Restore the cart to the last point in time
         cart.restore(id)
         self.assertEquals(2, len(cart))
         verify(cart.storer).store_cart(cart)
         verify(cart.storer).retrieve_cart(1)

  1. 使用spec插件运行测试nosetests

它是如何工作的...

这个食谱与之前的食谱非常相似,使用 Voidspace Mock 和 nose 编写可测试的故事。关于模拟和 BDD 的好处的详细信息,阅读那个食谱非常有用。

让我们比较 Voidspace Mock 和mockito的语法,以了解它们之间的区别。看一下以下 Voidspace Mock 的代码块:

         cart.storer.store_cart = Mock()
         cart.storer.store_cart.return_value = 1
         cart.storer.retrieve_cart = Mock()
         cart.storer.retrieve_cart.return_value = original_cart

它显示了被模拟的store_cart函数:

         cart.storer = mock()
         when(cart.storer).store_cart(cart).thenReturn(1)
         when(cart.storer).retrieve_cart(1).thenReturn(original_cart)

mockito通过模拟整个storer对象来实现这一点。mockito起源于 Java 的模拟工具,这解释了它的类似 Java 的 API,如thenReturn,与 Voidspace Mock 的 Python 风格的return_value相比。

有些人认为 Java 对 Python 的mockito实现的影响令人不快。坦率地说,我认为这不足以丢弃一个库。在前面的例子中,mockito以更简洁的方式记录了期望的行为,这绝对可以抵消类似 Java 的 API。

另请参阅

使用 Voidspace Mock 和 nose 编写可测试的故事。

使用 Lettuce 编写可测试的故事

Lettuce (lettuce.it)是一个为 Python 构建的类似 Cucumber 的 BDD 工具。

Cucumber (cukes.info)是由 Ruby 社区开发的,提供了一种以文本方式编写场景的方法。通过让利益相关者阅读这些故事,他们可以轻松地辨别出软件预期要做的事情。

这个教程展示了如何安装 Lettuce,编写一个测试故事,然后将其连接到我们的购物车应用程序中,以执行我们的代码。

准备好...

对于这个教程,我们将使用本章开头展示的购物车应用程序。我们还需要安装 Lettuce 及其依赖项。

通过输入pip install lettuce来安装 Lettuce。

如何做...

在接下来的步骤中,我们将探讨如何使用 Lettuce 创建一些可测试的故事,并将它们连接到可运行的 Python 代码中:

  1. 创建一个名为recipe32的新文件夹,以包含本教程中的所有文件。

  2. 创建一个名为recipe32.feature的文件来记录我们的故事。根据我们的购物车,编写我们新功能的顶层描述:

Feature: Shopping cart As a shopper
   I want to load up items in my cart
   So that I can check out and pay for them
  1. 让我们首先创建一个场景,捕捉购物车为空时的行为:
       Scenario: Empty cart
            Given an empty cart
            Then looking up the fifth item causes an error
            And looking up a negative price causes an error
            And the price with no taxes is $0.00
            And the price with taxes is $0.00
  1. 添加另一个场景,展示当我们添加牛奶盒时会发生什么:
       Scenario: Cart getting loaded with multiple of the same 
            Given an empty cart
            When I add a carton of milk for $2.50
            And I add another carton of milk for $2.50 
            Then the first item is a carton of milk
            And the price is $5.00 And the cart has 2 items
            And the total cost with 10% taxes is $5.50
  1. 添加第三个场景,展示当我们结合一盒牛奶和一份冷冻比萨时会发生什么:
    Scenario: Cart getting loaded with different items 
            Given an empty cart
            When I add a carton of milk
            And I add a frozen pizza
            Then the first item is a carton of milk
            And the second item is a frozen pizza
            And the first price is $2.50
            And the second price is $3.00
            And the total cost with no taxes is $5.50
            And the total cost with 10% taes is $6.05
  1. 让我们通过 Lettuce 运行故事,看看结果如何,考虑到我们还没有将这个故事与任何 Python 代码联系起来。在下面的截图中,很难辨别输出的颜色。特性和场景声明是白色的。GivenWhenThen是未定义的,颜色是黄色的。这表明我们还没有将步骤与任何代码联系起来:

  1. recipe32中创建一个名为steps.py的新文件,以实现对Given的支持所需的步骤。

  2. steps.py中添加一些代码来实现第一个Given

from lettuce import *
from cart import *
@step("an empty cart")
def an_empty_cart(step):
   world.cart = ShoppingCart()
  1. 要运行这些步骤,我们需要确保包含cart.py模块的当前路径是我们的PYTHONPATH的一部分。

对于 Linux 和 Mac OSX 系统,输入export PYTHONPATH=/path/to/ cart.py

对于 Windows 系统,转到控制面板|系统|高级,点击环境变量,要么编辑现有的PYTHONPATH变量,要么添加一个新的变量,指向包含cart.py的文件夹。

  1. 再次运行故事。在下面的截图中很难看到,但是Given an empty cart现在是绿色的:

虽然这个截图只关注第一个场景,但是所有三个场景都有相同的Given。我们编写的代码满足了所有三个Given

  1. 添加代码到steps.py中,实现对第一个场景的Then的支持:
@step("looking up the fifth item causes an error") 
def looking_up_fifth_item(step):
    try:
      world.cart.item(5)
      raise AssertionError("Expected IndexError") 
    except IndexError, e:
      pass
@step("looking up a negative price causes an error")
    def looking_up_negative_price(step):
        try:
          world.cart.price(-2)
             raise AssertionError("Expected IndexError")
        except IndexError, e:
          pass
@step("the price with no taxes is (.*)")
    def price_with_no_taxes(step, total):
       assert world.cart.total(0.0) == float(total)
@step("the price with taxes is (.*)")
    def price_with_taxes(step, total):
        assert world.cart.total(10.0) == float(total)

  1. 再次运行故事,注意第一个场景完全通过了,如下图所示:

  1. 现在在steps.py中添加代码,以实现对第二个场景所需的步骤:
@step("I add a carton of milk for (.*)")
def add_a_carton_of_milk(step, price):
    world.cart.add("carton of milk", float(price))
@step("I add another carton of milk for (.*)")
def add_another_carton_of_milk(step, price):
    world.cart.add("carton of milk", float(price))
@step("the first item is a carton of milk")
def check_first_item(step):
    assert world.cart.item(1) == "carton of milk"
@step("the price is (.*)")
def check_first_price(step, price):
    assert world.cart.price(1) == float(price)
@step("the cart has (.*) items")
def check_size_of_cart(step, num_items): 
    assert len(world.cart) == float(num_items)
@step("the total cost with (.*)% taxes is (.*)")
def check_total_cost(step, tax_rate, total):
    assert world.cart.total(float(tax_rate))==float(total)
  1. 最后,在steps.py中添加代码来实现最后一个场景所需的步骤:
@step("I add a carton of milk")
def add_a_carton_of_milk(step):
    world.cart.add("carton of milk", 2.50)
@step("I add a frozen pizza")
def add_a_frozen_pizza(step):
    world.cart.add("frozen pizza", 3.00)
@step("the second item is a frozen pizza")
def check_the_second_item(step):
    assert world.cart.item(2) == "frozen pizza"
@step("the first price is (.*)")
def check_the_first_price(step, price):
   assert world.cart.price(1) == float(price)
@step("the second price is (.*)")
def check_the_second_price(step, price): 
    assert world.cart.price(2) == float(price)
@step("the total cost with no taxes is (.*)")
def check_total_cost_with_no_taxes(step, total):
    assert world.cart.total(0.0) == float(total)
@step("the total cost with (.*)% taxes is (.*)")
def check_total_cost_with_taxes(step, tax_rate, total):
    assert round(world.cart.total(float(tax_rate)),2) == float(total)
  1. 通过输入lettuce recipe32运行故事,看看它们现在都通过了。在下一个截图中,我们有所有测试都通过了,一切都是绿色的:

它是如何工作的...

Lettuce 使用流行的Given/When/Then风格的 BDD 故事叙述。

  • Givens:这涉及设置一个场景。这通常包括创建对象。对于我们的每个场景,我们创建了一个ShoppingCart的实例。这与 unittest 的 setup 方法非常相似。

  • Thens:这对应于Given。这些是我们想要在一个场景中执行的操作。我们可以执行多个Then

  • Whens:这涉及测试Then的最终结果。在我们的代码中,我们主要使用 Python 的断言。在少数情况下,我们需要检测异常,我们将调用包装在try-catch块中,如果预期的异常没有发生,则会抛出异常。

无论我们以什么顺序放置Given/Then/When都无所谓。Lettuce 会记录所有内容,以便所有的 Givens 首先列出,然后是所有的When条件,然后是所有的Then条件。Lettuce 通过将连续的Given/When/Then条件转换为And来进行最后的润色,以获得更好的可读性。

还有更多...

如果你仔细看一些步骤,你会注意到一些通配符:

@step("the total cost with (.*)% taxes is (.*)")
def check_total_cost(step, tax_rate, total):
   assert world.cart.total(float(tax_rate)) == float(total)

@step字符串让我们通过使用模式匹配器动态抓取字符串的部分作为变量:

  • 第一个(.*)是一个捕获tax_rate的模式

  • 第二个(.*)是一个捕获total的模式

方法定义显示了这两个额外添加的变量。我们可以随意命名它们。这使我们能够实际上从recipe32.feature驱动测试,包括所有数据,并且只使用steps.py以一种通用的方式将它们连接在一起。

重要的是要指出存储在tax_ratetotal中的实际值是 Unicode 字符串。因为测试涉及浮点数,我们必须转换变量,否则assert会失败。

一个故事应该有多复杂?

在这个示例中,我们将所有内容都放入一个故事中。我们的故事涉及各种购物车操作。随着我们编写更多的场景,我们可能会将其扩展为多个故事。这回到了第一章的 复杂 测试 分解 简单 测试部分中讨论的概念,使用 Unittest 开发基本测试。如果我们在一个场景中包含了太多步骤,它可能会变得太复杂。最好能够在最后轻松验证的情况下可视化单个执行线程。

不要将布线代码与应用程序代码混合在一起

该项目的网站展示了一个构建阶乘函数的示例。它既有阶乘函数,也有单个文件中的布线。出于演示目的,这是可以的。但是对于实际的生产使用,最好将应用程序与 Lettuce 布线解耦。这鼓励了一个清晰的接口并展示了可用性。

Lettuce 在使用文件夹时效果很好

生菜默认情况下会在我们运行它的地方寻找一个features文件夹,并发现任何以.feature结尾的文件。这样它就可以自动找到我们所有的故事并运行它们。

可以使用-s--scenarios来覆盖 features 目录。

另请参阅

第一章的 复杂 测试 分解 简单 测试部分,使用 Unittest 开发基本测试

使用 Should DSL 来使用 Lettuce 编写简洁的断言

Lettuce (lettuce.it)是一个为 Python 构建的 BDD 工具。

Should DSL (www.should-dsl.info)提供了一种更简单的方式来为Then条件编写断言。

这个示例展示了如何安装 Lettuce 和 Should DSL。然后,我们将编写一个测试故事。最后,我们将使用 Should DSL 将其与我们的购物车应用程序进行连接,以练习我们的代码。

准备工作

对于这个示例,我们将使用本章开头展示的购物车应用程序。我们还需要通过以下方式安装 Lettuce 及其依赖项:

  • 通过输入pip install lettuce来安装 Lettuce

  • 通过输入pip install should_dsl来安装 Should DSL

如何做...

通过以下步骤,我们将使用 Should DSL 来在我们的测试故事中编写更简洁的断言:

  1. 创建一个名为recipe33的新目录,以包含此食谱的所有文件。

  2. recipe33中创建一个名为recipe33.feature的新文件,以包含我们的测试场景。

  3. recipe33.feature中创建一个故事,其中包含几个场景来练习我们的购物车,如下所示:

Feature: Shopping cart
  As a shopper
  I want to load up items in my cart
  So that I can check out and pay for them
     Scenario: Empty cart
        Given an empty cart
        Then looking up the fifth item causes an error
        And looking up a negative price causes an error
        And the price with no taxes is 0.0
        And the price with taxes is 0.0
     Scenario: Cart getting loaded with multiple of the same
        Given an empty cart
        When I add a carton of milk for 2.50
        And I add another carton of milk for 2.50
        Then the first item is a carton of milk
        And the price is 5.00
        And the cart has 2 items
        And the total cost with 10% taxes is 5.50
     Scenario: Cart getting loaded with different items
        Given an empty cart
        When I add a carton of milk
        And I add a frozen pizza
        Then the first item is a carton of milk
        And the second item is a frozen pizza 
        And the first price is 2.50
        And the second price is 3.00
        And the total cost with no taxes is 5.50
        And the total cost with 10% taxes is 6.05
  1. 编写一组使用 Should DSL 的断言,如下所示:
from lettuce import *
from should_dsl import should, should_not
from cart import *
@step("an empty cart")
def an_empty_cart(step):
    world.cart = ShoppingCart()
@step("looking up the fifth item causes an error")
def looking_up_fifth_item(step):
   (world.cart.item, 5) |should| throw(IndexError)
@step("looking up a negative price causes an error")
def looking_up_negative_price(step):
   (world.cart.price, -2) |should| throw(IndexError)
@step("the price with no taxes is (.*)")
def price_with_no_taxes(step, total):
   world.cart.total(0.0) |should| equal_to(float(total))
@step("the price with taxes is (.*)")
def price_with_taxes(step, total):
   world.cart.total(10.0) |should| equal_to(float(total))
@step("I add a carton of milk for 2.50")
def add_a_carton_of_milk(step):
   world.cart.add("carton of milk", 2.50)
@step("I add another carton of milk for 2.50")
def add_another_carton_of_milk(step):
   world.cart.add("carton of milk", 2.50)
@step("the first item is a carton of milk")
def check_first_item(step):
   world.cart.item(1) |should| equal_to("carton of milk")
@step("the price is 5.00")
def check_first_price(step):
   world.cart.price(1) |should| equal_to(5.0)
@step("the cart has 2 items")
def check_size_of_cart(step):
   len(world.cart) |should| equal_to(2)
@step("the total cost with 10% taxes is 5.50")
def check_total_cost(step):
   world.cart.total(10.0) |should| equal_to(5.5)
@step("I add a carton of milk")
def add_a_carton_of_milk(step):
   world.cart.add("carton of milk", 2.50)
@step("I add a frozen pizza")
def add_a_frozen_pizza(step):
   world.cart.add("frozen pizza", 3.00)
@step("the second item is a frozen pizza")
def check_the_second_item(step):
   world.cart.item(2) |should| equal_to("frozen pizza")
@step("the first price is 2.50")
def check_the_first_price(step):
   world.cart.price(1) |should| equal_to(2.5)
@step("the second price is 3.00")
def check_the_second_price(step):
   world.cart.price(2) |should| equal_to(3.0)
@step("the total cost with no taxes is 5.50")
def check_total_cost_with_no_taxes(step):
   world.cart.total(0.0) |should| equal_to(5.5)
@step("the total cost with 10% taxes is (.*)")
def check_total_cost_with_taxes(step, total):
   world.cart.total(10.0) |should| close_to(float(total),\
delta=0.1)
  1. 运行故事:

它是如何工作的...

前一个食谱(使用 Lettuce 编写可测试的故事)展示了更多关于 Lettuce 如何工作的细节。这个食谱演示了如何使用 Should DSL 来进行有用的断言。

为什么我们需要 Should DSL?我们编写的最简单的检查涉及测试值以确认购物车应用程序的行为。在前一个食谱中,我们主要使用了 Python 断言,比如:

assert len(context.cart) == 2

这很容易理解。Should DSL 提供了一个简单的替代方案,就是这个:

len(context.cart) |should| equal_to(2)

这看起来有很大的不同吗?有人说是,有人说不是。它更啰嗦,对于一些人来说更容易阅读。对于其他人来说,它不是。

那么为什么我们要访问这个?因为 Should DSL 不仅仅有equal_to。还有许多其他命令,比如这些:

  • be:检查身份

  • contain, include, be_into:验证对象是否包含或包含另一个对象

  • be_kind_of:检查类型

  • be_like:使用正则表达式进行检查

  • be_thrown_by,throws:检查是否引发了异常

  • close_to:检查值是否接近,给定一个增量

  • end_with:检查字符串是否以给定的后缀结尾

  • equal_to:检查值的相等性

  • respond_to:检查对象是否具有给定的属性或方法

  • start_with:检查字符串是否以给定的前缀开头

还有其他替代方案,但这提供了多样的比较。如果我们想象需要编写检查相同事物的断言所需的代码,那么事情会变得更加复杂。

例如,让我们考虑确认预期的异常。在前一个食谱中,我们需要确认在访问购物车范围之外的项目时是否引发了IndexError。简单的 Python assert不起作用,所以我们编写了这个模式:

try:
  world.cart.price(-2)
  raise AssertionError("Expected an IndexError") 
except IndexError, e:
   pass

这很笨拙且丑陋。现在,想象一个更复杂、更现实的系统,以及在许多测试情况下使用这种模式来验证是否引发了适当的异常。这可能很快变成一项昂贵的编码任务。

值得庆幸的是,Should DSL 将这种异常断言模式转变为一行代码:

(world.cart.price, -2) |should| throw(IndexError)

这是清晰而简洁的。我们可以立即理解,使用这些参数调用此方法应该引发某个异常。如果没有引发异常,或者引发了不同的异常,它将失败并给我们清晰的反馈。

如果你注意到,Should DSL 要求将方法调用拆分为一个元组,其中元组的第一个元素是方法句柄,其余是方法的参数。

还有更多...

在本章中列出的示例代码中,我们使用了|should|。但是 Should DSL 也带有|should_not|。有时,我们想要表达的条件最好用|should_not|来捕捉。结合之前列出的所有匹配器,我们有大量的机会来测试事物,无论是积极的还是消极的。

但是,不要忘记,如果阅读起来更容易,我们仍然可以使用 Python 的普通assert。关键是有很多表达相同行为验证的方式。

另请参阅

  • 使用 Lettuce 编写可测试的故事。

更新项目级别的脚本以运行本章的 BDD 测试

在本章中,我们已经开发了几种策略来编写和练习 BDD 测试。这应该有助于我们开发新项目。对于任何项目来说,一个无价的工具是拥有一个顶级脚本,用于管理打包、捆绑和测试等事物。

本配方显示了如何创建一个命令行项目脚本,该脚本将使用各种运行程序运行本章中创建的所有测试。

准备工作

对于这个配方,我们需要编写本章中的所有配方。

如何做...

使用以下步骤,我们将创建一个项目级别的脚本,该脚本将运行本章中的所有测试配方:

  1. 创建一个名为recipe34.py的新文件。

  2. 添加使用getopt库来解析命令行参数的代码,如下所示:

import getopt
import logging 
import nose 
import os 
import os.path 
import re 
import sys 
import lettuce 
import doctest
from glob import glob
def usage(): 
    print()
    print("Usage: python recipe34.py [command]" 
    print()
    print "\t--help" 
    print "\t--test" 
    print "\t--package" 
    print "\t--publish" 
    print "\t--register" 
    print()
    try:
      optlist, args = getopt.getopt(sys.argv[1:], 
               "h",
              ["help", "test", "package", "publish", "register"]) 
   except getopt.GetoptError:
       # print help information and exit:
       print "Invalid command found in %s" % sys.argv 
       usage()
       sys.exit(2)
  1. 添加一个使用我们自定义的nose插件BddPrinter的测试函数,如下所示:
def test_with_bdd():
    from recipe26_plugin import BddPrinter
    suite = ["recipe26", "recipe30", "recipe31"] 
    print("Running suite %s" % suite)
    args = [""] 
    args.extend(suite) 
    args.extend(["--with-bdd"])
    nose.run(argv=args, plugins=[BddPrinter()])
  1. 添加一个测试函数,执行基于文件的doctest
def test_plain_old_doctest():
   for extension in ["doctest", "txt"]:
       for doc in glob("recipe27*.%s" % extension): 
           print("Testing %s" % doc) 
           doctest.testfile(doc)
  1. 添加一个测试函数,使用自定义的doctest运行器执行多个doctest
def test_customized_doctests():
    def test_customized_doctests():
    from recipe28 import BddDocTestRunner
    old_doctest_runner = doctest.DocTestRunner 
    doctest.DocTestRunner = BddDocTestRunner
    for recipe in ["recipe28", "recipe29"]:
        for file in glob("%s*.doctest" % recipe): 
            given = file[len("%s_" % recipe):] 
            given = given[:-len(".doctest")] 
            given = " ".join(given.split("_"))
            print("===================================") 
            print("%s: Given a %s..." % (recipe, given)) 
            print( "===================================") 
            doctest.testfile(file)
            print()
    doctest.DocTestRunner = old_doctest_runner
  1. 添加一个测试函数,执行 Lettuce 测试:
def test_lettuce_scenarios():
    print("Running suite recipe32")
    lettuce.Runner(os.path.abspath("recipe32"), verbosity=3).run()
    print()
    print("Running suite recipe33") 
    lettuce.Runner(os.path.abspath("recipe33"), verbosity=3).run() 
    print()
  1. 添加一个顶层测试函数,运行所有的测试函数,并可以连接到命令行选项:
def test():
    def test(): 
        test_with_bdd()
        test_plain_old_doctest() 
        test_customized_doctests() 
        test_lettuce_scenarios()
  1. 添加一些额外的存根函数,代表打包、发布和注册选项:
def package():
    print "This is where we can plug in code to run " + \ 
          "setup.py to generate a bundle."
def publish():
    print "This is where we can plug in code to upload " + \ 
          "our tarball to S3 or some other download site."
def register():
    print "setup.py has a built in function to " + \ 
          "'register' a release to PyPI. It's " + \ 
          "convenient to put a hook in here."
    # os.system("%s setup.py register" % sys.executable)
  1. 添加代码来解析命令行选项:
if len(optlist) == 0:
   usage()
   sys.exit(1)
# Check for help requests, which cause all other
# options to be ignored.
for option in optlist:
   if option[0] in ("--help", "-h"):
      usage()
      sys.exit(1)
# Parse the arguments, in order
for option in optlist:
   if option[0] in ("--test"):
      test()
   if option[0] in ("--package"):
      package()
   if option[0] in ("--publish"):
      publish()
   if option[0] in ("--register"):
      registe
  1. 不带任何选项运行脚本:

  1. 使用–test运行脚本:
(ptc)gturnquist-mbp:04 gturnquist$ python recipe34.py --test Running suite ['recipe26', 'recipe30', 'recipe31']
...
  Scenario: Cart getting loaded with different items        #
recipe33/recipe33.feature:22
     Given an empty cart                                    #
recipe33/steps.py:6
     When I add a carton of milk                            #
recipe33/steps.py:50
     And I add a frozen pizza                               #
recipe33/steps.py:54
     Then the first item is a carton of milk                #
recipe33/steps.py:34
     And the second item is a frozen pizza                  #
recipe33/steps.py:58
     And the first price is 2.50                            #
recipe32/steps.py:69
     And the second price is 3.00                           #
recipe33/steps.py:66
     And the total cost with no taxes is 5.50               #
recipe33/steps.py:70
     And the total cost with 10% taxes is 6.05              #
recipe33/steps.py:74
1 feature (1 passed)
3 scenarios (3 passed)
21 steps (21 passed) 
  1. 使用--package --publish --register运行脚本。看一下这个截图:

它是如何工作的...

此脚本使用 Python 的getopt库。

另请参阅

有关如何以及为什么使用getopt,编写项目级别脚本的原因,以及为什么我们使用getopt而不是optparse的更多细节。

第五章:使用验收测试编写高级客户场景

在本章中,我们将涵盖以下内容:

  • 安装 Pyccuracy

  • 使用 Pyccuracy 测试基础知识

  • 使用 Pyccuracy 验证 Web 应用程序安全性

  • 安装机器人框架

  • 使用 Robot 框架创建数据驱动的测试套件

  • 使用 Robot 框架编写可测试的故事

  • 给 Robot 框架测试打标签并运行子集

  • 使用 Robot 框架测试 Web 基础知识

  • 使用 Robot 框架验证 Web 应用程序安全性

  • 创建一个项目级脚本来运行本章的验收测试

介绍

验收测试涉及编写测试来证明我们的代码是可以接受的!但是,这是什么意思?上下文意味着从客户的角度来看是可以接受的。客户通常更感兴趣的是软件的功能,而不是它的工作方式。这意味着测试的目标是输入和输出,并且往往比单元测试更高级。这有时被称为黑盒测试,并且通常更加系统化。在一天结束时,它通常与断言客户是否接受软件有关。

一些开发人员假设验收测试涉及验证 Web 应用程序的前端。实际上,包括 Pyccuracy 在内的几个测试工具都是基于测试 Web 应用程序的唯一前提构建的。从客户是否接受软件的角度来看,这实际上符合客户的接受标准。

然而,Web 测试并不是唯一形式的验收测试。并非所有系统都是基于 Web 的。如果一个子系统由一个团队构建并交给另一个计划在其上构建另一层的团队,那么在第二个团队接受之前可能需要进行验收测试。

在本章中,我们将深入探讨涉及 Web 和非 Web 应用程序验收测试的一些方法。

要创建一个用于测试的电子商店 Web 应用程序,请按照以下步骤进行:

  1. 确保您的系统上已安装mercurial
  • 对于 macOS,请使用 MacPorts 或 Homebrew

  • 对于 Ubuntu/Debian,请使用sudo apt-get install mercurial

  • 对于其他系统,您需要额外研究安装mercurial

  1. 这还需要安装可编译的工具,如gcc
  • 对于 Ubuntu,请使用sudo apt-get install build-essential

  • 对于其他系统,您需要额外研究安装gcc

  1. 如果您在按照以下步骤安装 Satchmo 时遇到其他问题,请访问项目网站www.satchmoproject.com,可能还要访问他们的支持小组groups.google.com/group/satchmo-users

  2. 通过输入以下命令安装 Satchmo,一个电子商务网站构建器:

pip install -r http://bitbucket.org/gturnquist/satchmo/raw/tip/scripts/requirements.txt
pip install -e hg+http://bitbucket.org/gturnquist/satchmo/#egg=satchmo
  1. 使用pip install PIL安装 Python 的PIL库进行图像处理。

  2. 编辑<virtualenv root>/lib/python2.6/site-packages/django/contrib/admin/templates/admin/login.html,将id="login"添加到Log in<input>标记。这允许 Pyccuracy 抓取Log in按钮并单击它。

  3. 运行 Satchmo 脚本以创建商店应用程序:clonesatchmo.py

  4. 在提示是否创建超级用户时,选择yes

  5. 在提示时,输入一个用户名

  6. 在提示时,输入一个电子邮件地址

  7. 在提示时,输入一个密码

  8. 进入 store 目录:cd store

  9. 启动商店应用程序:python manage.py runserver

如果您在按照这些步骤安装 Satchmo 时遇到问题,请访问项目网站www.satchmoproject.com,可能还要访问他们的支持小组groups.google.com/forum/#!forum/satchmo-users

要创建一个用于测试的非 Web 购物车应用程序,请创建cart.py,其中包含以下代码:

class ShoppingCart(object): 
    def __init__(self): 
        self.items = [] 

    def add(self, item, price): 
        for cart_item in self.items: 
            # Since we found the item, we increment 
            # instead of append 
            if cart_item.item == item: 
                cart_item.q += 1 
                return self 

        # If we didn't find, then we append 
        self.items.append(Item(item, price)) 
        return self 

    def item(self, index): 
        return self.items[index-1].item 

    def price(self, index): 
        return self.items[index-1].price * self.items[index-1].q 

    def total(self, sales_tax): 
        sum_price = sum([item.price*item.q for item in self.items]) 
        return sum_price*(1.0 + sales_tax/100.0) 

    def __len__(self): 
        return sum([item.q for item in self.items]) 

class Item(object): 
    def __init__(self, item, price, q=1): 
        self.item = item 
        self.price = price 
        self.q = q 

这个购物车具有以下特点:

  • [1]开始,这意味着第一个项目和价格不是[0]

  • 包括具有相同项目的多个能力

  • 将计算总价格,然后添加税金

这个应用程序并不复杂。也许它并不完全看起来像一个系统级别,但它确实提供了一个易于编写验收测试的应用程序。

安装 Pyccuracy

Pyccuracy 是使用 BDD 风格语言编写 Web 验收测试的有用工具。本食谱展示了安装它并为后续食谱设置它所需的所有步骤。

如何做...

通过这些步骤,我们将安装 Pyccuracy 和运行本章后续场景所需的所有工具:

  1. 通过输入pip install pyccuracy来安装Pyccuracy

  2. github.com/heynemann/pyccuracy/raw/master/lib/selenium-server.jar下载selenium-server.jar

  3. 通过输入java -jar selenium-server.jar来启动它。请注意,如果您没有安装 Java,您肯定需要下载并安装它。

  4. 通过输入pip install lxml来安装lxml

  5. 创建一个名为recipe35.acc的简单测试文件,并输入以下代码:

As a Yahoo User
I want to search Yahoo
So that I can test my installation of Pyccuracy

Scenario 1 - Searching for Python Testing Cookbook
Given
    I go to "http://yahoo.com"
When
    I fill "p" textbox with "Python Testing Cookbook"
    And I click "search-submit" button and wait
Then
    I see "Python Testing Cookbook - Yahoo! Search Results" title
  1. 输入pyccuracy_console -p test.acc来运行。以下截图显示它在 Firefox 中运行(系统默认):

  1. 再次运行,使用不同的网页浏览器,如 Safari,输入pyccuracy_console -p test.acc -b safari

在撰写本文时,Selenium 支持 Firefox、Safari、Opera 和 IE 7+,但不支持 Chrome。

  1. 在运行测试的文件夹中,现在应该有一个report.html文件。使用浏览器打开它以查看结果。然后,点击展开全部:

它是如何工作的...

Pyccuracy 使用 Selenium,一个流行的浏览器驱动应用程序测试工具来运行其场景。Pyccuracy 提供了一个开箱即用的领域特定语言DSL)来编写测试。DSL 提供了发送命令到测试浏览器并检查结果的手段,验证 Web 应用程序的行为。

在本章后面,还有几个食谱展示了 Pyccuracy 的更多细节。

另请参阅

  • 使用 Pyccuracy 测试基础知识

  • 使用 Pyccuracy 验证 Web 应用程序安全

使用 Pyccuracy 测试基础知识

Pyccuracy 提供了一套易于阅读的操作,用于驱动 Web 应用程序的前端。本食谱展示了如何使用它来驱动购物车应用程序并验证应用程序功能。

准备工作

  1. 如果尚未运行,请在另一个 shell 或窗口中输入java -jar selenium-server.jar启动 Selenium 服务器:

  1. 如果 Satchmo 商店应用尚未运行,请在另一个 shell 或窗口中输入 python manage.py runserver 启动它。

这必须在virtualenv环境中运行。

如何做...

使用这些步骤,我们将探索编写 Pyccuracy 测试的基础知识:

  1. 创建一个名为recipe36.acc的新文件。

  2. 创建一个加载商品到购物车的故事:

As a store customer
I want to put things into my cart
So that I can verify the store's functionality.
  1. 添加一个场景,其中详细查看了空购物车,并确认余额为$0.00
Scenario 1 - Inspect empty cart in detail
Given
I go to "http://localhost:8000"
When
I click "Cart" link and wait
Then
I see that current page contains "Your cart is empty"
And I see that current page contains "0 - $0.00"
  1. 添加另一个场景,其中选择了一本书,并将其中两本添加到购物车中:
Scenario 2 - Load up a cart with 2 of the same
Given
I go to "http://localhost:8000"
When
I click "Science Fiction" link
And I click "Robots Attack!" link and wait
And I fill "quantity" textbox with "2"
And I click "addcart" button and wait
And I click "Cart" link and wait
Then
I see that current page contains "Robots Attack!"
And I see "quantity" textbox contains "2"
And I see that current page contains "<td align="center">$7.99</td>"
And I see that current page contains "<td align="center">$15.98</td>"
And I see that current page contains "<td>$15.98</td>"
  1. 通过输入pyccuracy_console -p recipe36.acc来运行故事:

它是如何工作的...

Pyccuracy 具有许多基于驱动浏览器或读取页面的内置操作。这些操作是用于解析故事文件并生成发送到 Selenium 服务器的命令的模式,然后驱动浏览器,然后读取页面的结果。

关键是选择正确的文本来识别正在操作或阅读的元素。

缺少 ID 标签的 Web 应用程序更难阅读。

还有更多...

关键是选择正确的标识符和元素类型。有了良好的标识符,就可以轻松地执行诸如—点击购物车**链接之类的操作。你注意到我们在查看购物车表时遇到的问题了吗?HTML <table>标签没有标识符,这使我们无法选择。相反,我们不得不查看整个页面并全局搜索一些标记。

这使得测试变得更加困难。一个好的解决方案是修改 Web 应用程序以在<table>标签中包含一个 ID。然后,我们将我们的验收标准缩小到只有表格。对于这个应用程序来说还好,但对于复杂的 Web 应用程序来说,如果没有良好的 ID,要找到我们正在寻找的确切文本将会更加困难。

这提出了一个有趣的问题——应该修改应用程序以支持测试吗?简单地说,是的。为关键的 HTML 元素添加一些良好的标识符以支持测试并不是一个重大的动荡。这并没有对应用程序进行重大的设计更改。最终结果是更容易阅读的测试用例和更好的自动化测试。

这引出了另一个问题——如果使应用程序更易于测试确实涉及重大设计更改怎么办?这可以被视为工作中的一个重大干扰。或者,也许这是一个强烈的暗示,我们的设计组件耦合过于紧密或者不够内聚。

在软件开发中,耦合内聚性是主观的术语,很难衡量。可以说的是,不易于测试的应用程序通常是单片的,难以维护,并且可能存在循环依赖,这意味着我们作为开发人员很难进行更改,以满足需求而不影响整个系统。

当然,这与我们配方的情况有很大的不同,我们只是缺少 HTML 表的标识符。然而,重要的是要问这个问题——如果我们需要的变化比这么小的东西更多呢?

另请参阅

安装 Pyccuracy

使用 Pyccuracy 来验证 Web 应用程序的安全性

应用程序通常有登录屏幕。测试安全的 Web 应用程序要求我们将登录过程作为自定义操作进行捕捉。这样,我们可以重复使用它,直到我们需要的场景为止。

准备工作

  1. 如果尚未运行,请在另一个 shell 或窗口中输入java -jar selenium-server.jar来启动 Selenium 服务器。

  2. 如果 Satchmo 商店应用程序尚未运行,请在另一个 shell 或窗口中输入 python manage.py runserver 来启动它。

这必须在virtualenv环境中运行。

如何做...

通过以下步骤,我们将练习一个 Web 应用程序的安全性,然后看看如何通过创建自定义操作来扩展 Pyccuracy:

  1. 创建一个名为recipe37.acc的新文件,将此配方的场景放入其中。

  2. 为练习 Django 的管理员应用程序创建一个故事:

As a system administrator, 
I want to log in to Django's admin page 
so that I can check the product catalog.
  1. 添加一个登录到管理员应用程序的场景:
Scenario 1 - Logging in to the admin page
Given
    I go to "http://localhost:8000/admin"
When
    I fill "username" textbox with "gturnquist"
    And I fill "password" textbox with "password"
    And I click "login" button and wait
Then
    I see that current page contains 
    "<ahref="product/product/">Products</a>"
  1. 添加一个检查产品目录的场景,使用自定义登录操作:
Scenario 2 - Check product catalog
Given
    I am logged in with username "gturnquist" and password "password"
When
    I click "Products" link and wait
Then
    I see that current page contains "robot-attack"
  1. 创建一个名为recipe37.py的匹配文件,其中包含自定义定义的操作。

  2. 编写登录到管理员操作的自定义操作:

from pyccuracy.actions import ActionBase
from pyccuracy.errors import *

class LoggedInAction(ActionBase):
    regex = r'(And )?I am logged in with username ["] (?P<username>.+)["] and password "["]$'
    def execute(self, context, username, password):
        self.execute_action(u'I go to "http://localhost:8000/
admin"', context)
    logged_in = False
    try:
        self.execute_action(
          u'And I see that current page contains "id_username"', context)
        except ActionFailedError:
            logged_in = True
        if not logged_in:
            self.execute_action(u'And I fill "username" textbox with "%s"' % username, context)
            self.execute_action(u'And I fill "password" textbox with "%s"' % password, context)
            self.execute_action(u'And I click "login" button', context)
  1. 通过输入pyccuracy_console -p recipe37.acc来运行故事:

工作原理...

第一个场景展示了练习登录屏幕所需的简单步骤。在证明登录屏幕有效之后,重复这个过程以进行更多场景变得繁琐。

为了处理这个问题,我们通过扩展ActionBase在 Python 中创建一个自定义操作。自定义操作需要一个正则表达式来定义 DSL 文本。接下来,我们定义一个execute方法,包括应用逻辑和 Pyccuracy 步骤的组合来执行。基本上,我们可以定义一组步骤来自动执行操作并动态处理不同的情况。

在我们的情况下,我们编写了代码来处理用户是否已经登录。通过这个自定义操作,我们构建了第二个场景,并用一个语句处理了登录,使我们能够继续测试我们场景的核心部分。

另请参阅

安装 Pyccuracy

安装 Robot Framework

Robot Framework 是一个有用的框架,用于使用关键字方法编写验收测试。关键字是各种库提供的简写命令,也可以是用户定义的。这很容易支持 BDD 风格的Given-When-Then关键字。它还为第三方库定义自定义关键字打开了大门,以与其他测试工具(如 Selenium)集成。这也意味着使用 Robot Framework 编写的验收测试不局限于 Web 应用程序。

本配方展示了安装 Robot Framework 以及后续配方中使用的第三方 Robot Framework Selenium 库的所有步骤。

如何做...

  1. 确保激活您的virtualenv沙箱。

  2. 通过输入easy_install robotframework进行安装。

在撰写本文时,Robot Framework 无法使用pip安装。

  1. 使用任何类型的窗口导航器,转到<virtualenvroot>/build/robotframework/doc/quickstart并用您喜欢的浏览器打开quickstart.html。这不仅是一个指南,也是一个可运行的测试套件。

  2. 切换到 Robot Framework 的虚拟环境构建目录:cd<virtualenvroot>/build/robotframework/doc/quickstart

  3. 通过pybot quickstart.html运行快速入门手册以验证安装:

  1. 检查测试运行生成的report.htmllog.htmloutput.xml文件。

  2. 安装 Robot Framework Selenium 库,以便首先下载robotframework-seleniumlibrary.googlecode.com/files/robotframework-seleniumlibrary-2.5.tar.gz进行集成。

  3. 解压 tarball。

  4. 切换到带有cd robotframework-seleniumlibrary-2.5的目录。

  5. 使用python setup.py install安装软件包。

  6. 切换到演示目录,使用cd demo

  7. 使用python run demo.py demoapp start启动演示 Web 应用程序。

  8. 使用python run demo.py selenium start启动 Selenium 服务器。

  9. 使用pybot login_tests运行演示测试:

  1. 使用python run demo.py demoapp stop关闭演示 Web 应用程序。

  2. 使用python run demo.py selenium stop关闭 Selenium 服务器。

  3. 检查测试运行生成的report.htmllog.htmloutput.xmlselenium_log.txt文件。

还有更多...

通过这个配方,我们已经安装了 Robot Framework 和一个与 Selenium 集成的第三方库。

有许多第三方库为 Robot Framework 提供了增强功能。这些选项有足够的潜力填满一本书。因此,我们必须把焦点缩小到 Robot Framework 提供的一些核心功能,包括 Web 和非 Web 测试。

使用 Robot Framework 创建数据驱动的测试套件

Robot Framework 使用关键字来定义测试、测试步骤、变量和其他测试组件。关键字是各种库提供的简写命令,也可以是自定义的。这允许以许多不同的方式编写和组织测试。

在这个配方中,我们将探讨如何使用不同的输入和输出运行相同的测试过程。这些可以被描述为数据驱动测试。

准备工作

  1. 我们首先需要激活我们的virtualenv设置

  2. 对于这个配方,我们将使用购物车应用程序

  3. 接下来,我们需要安装 Robot Framework,如前一个配方所示

如何做...

以下步骤将向我们展示如何使用 HTML 表编写简单的验收测试:

  1. 创建一个名为recipe39.html的新文件来捕捉测试和配置。

  2. 添加一个包含一组数据驱动测试用例的 HTML 段落和表,如下所示的浏览器截图:

  1. 添加另一个 HTML 段落和表,定义自定义关键字 Adding items to cart 和 Add item:

  1. 创建一个名为recipe39.py的新文件,其中包含与我们的自定义关键字相关联的 Python 代码。

  2. 创建一个旧式的 Python 类,实现所需场景的自定义关键字:

from cart import *

class recipe39:
    def __init__(self):
        self.cart = ShoppingCart()
    def add_item_to_cart(self, description, price):
        self.cart.add(description, float(price))
    def get_total(self, tax):
        return format(self.cart.total(float(tax)), ".2f")

重要的是要定义类为旧式。如果我们将其定义为新式,即通过子类化object,Robot Framework 的运行器pybot将无法找到这些方法并将它们与我们的 HTML 关键字关联起来。

  1. 添加第三个 HTML 段落和表,加载我们的 Python 代码来实现 Add item to cart 和 Get total:

  1. 在您喜欢的浏览器中查看 HTML 文件:

  1. 通过在终端输入pybot recipe39.html来运行 HTML 文件以执行测试:

  1. 您可以使用您喜欢的浏览器检查report.htmllog.html,以获取有关结果的更多详细信息。

它是如何工作的...

Robot Framework 使用 HTML 表来定义测试组件。表的标题行标识了表定义的组件类型。

我们创建的第一个表是一组测试用例。Robot Framework 通过在标题行的第一个单元格中看到Test Case来识别这一点。标题的其余单元格不会被解析,这使我们可以自由地输入描述性文本。在这个示例中,我们的每个测试用例都是用一行定义的。第二列在每一行上都有Adding items to cart,这是在第二个表中定义的自定义关键字。其余的列是这些自定义关键字的参数。

我们编写的第二个表用于定义自定义关键字。Robot Framework 通过在标题行的第一个单元格中看到Keyword来确定这一点。我们的表定义了两个关键字:

  • Adding items to cart

  • 第一行通过以[Arguments]开头来定义参数,有六个输入变量:${item1}${price1}${item2}${price2}${tax}${total}

  • 接下来的一组行是操作

  • 第二行和第三行使用了另一个自定义关键字:Add item,带有两个参数。

  • 第四行定义了一个新变量${calculated total},它被赋予了另一个关键字Get total的结果,该关键字带有一个参数${tax},该参数在我们的 Python 模块中定义。

  • 最后一行使用了一个内置关键字Should Be Equal,来确认Get total的输出是否与原始${total}匹配。

  • Add item

  • 第一行通过以[Arguments]开头来定义参数,有两个输入变量:${description}${price}

  • 第二行使用了另一个关键字Add item to cart,该关键字在我们的 Python 模块中定义,带有两个命名参数:${description}${price}

我们制作的第三个表包含设置。通过在标题行的第一个单元格中看到Setting来识别。该表用于导入包含最终关键字的 Python 代码,使用内置关键字Library

还有更多...

Robot Framework 通过一个非常简单的约定将我们的关键字映射到我们的 Python 代码:

  • Get total ${tax}映射到get_total(self,tax)

  • Add item to cart ${description} ${price}映射到add_item_to_cart(self, description, price)

我们需要add_item_to_cart而不能只写add_item来连接Add item关键字的原因是因为 Robot Framework 在连接 Python 代码时使用命名参数。由于我们表中每次使用Add item都有不同的变量名,我们需要一个带有不同参数的单独关键字。

我必须写 HTML 表吗?

Robot Framework 由 HTML 表格驱动,但表格的生成方式并不重要。许多项目使用诸如reStructuredText (docutils.sourceforge.net/rst.html)之类的工具以更简洁的方式编写表格,然后使用转换器将其转换为 HTML。将.rst转换为 HTML 的有用工具是docutils (docutils.sourceforge.net/)。它提供了一个方便的rst2html.py脚本,可以将所有.rst表格转换为 HTML。

不幸的是,本书的格式使得很难将.rst呈现为代码或屏幕截图。

编写实现我们自定义关键字的代码的最佳方法是什么?

我们编写了一大块 Python 代码,将我们的自定义关键字与ShoppingCart应用程序联系起来。使其尽可能轻量化非常重要。为什么?因为当我们部署实际应用程序时,这个桥梁不应该是其中的一部分。可能会诱人将此桥梁用作捆绑事物或转换事物的机会,但应该避免这样做。

相反,最好将这些功能包含在软件应用程序本身中。然后,这个额外的功能就成为经过测试、部署的软件功能的一部分。

如果我们不过多地投资于桥接代码,可以帮助我们避免使软件依赖于测试框架。出于某种原因,如果我们决定切换到 Robot Framework 之外的其他东西,由于在桥接代码中投入了太多,我们不会被束缚在特定工具中。

Robot Framework 变量是 Unicode

在使我们的 Python 代码工作的过程中,另一个关键因素是认识到输入值是 Unicode 字符串。由于ShoppingCart是基于浮点值的,我们必须使用 Python 的float(input)函数来转换输入,以及format(output, ".2f")来转换输出。

这是否与前面讨论的尽可能保持轻量级的桥梁相矛盾?并不是。通过使用纯粹的内置 Python 函数,没有副作用,我们不会陷入困境,而只是将格式消息传递给排列事物。如果我们开始操纵容器,或将字符串转换为列表,反之亦然,甚至定义新类,那肯定会对这个桥梁太重了。

另请参阅

安装 Robot Framework

使用 Robot Framework 编写可测试的故事

正如本章前面讨论的那样,Robot Framework 让我们可以使用自定义定义的关键字。

这使我们能够以任何风格构建关键字。在这个配方中,我们将定义实现 BDD-Given-When-Then风格规范的自定义关键字。

准备工作

  1. 我们首先需要激活我们的virtualenv设置。

  2. 对于这个配方,我们将使用购物车应用程序。

  3. 接下来,我们需要安装 Robot Framework,就像本章的前几节所示。

如何做...

以下步骤将探讨如何编写 BDD-Given-When-Then风格的验收测试:

  1. 创建一个名为recipe40.html的新文件,放入我们的 HTML 表格。

  2. 在 HTML 中创建一个带有开头声明的故事文件:

  1. 添加一个包含几个场景的表格,用于使用一系列Given-When-Then关键字来执行购物车应用程序。

  1. 添加第二个表格,定义我们所有自定义的Given-When-Then自定义关键字。

  1. 创建一个名为recipe40.py的新文件,将自定义关键字与ShoppingCart应用程序链接起来的 Python 代码放入其中。
from cart import *
class recipe40:
def __init__(self):
self.cart = None
def create_empty_cart(self):
self.cart = ShoppingCart()
def lookup_item(self, index):
try:
return self.cart.item(int(index))
except IndexError:
return "ERROR"
def lookup_price(self, index):
try:
return format(self.cart.price(int(index)), ".2f")
except IndexError:
return "ERROR"
def add_item(self, description, price):
self.cart.add(description, float(price))
def size_of_cart(self):
return len(self.cart)
def total(self, tax):
return format(self.cart.total(float(tax)), ".2f")

这个类实现为旧式是至关重要的。如果通过扩展object实现为新式,Robot Framework 将无法链接关键字。

  1. 在我们的recipe40.html文件中添加第三个表格来导入我们的 Python 模块。

  1. 通过输入pybot recipe40.html来运行故事:

工作原理...

Robot Framework 使用 HTML 表来定义测试组件。表的标题行标识了表定义的组件类型。

我们创建的第一个表是一组测试用例。Robot Framework 通过在标题行的第一个单元格中看到Test Case来识别这一点。标题行的其余单元格不被解析,这使我们可以自由地放入描述性文本。

在这个示例中,我们的每个测试用例都包含了使用 BDD 测试人员熟悉的Given-When-Then风格的几个自定义关键字。许多这些关键字有一个或多个参数。

我们编写的第二个表用于定义我们的自定义Given-When-Then关键字。Robot Framework 通过在标题行的第一个单元格中看到Keyword来解决了这个问题。

我们制作的第三个表包含设置。通过在标题行的第一个单元格中看到Setting来识别。这个表用于导入包含最终关键字的 Python 代码,使用内置关键字Library

在这个示例中,我们自定义关键字的一个重要方面是,我们用自然流畅的语言编写了它们:

When I add a carton of milk for 2.50 

为了参数化输入并使关键字可重用于多个测试步骤,这被分解为四个 HTML 单元格:

Robot Framework 将其视为一个自定义关键字When``I``add``a,有三个参数:carton of milkfor2.50

稍后,我们将填写与此关键字相关的实际步骤。这样做时,我们只关心使用carton of milk2.50,但我们仍然必须将for视为输入变量。我们使用一个占位符变量${noop}来实现这一点,在任何后续关键字步骤中我们将简单地不使用它。

在这个示例中,我们称这个临时变量为${noop}。我们可以随意命名它。如果在同一个关键字中有多个临时参数,我们也可以重复使用它。这是因为 Robot Framework 不进行强类型检查。

还有更多...

我们不得不编写的整个 HTML 块开始感觉有点沉重。如使用 Robot Framework 创建数据驱动测试套件中所述,.rst是一个很好的替代方案。不幸的是,使用.rst编写这个示例对于这本书的格式来说太宽了。有关使用.rst编写更多详细信息和获取工具将.rst转换为 HTML,请参阅该示例。

Given-When-Then 导致重复规则

确实,我们不得不定义Then itemAdd item,它们基本上是相同的,以支持两种不同的测试场景。在其他 BDD 工具中,这些将自动被识别为相同的从句。Robot Framework 并没有直接提供 BDD 领域特定的语言,所以我们不得不自己填写这部分。

处理这个问题的最有效方法是详细定义Then item所需的所有步骤,然后编写And item来调用Then item

相比之下,“当我添加一个”和“而我添加一个”是通过调用“添加项目”来实现的。由于这个从句只是一个简单的传递到我们的 Python 模块,所以不需要像前面的例子那样将它们链接在一起。

另一个选择是调查编写我们自己的 BDD 插件库,以简化所有这些。

try-except 块是否违反了保持轻量的想法?

使用 Robot Framework 创建数据驱动测试套件的示例中,我提到了将 HTML 表与ShoppingCart应用程序连接的代码应尽可能保持轻量,并避免转换和其他操作。

捕获预期异常并返回字符串可能会越过这条线。在我们的情况下,解决方案是定义一个可以处理错误和合法值的单个从句。该从句接受返回的任何内容,并使用内置的Should Be Equal关键字进行验证。

如果情况不是这样,可能更顺利的做法是不使用 try-expect 块,而是使用内置的Run Keyword And Expect Error关键字链接到另一个自定义的 Python 关键字。然而,在这种情况下,我认为保持事情简单的目标得到了满足。

另请参阅

  • 安装 Robot Framework

  • 使用 Robot Framework 创建数据驱动的测试套件

标记 Robot Framework 测试并运行子集

Robot Framework 提供了一种全面的方式来使用表驱动结构捕获测试场景。这包括添加标记和文档的元数据的能力。

标记允许包括或排除用于测试的标记。文档出现在命令行上,也出现在结果报告中。本教程将演示这两个关键特性。

最后,HTML 表格并不是使用 Robot Framework 定义数据表的唯一方式。在本教程中,我们将探讨使用双空格分隔的条目。虽然这不是编写故事的唯一非 HTML 方式,但它是最容易的非 HTML 方式,可以在印刷形式的本书的字体大小限制内进行演示。

准备工作

  1. 我们首先需要激活我们的virtualenv设置。

  2. 创建一个名为cart41.py的新文件,以放置购物车应用程序的备用版本。

  3. 输入以下代码,将购物车存储到数据库中:

class ShoppingCart(object):
    def __init__(self):
        self.items = []
    def add(self, item, price):
        for cart_item in self.items:
            # Since we found the item, we increment
            # instead of append
            if cart_item.item == item:
                cart_item.q += 1
                return self
        # If we didn't find, then we append
        self.items.append(Item(item, price))
        return self
    def item(self, index):
        return self.items[index-1].item
    def price(self, index):
        return self.items[index-1].price * self.items[index-1].q
    def total(self, sales_tax):
        sum_price = sum([item.price*item.q for item in self.items])
        return sum_price*(1.0 + sales_tax/100.0)
    def store(self):
        # This simulates a DB being created.
        f = open("cart.db", "w")
        f.close()
    def retrieve(self, id):
        # This simulates a DB being read.
        f = open("cart.db")
        f.close()
    def __len__(self):
        return sum([item.q for item in self.items])
class Item(object):
    def __init__(self, item, price, q=1):
        self.item = item
        self.price = price
        self.q = q

这个版本的购物车有两个额外的方法:storeretrieve。它们实际上并不与数据库交互,而是创建一个空的cart.db文件。为什么?目的是模拟与数据库的交互。在本教程的后面,我们将展示如何标记涉及此操作的测试用例,并轻松地将它们排除在测试运行之外。

  1. 接下来,我们需要安装 Robot Framework,就像本章前面的部分所示。

操作步骤如下...

以下步骤将展示如何以 HTML 表格之外的格式编写场景,以及如何标记测试以允许在命令行上选择运行哪些测试:

  1. 使用纯文本和空格分隔的条目创建一个名为recipe41.txt的新文件,其中包含一些测试用例:一个简单的测试用例和另一个更复杂的测试用例,带有文档和标记:
*Test Cases*
Simple check of adding one item
    Given an empty cart
    When I add a carton of milk for 2.50
    Then the total with 0 % tax is 2.50
    And the total with 10 % tax is 2.75

More complex by storing cart to database
    [Documentation] This test case has special tagging, so it can be
excluded. This is in case the developer doesn't have the right database
system installed to interact properly.cart.db
    [Tags] database
    Given an empty cart
    When I add a carton of milk for 2.50
    And I add a frozen pizza for 3.50
    And I store the cart
    And I retrieve the cart
    Then there are 2 items

需要注意的是,至少需要两个空格来标识一个单元格和下一个单元格之间的间隔。具有When I add a carton of milk for 2.50的行实际上有四个单元格的信息:| When I add a | carton of milk | for | 2.50 |。实际上,这一行的前缀有一个空的第五个单元格,由两个空格缩进表示。必须将此行标记为测试用例Simple check of adding one item中的一步,而不是另一个测试用例。

  1. 使用纯文本和空格分隔的值创建自定义关键字定义的表:
*Keywords*
Given an empty cart
    create empty cart
When I add a
    [Arguments] ${description} ${noop} ${price}
    add item ${description} ${price}
And I add a
    [Arguments] ${description} ${noop} ${price}
    add item ${description} ${price}
Then the total with
    [Arguments] ${tax} ${noop} ${total}
    ${calc total}= total ${tax}
    Should Be Equal ${calc total} ${total}
And the total with
    [Arguments] ${tax} ${noop} ${total}
    Then the total with ${tax} ${noop} ${total}
And I store the cart
    Set Test Variable ${cart id} store cart
And I retrieve the cart
    retrieve cart ${cart id}
Then there are
    [Arguments] ${size} ${noop}
    ${calc size}= Size of cart
    Should Be Equal As Numbers ${calc size} ${size}
  1. 创建一个名为recipe41.py的新文件,其中包含 Python 代码,用于将一些关键字与购物车应用程序连接起来:
from cart41 import *

class recipe41:
    def __init__(self):
        self.cart = None
    def create_empty_cart(self):
        self.cart = ShoppingCart()
    def lookup_item(self, index):
        try:
            return self.cart.item(int(index))
        except IndexError:
            return "ERROR"
    def lookup_price(self, index):
        try:
            return format(self.cart.price(int(index)), ".2f")
        except IndexError:
            return "ERROR"
    def add_item(self, description, price):
        self.cart.add(description, float(price))
    def size_of_cart(self):
        return len(self.cart)
    def total(self, tax):
        return format(self.cart.total(float(tax)), ".2f")
    def store_cart(self):
        return self.cart.store()
    def retrieve_cart(self, id):
        self.cart.retrieve(id)
    def size_of_cart(self):
        return len(self.cart)
  1. recipe41.txt添加最后一个表,导入我们的 Python 代码作为库,以提供所需关键字的最后一组:
*Settings****** Library recipe41.py
  1. 通过输入pybot recipe41.txt来运行测试场景,就好像我们在一个具有数据库支持的机器上一样:

  1. 通过输入pybot -exclude database recipe41.txt来运行测试场景,排除了被标记为database的测试。

  1. 通过输入pybot -include database recipe41.txt来运行测试场景,包括被标记为database的测试:

  1. 查看report.html,观察额外的[Documentation]文本出现的位置,以及我们的database标记:

工作原理...

在本教程中,我们为第二个测试用例添加了一个额外的部分,包括文档和标记:

More complex by storing cart to database 
  [Documentation]  This test case has special tagging, so it can be excluded. This is in case the developer doesn't have the right database system installed to interact properly.cart.db 
  [Tags]  database 
  Given an empty cart 
  When I add a  carton of milk  for  2.50 
  And I add a   frozen pizza    for  3.50 
  And I store the cart 
  And I retrieve the cart 
  Then there are  2  items 

标记可在命令行上使用,就像前面的示例中所示的那样。它提供了一种有用的方式来组织测试用例。测试用例可以有尽可能多的标记。

我们之前展示了这提供了一个方便的命令行选项,可以根据标签包含或排除。标签还提供有用的文档,report.html的上一个截图显示了测试结果也按标签进行了小计:

  • 标签可用于标识不同层次的测试,例如冒烟测试、集成测试和面向客户的测试

  • 标签还可以用于标记子系统,如数据库、发票、客户服务和结算

还有更多...

这个示例演示了纯文本格式。三个星号用于包围标题单元格,两个空格用于指定两个单元格之间的间隔。

关于这是否比 HTML 更难阅读存在争议。它可能不像阅读 HTML 标记那样清晰,但我个人更喜欢这种方式而不是阅读 HTML 的角度。可以添加更多的空格,以使表格的单元格更清晰,但我没有这样做,因为这本书的字体大小与之不太匹配。

文档呢?

我们还添加了一点演示目的的文档。当pybot运行时,一段文本会出现,并且也会出现在生成的工件中。

另请参阅

  • 安装 Robot Framework

  • 使用 Robot Framework 创建数据驱动的测试套件

  • 使用 Robot Framework 编写可测试的故事

使用 Robot Framework 测试 Web 基础知识

Web 测试是一种常见的验收测试风格,因为客户想知道系统是否可接受,这是展示它的完美方式。

在以前的示例中,我们已经探索了针对非 Web 应用程序编写测试的方法。在这个示例中,让我们看看如何使用第三方 Robot Framework 插件来使用 Selenium 测试购物车 Web 应用程序。

准备好了...

  1. 我们首先需要激活我们的virtualenv设置。

  2. 对于这个示例,我们使用的是 Satchmo 购物车 Web 应用程序。要启动它,请切换到 store 目录并输入 python manage.py runserver。您可以通过访问 http://localhost:8000 来探索它。

  3. 接下来,按照安装RobotFramework示例中所示,安装 Robot Framework 和第三方 Selenium 插件。

如何做...

通过以下步骤,我们将看到如何使用一些基本的 Robot 命令来驱动 Web 应用程序:

  1. 创建一个名为recipe42.txt的纯文本故事文件,其中包含故事的开头描述:
As a store customer
I want to put things into my cart
So that I can verify the store's functionality.
  1. 创建一个测试用例部分,并添加一个验证购物车为空并捕获屏幕截图的场景:
*Test Cases*
Inspect empty cart in detail
  Click link Cart
  Page Should Contain Your cart is empty
  Page Should Contain 0 - $0.00
  Capture Page Screenshot recipe42-scenario1-1.png
  1. 添加另一个场景,选择一本书,将两本书添加到购物车中,并确认总购物车价值:
Load up a cart with 2 of the same
  Click link Science Fiction don't wait
  Capture Page Screenshot recipe42-scenario2-1.png
  Click link Robots Attack!
  Capture Page Screenshot recipe42-scenario2-2.png
  Input text quantity 2
  Capture Page Screenshot recipe42-scenario2-3.png
  Click button Add to cart
  Click link Cart
  Capture Page Screenshot recipe42-scenario2-4.png
  Textfield Value Should Be quantity 2
  Page Should Contain Robots Attack! (Hard cover)
  Html Should Contain <td align="center">$7.99</td>
  Html Should Contain <td align="center">$15.98</td>
  Html Should Contain <td>$15.98</td>
  1. 添加一个关键字部分,并定义一个用于检查页面原始 HTML 的关键字:
*Keywords*
Html Should Contain
    [Arguments]     ${expected}
    ${html}= Get Source
    Should Contain ${html} ${expected}
Startup
    Start Selenium Server
    Sleep 3s

Get Source是一个 Selenium 库关键字,用于获取整个页面的原始 HTML。Start Selenium Server是另一个用于启动 Selenium 服务器的关键字。还包括内置的Sleep调用,以避免启动/关闭时序问题,如果这个测试在另一个基于 Selenium 的测试套件之前或之后发生。

  1. 添加一个导入 Selenium 库的部分,并为每个测试用例启动和关闭浏览器定义一个设置和拆卸过程:
*Settings*
Library         SeleniumLibrary
Test Setup      Open Browser http://localhost:8000
Test Teardown   Close All Browsers
Suite Setup     Startup
Suite Teardown  Stop Selenium Server

Test Setup是一个内置关键字,用于定义在每个测试用例之前执行的步骤。在这种情况下,它使用 Selenium 库关键字Open Browser来启动指向 Satchmo 应用程序的浏览器。Test Teardown是一个内置关键字,它在每个测试结束时执行,并关闭此测试启动的浏览器。Suite Setup是一个内置关键字,仅在执行任何测试之前运行,Suite Teardown仅在此套件中的所有测试之后运行。在这种情况下,我们用它来启动和停止 Selenium 库。

  1. 通过输入pybot recipe42.txt来运行测试套件:

  1. 打开log.html并观察细节,包括每个场景中捕获的屏幕截图。以下截图只是众多捕获的截图之一。随时可以检查其余的截图以及日志:

它是如何工作的...

Robot Framework 提供了一个强大的环境来通过关键字定义测试。Selenium 插件与 Selenium 进行接口,并提供一整套关键字,专注于操作 Web 应用程序并读取和确认它们的输出。

Web 应用程序测试的一个重要部分是获得一个元素来操作它或测试值。这样做的最常见方式是通过检查元素的关键属性,例如idnamehref。例如,在我们的场景中,有一个按钮,我们需要点击它以将书添加到购物车中。它可以通过 IDaddcart或显示的文本Add to cart来识别。

还有更多...

虽然 Robot Framework 与其他商业前端测试解决方案相比是免费的,但重要的是要意识到编写自动化测试所需的工作量并不是免费且毫不费力的。要使其成为前端设计的一个积极部分需要付出努力。

在屏幕设计的过程中早期引入 Robot 和 Selenium 库等工具将鼓励良好的实践,例如标记框架和元素,以便尽早进行测试。这与在构建后端服务器系统后尝试编写自动化测试没有什么不同。如果它们后来被引入,这两种情况都会更加昂贵。在后端系统的早期引入自动化测试鼓励类似的编码以支持可测试性。

如果我们试图在开发周期的后期接受验收测试,或者尝试测试我们从另一个团队继承的系统,我们需要包括时间来对 Web 界面进行更改,以添加标签和标识符以支持编写测试。

了解定时配置-它们可能很重要!

虽然 Satchmo 购物车应用程序在我们编写的测试中没有任何显着的延迟,但这并不意味着其他应用程序不会有。如果您的 Web 应用程序的某些部分明显较慢,那么阅读有关配置 Selenium 等待应用程序响应多长时间的在线文档是很有价值的。

另请参阅

  • 安装 Robot Framework

  • 使用 Robot Framework 创建数据驱动的测试套件

  • 使用 Robot Framework 编写可测试的故事

使用 Robot Framework 来验证 Web 应用程序安全性

Web 应用程序通常具有某种安全性。这通常以登录页面的形式存在。一个写得很好的测试用例应该在开始时启动一个新的浏览器会话,并在结束时关闭它。这导致用户在每个测试用例中重复登录。

在这个食谱中,我们将探讨编写代码以登录到 Django 提供的 Satchmo 的管理页面。然后,我们将展示如何将整个登录过程捕获到一个单一关键字中,从而使我们能够顺利地编写一个访问产品目录的测试,而不会被登录所拖累。

准备工作

  1. 我们首先需要激活我们的virtualenv设置。

  2. 对于这个食谱,我们使用 Satchmo 购物车 Web 应用程序。要启动它,请切换到 store 目录并输入python manage.py runserver。您可以通过访问http://localhost:8000来探索它。

  3. 接下来,按照安装 Robot Framework食谱中所示,安装 Robot Framework 和第三方 Selenium 插件。

如何做...

以下步骤将突出显示如何捕获登录步骤,然后将其封装在一个自定义关键字中:

  1. 创建一个名为recipe43.txt的新文件,并为练习 Django 的管理界面编写一个测试故事:
As a system administrator
I want to login to Django's admin page
So that I can check the product catalog.
  1. 为测试用例添加一个部分,并编写一个测试用例,测试登录页面:
*Test Cases*
Logging in to the admin page
  Open Browser http://localhost:8000/admin
  Input text username gturnquist
  Input text password password
  Submit form
  Page Should Contain Link Products
  Close All Browsers
  1. 添加另一个测试用例,检查产品目录并验证表中的特定行:
Check product catalog
  Given that I am logged in
  Click link Products
  Capture Page Screenshot recipe43-scenario2-1.png
  Table Should Contain result_list Robots Attack!
  Table Row Should Contain result_list 4 Robots Attack!
  Table Row Should Contain result_list 4 7.99
  Close All Browsers
  1. 创建一个捕获登录过程的关键字部分,作为一个单一的关键字:
*Keywords*
Given that I am logged in
  Open Browser http://localhost:8000/admin/
  Input text username gturnquist
  Input text password password
  Submit form

Startup
  Start Selenium Server
  Sleep 3s

对于您自己的测试,请输入您在安装 Satchmo 时使用的用户名和密码。Start Selenium Server关键字是另一个启动 Selenium 服务器的关键字。内置的 Sleep 调用用于避免启动/关闭时间问题,如果这个测试在另一个基于 Selenium 的测试套件之前或之后发生。

  1. 最后,添加一个设置部分,导入 Selenium 库,并在测试套件的开头和结尾启动和停止 Selenium 服务器:
*Settings*
Library SeleniumLibrary
Suite Setup Startup
Suite Teardown Stop Selenium Server
  1. 通过输入pybot recipe43.txt来运行测试套件:

它是如何工作的...

第一个测试案例展示了我们如何输入用户名和密码数据,然后提交表单。SeleniumLibrary 允许我们按名称选择表单,但如果我们没有识别它,它会选择它找到的第一个 HTML 表单。由于登录页面上只有一个表单,这对我们来说很好用。

对于第二个测试案例,我们想要导航到产品目录。由于它在一个干净的浏览器会话中运行,我们被迫再次处理登录界面。这意味着我们需要包括相同的步骤再次登录。为了进行更全面的测试,我们可能会编写很多测试案例。为什么 我们 应该 避免 每个 测试 案例 复制 粘贴 相同的 登录 步骤?因为这违反了不要重复自己DRY)原则。如果登录页面被修改,我们可能需要修改每个实例。

相反,我们使用Given that I am logged in关键字捕获了登录步骤。这为我们提供了许多测试案例的有用子句,并让我们专注于管理页面。

还有更多...

在这个示例中,我们使用了一些 Selenium 库的表测试操作。我们验证了特定书籍在表级和行级都存在。我们还验证了该行中书籍的价格。

最后,我们捕获了产品目录的屏幕截图。这个屏幕截图给了我们一个快速的、视觉的一瞥,我们可以用它来手动确认产品目录,或者用它来规划我们的下一个测试步骤。

为什么不使用“记住我”选项?

许多网站都包括一个“记住我”复选框,以便在客户端 cookie 中保存登录凭据。Django 管理页面没有这个选项,那么这与我们有关吗?这是因为许多网站都有这个选项,我们可能会想要将其纳入我们的测试中,以避免每次都登录。即使我们要测试的 Web 应用程序有这个选项,使用它也不是一个好主意。它会创建一个持久状态,可以从一个测试传播到下一个测试。不同的用户账户可能有不同的角色,影响可见内容。我们可能不知道测试案例的运行顺序,因此必须添加额外的代码来嗅探我们登录的用户是谁。

相反,更容易和更清晰的方法是持久化这些信息。相反,通过单个关键字明确登录提供了更清晰的意图。这并不意味着我们不应该测试和确认特定网页应用的记住复选框。相反,我们实际上应该测试好账户和坏账户,以确保登录界面按预期工作。然而,除此之外,最好不要用当前测试案例的持久化结果混淆未来的测试案例。

我们不应该重构第一个测试场景来使用这个关键字吗?

为了遵守 DRY 原则,我们应该在测试故事中的一个地方进行登录过程。然而,出于演示目的,我们将其编码在顶部,然后将相同的代码复制到一个关键字中。最好的解决方案是将其封装成一个单一的关键字,可以在测试案例中重复使用,也可以用来定义其他自定义关键字,比如Given I am logged in

登录关键字更灵活吗?

绝对地——在这个测试故事中,我们硬编码了用户名和密码。然而,对登录页面进行良好的测试将涉及到一个数据驱动的表格,其中包含大量的良好和不良账户的组合,以及有效和无效的密码。这就需要一种接受用户名和密码作为参数的登录关键字。

另请参阅

  • 安装 Robot Framework

  • 使用 Pyccuracy 验证 Web 应用程序安全性

  • 使用 Robot Framework 创建一个数据驱动的测试套件

创建一个项目级别的脚本来验证本章的验收测试

我们已经使用了pyccuracy_consolepybot来运行各种测试配方。然而,管理一个 Python 项目涉及的不仅仅是运行测试。像打包、在 Python 项目索引中注册以及推送到部署站点等事情都是重要的管理程序。

构建一个命令行脚本来封装所有这些非常方便。通过这个配方,我们将运行一个运行本章中所有测试的脚本。

准备工作

  1. 我们首先需要激活我们的virtualenv设置。

  2. 对于这个配方,我们使用了 Satchmo 购物车 Web 应用程序。要启动它,切换到 store 目录,然后输入python manage.py runserver。您可以通过访问http://localhost:8000来探索它。

  3. 接下来,按照安装RobotFramework配方中所示,安装 Robot Framework 和第三方 Selenium 插件。

  4. 这个配方假设本章中的各种配方都已经编码完成。

如何做...

通过这些步骤,我们将看到如何以编程方式运行本章中的所有测试:

  1. 创建一个名为recipe44.py的新文件,以包含这个配方的代码。

  2. 创建一个命令行脚本,定义几个选项:

import getopt
import logging
import os
import os.path
import re
import sys
from glob import glob

def usage():
    print
    print "Usage: python recipe44.py [command]"
    print
    print "t--help"
    print "t--test"
    print "t--package"
    print "t--publish"
    print "t--register"
    print
try:
    optlist, args = getopt.getopt(sys.argv[1:],
            "h",
            ["help", "test", "package", "publish", "register"])
except getopt.GetoptError:
    # print help information and exit:
    print "Invalid command found in %s" % sys.argv
    usage()
    sys.exit(2)
  1. 添加一个方法来启动 Selenium,运行基于 Pyccuracy 的测试,然后关闭 Selenium:
def test_with_pyccuracy():
    from SeleniumLibrary import start_selenium_server
    from SeleniumLibrary import shut_down_selenium_server
    from time import sleep

    f = open("recipe44_selenium_log.txt", "w")
    start_selenium_server(logfile=f)
    sleep(10)

    import subprocess
    subprocess.call(["pyccuracy_console"])
    shut_down_selenium_server()
    sleep(5)
    f.close()
  1. 添加一个运行 Robot Framework 测试的方法:
def test_with_robot():
    from robot import run
    run(".")
  1. 添加一个方法来运行这两个测试方法:
def test():
    test_with_pyccuracy()
    test_with_robot()
  1. 为其他项目函数添加一些存根方法:
def package():
    print "This is where we can plug in code to run " +
        "setup.py to generate a bundle."
def publish():
    print "This is where we can plug in code to upload " +
        "our tarball to S3 or some other download site."
def register():
    print "setup.py has a built in function to " +
        "'register' a release to PyPI. It's " +
        "convenient to put a hook in here."
    # os.system("%s setup.py register" % sys.executable)
  1. 添加一些解析选项的代码:
if len(optlist) == 0:
    usage()
    sys.exit(1)
# Check for help requests, which cause all other
# options to be ignored.
for option in optlist:
    if option[0] in ("--help", "-h"):
        usage()
        sys.exit(1)

# Parse the arguments, in order
for option in optlist:
    if option[0] in ("--test"):
        test()
    if option[0] in ("--package"):
        package()
    if option[0] in ("--publish"):
        publish()
    if option[0] in ("--register"):
        register()
  1. 通过输入python``recipe44 -test来运行带有测试标志的脚本。在下面的截图中,我们可以看到所有的 Pyccuracy 测试都通过了:

在下一个截图中,我们可以看到 Robot Framework 测试也通过了:

它是如何工作的...

我们使用 Python 的getopt模块来定义命令行选项:

    optlist, args = getopt.getopt(sys.argv[1:], 
            "h", 
           ["help", "test", "package", "publish", "register"]) 

这将映射以下内容:

  • "h": -h

  • "help": --help

  • "test": --test

  • "package": --package

  • "publish": --publish

  • "register": --register

我们扫描接收到的参数列表,并调用相应的函数。对于我们的测试函数,我们使用 Python 的subprocess模块来调用pyccuracy_console。我们也可以用同样的方法调用pybot,但是 Robot Framework 提供了一个方便的 API 来直接调用它:

    from robot import run 
    run(".") 

这让我们可以在我们的代码中使用它。

还有更多

要运行这些测试,我们需要运行 Selenium。我们的 Robot Framework 测试是构建在自己运行 Selenium 的基础上的。Pyccuracy 没有这样的功能,所以它需要另一种方法。在那些配方中,我们使用了java -jar selenium-server.jar。我们可以尝试管理这个,但使用 Selenium 库的 API 来启动和停止 Selenium 更容易。

这就是在纯 Python 中编写代码给我们最多选择的地方。我们能够使用另一个本来不打算与之一起工作的库的部分来增强 Pyccuracy 的功能。

我们只能使用 getopt 吗?

Python 2.7 引入了argparse作为一种替代方法。当前的文档没有表明getopt已经被弃用,所以我们可以像刚才做的那样安全地使用它。getopt模块是一个很好的、易于使用的命令行解析器。

使用各种命令行工具有什么问题?

使用诸如pyccuracy_consolepybotnosetests等工具并没有错,这些工具都是 Python 库中自带的。这个教程的目的是提供一种方便的替代方法,将所有这些工具整合到一个中心脚本中。通过在这个脚本上投入一点时间,我们就不必记住如何使用所有这些功能;相反,我们可以开发我们的脚本来支持项目的开发工作流程。

第六章:将自动化测试与持续集成集成

在这一章中,我们将涵盖:

  • 使用 NoseXUnit 为 Jenkins 生成持续集成报告

  • 配置 Jenkins 在提交时运行 Python 测试

  • 配置 Jenkins 在计划时运行 Python 测试

  • 使用 teamcity-nose 为 TeamCity 生成持续集成报告

  • 配置 TeamCity 在提交时运行 Python 测试

  • 配置 TeamCity 在计划时运行 Python 测试

介绍

众所周知的经典软件开发过程瀑布模型包括以下阶段:

  1. 收集和定义需求

  2. 起草设计以满足需求

  3. 编写实施策略以满足设计

  4. 编码完成

  5. 对编码实施进行测试

  6. 系统与其他系统以及该系统的未来版本集成

在瀑布模型中,这些步骤通常分布在几个月的工作中。这意味着与外部系统集成的最后一步是在几个月后完成的,并且通常需要大量的工作。持续集成(CI)通过引入编写测试来解决瀑布模型的不足,这些测试可以测试这些集成点,并且在代码检入系统时自动运行。采用 CI 的团队通常会立即修复基线,如果测试套件失败。这迫使团队不断地保持他们的代码工作和集成,因此使得这一最终步骤相对成本低廉。采用更敏捷的方法的团队工作周期更短。团队可能会在编码冲刺中工作,这些冲刺的周期可能从每周到每月不等。同样,通过每次检入时运行集成测试套件,基线始终保持功能正常;因此,它随时可以交付。这可以防止系统处于非工作状态,只有在冲刺结束或瀑布周期结束时才能使其工作。这为客户或管理层提供了更多的代码演示机会,可以更主动地获取反馈并更积极地投入开发。这一章更侧重于将自动化测试与 CI 系统集成,而不是编写测试。因此,我们将重复使用以下购物车应用程序。创建一个名为cart.py的新文件,并将以下代码输入其中:

class ShoppingCart(object): 
    def __init__(self): 
        self.items = [] 

    def add(self, item, price): 
        for cart_item in self.items: 
            # Since we found the item, we increment 
            # instead of append 
            if cart_item.item == item: 
                cart_item.q += 1 
                return self 

        # If we didn't find, then we append 
        self.items.append(Item(item, price)) 
        return self 

    def item(self, index): 
        return self.items[index-1].item 

    def price(self, index): 
        return self.items[index-1].price * self.items[index-1].q 

    def total(self, sales_tax): 
        sum_price = sum([item.price*item.q for item in self.items]) 
        return sum_price*(1.0 + sales_tax/100.0) 

    def __len__(self): 
        return sum([item.q for item in self.items]) 

class Item(object): 
    def __init__(self, item, price, q=1): 
        self.item = item 
        self.price = price 
        self.q = q 

为了演示 CI,本章中的各种配方将使用以下简单应用程序的一组简单的单元测试。创建另一个名为tests.py的文件,并将以下测试代码输入其中:

from cart import * 
import unittest 

class ShoppingCartTest(unittest.TestCase): 
    def setUp(self): 
        self.cart = ShoppingCart().add("tuna sandwich", 15.00) 

    def test_length(self): 
        self.assertEquals(1, len(self.cart)) 

    def test_item(self): 
        self.assertEquals("tuna sandwich", self.cart.item(1)) 

    def test_price(self): 
        self.assertEquals(15.00, self.cart.price(1)) 

    def test_total_with_sales_tax(self): 
        self.assertAlmostEquals(16.39,  
                                self.cart.total(9.25), 2) 

这一简单的测试集看起来并不令人印象深刻,是吗?实际上,它并不像我们之前谈到的集成测试,而是基本的单元测试,对吧?完全正确!这一章并不侧重于编写测试代码。那么,如果这本书是关于代码配方,为什么我们要关注工具呢?因为使自动化测试与团队合作比编写测试更重要。了解将自动化测试概念并将其整合到我们的开发周期中的工具是很重要的。CI 产品是一个有价值的工具,我们需要了解如何将它们与我们的测试代码联系起来,从而使整个团队都能参与并使测试成为我们开发过程中的一等公民。这一章探讨了两种强大的 CI 产品:Jenkins 和 TeamCity。

Jenkins (jenkins-ci.org/) 是一个开源产品,最初由 Sun Microsystems 的一名开发人员领导创建,后来在 Sun 被 Oracle 收购后离开。它有一个强大的开发者社区,许多人提供补丁、插件和改进。它最初被称为Hudson,但开发社区投票决定改名以避免法律纠纷。整个 Hudson/Jenkins 命名的历史可以在网上阅读,但与本书中的示例无关。TeamCity (www.jetbrains.com/teamcity/) 是由 JetBrains 创建的产品,该公司还生产商业产品,如 IntelliJ IDE、ReSharper 和 PyCharm IDE。专业版是一个免费版本,在本章中将用于展示另一个 CI 系统。它还有企业版、商业升级版,您可以自行评估。

使用 NoseXUnit 为 Jenkins 生成 CI 报告

JUnit (junit.org) 是自动化测试中的软件行业领导者。它提供了生成 XML 报告文件的能力,这些文件可以被许多工具使用。这也适用于像 Jenkins 这样的持续集成工具。NoseXUnit (nosexunit.sourceforge.net/) 是一个nose插件,以相同的格式生成 Python 测试结果的 XML 报告。它像 JUnit 一样使用 XML 报告,但适用于 PyUnit。即使我们不是在构建 Java 代码,也没有要求我们的 CI 服务器不能是基于 Java 的系统。只要我们能生成正确的报告,这些工具就可以使用。考虑到最受欢迎和得到良好支持的 CI 系统之一是 Jenkins,这种类型的插件非常有用。通过这个示例,我们将探讨如何从简单的 Python 测试生成可用的报告。

准备工作

需要以下步骤来安装本章的所有组件:

  1. 安装nose

  2. 通过输入pip install nosexunit来安装 NoseXUnit (nosexunit.sourceforge.net/)

操作步骤...

以下步骤将展示如何使用 NoseXUnit 插件生成与 Jenkins 兼容格式的 XML 报告:

  1. 使用nosetests和 NoseXUnit 插件来测试购物车应用程序,输入nosetests tests.py --with-nosexunit

  1. 使用 XML 或文本编辑器打开target/NoseXUnit/core/TEST-tests.xml中的报告。以下截图显示了在 Spring Tool Suite (www.springsource.com/developer/sts)中显示的报告,这是 Eclipse 的一个衍生产品(这绝不是一种推荐)。许多现代 IDE 都内置了 XML 支持,其他编辑器如 Emacs、TextPad 等也有:

它是如何工作的...

NoseXUnit 收集每个测试的结果,并生成一个与 JUnit 相同格式的 XML 报告。XML 文件并不是为人类消费设计的,但很容易辨别结果。当我们之前运行nosetests时,有多少个测试用例通过了?测试方法的名称是什么?在这个 XML 文件中,我们可以看到四个测试用例的名称。实际上,如果这个文件在某些工具中打开,比如 STS,它会显示为一个测试结果:

我们不必使用 STS 来完成任何操作。实际上,STS 对于这个简单的任务来说有点过于庞大。您喜欢的 XML 或文本编辑器可以用来检查报告。我只是想演示这个插件的输出如何与现有工具很好地配合。通过输入nosetests help,我们可以看到nose从所有已安装的插件中具有的所有选项。这包括:

  • --core-target=CORE_TARGET: 测试报告的输出文件夹(默认为 target/NoseXUnit/core)

  • --with-nosexunit: 通过插件运行

配置 Jenkins 在提交时运行 Python 测试

Jenkins 可以配置为在提交时调用我们的测试套件。这非常有用,因为我们可以使其跟踪我们的更改。使用 CI 系统的团队通常会立即解决 CI 失败,以保持基线功能。Jenkins 提供了几乎无限的功能,例如从版本控制中检索最新源代码,打包发布,运行测试,甚至分析源代码。这个配方展示了如何配置 Jenkins 来运行我们的测试套件针对我们的购物车应用程序。

准备工作

  1. mirrors.jenkins-ci.org/war/latest/jenkins.war下载 Jenkins:

  1. 通过运行java -jar jenkins.war来启动它。重要的是没有其他应用程序在端口8080上监听:

  1. 打开控制台确认 Jenkins 正在工作:

  1. 点击管理 Jenkins。

  2. 点击管理插件。

  3. 点击可用选项卡。

  4. 找到 Git 插件并点击旁边的复选框。

  5. 在页面底部,点击安装按钮。验证插件是否成功安装。

  6. 返回仪表板屏幕。

  7. 关闭 Jenkins,然后重新启动。

  8. 在您的计算机上安装 Git 源代码控制。您可以访问git-scm.com/找到可下载的软件包。您的系统也可能包括 MacPorts 或 Homebrew(适用于 Mac)、Red Hat Linux 发行版的 yum 和 Debian/Ubuntu 系统的 apt-get 等软件包安装选项。

  9. 为这个配方创建一个空文件夹:

    gturnquist$ mkdir /tmp/recipe46

  1. 初始化用于源代码维护的文件夹:
    gturnquist$ git init /tmp/recipe46
    Initialized empty Git repository in /private/tmp/recipe46/.git/

  1. 将购物车应用程序复制到文件夹中,添加并提交更改:
    gturnquist$ cp cart.py /tmp/recipe46/
    gturnquist$ cd /tmp/recipe46/
    gturnquist$ git add cart.py
    gturnquist$ git commit -m "Added shopping cart application to setup this recipe."
    [master (root-commit) 057d936] Added shopping cart application to setup this recipe.
     1 files changed, 35 insertions(+), 0 deletions(-)
     create mode 100644 cart.py

如何做...

以下步骤将展示如何将我们的代码置于控制之下,然后在进行任何更改并提交时运行测试套件:

  1. 打开 Jenkins 控制台。

  2. 点击新作业。

  3. recipe46输入作业名称并选择构建自由样式软件项目。

  4. 点击 a。

  5. 在源代码管理部分,选择 Git。对于 URL,输入/tmp/recipe46/

  6. 在构建触发器部分,选择轮询 SCM 并在计划框中输入* * * * *,以便每分钟触发一次轮询。

  7. 在构建部分,选择执行 shell 并输入以下临时脚本,加载虚拟环境并运行测试套件:

. /Users/gturnquist/ptc/bin/activate 
nosetests tests.py -with-nosexunit 

您需要替换激活您自己的虚拟环境的命令,无论是在 Windows、Linux 还是 macOS 上,然后跟随我们在本章早些时候所做的运行测试的命令。

  1. 在后构建操作部分,选择发布 JUnit 测试结果报告并输入target/NoseXUnit/core/*.xml,以便 Jenkins 收集测试结果。

  2. 点击保存以存储所有作业设置。

  3. 点击启用自动刷新。我们应该期望第一次运行失败,因为我们还没有添加任何测试:

  1. 将测试套件复制到受控源文件夹中,添加并提交:
    gturnquist$ cp tests.py /tmp/recipe46/
    gturnquist$ cd /tmp/recipe46/
    gturnquist$ git add tests.py
    gturnquist$ git commit -m "Added tests for the recipe."
    [master 0f6ef56] Added tests for the recipe.
     1 files changed, 20 insertions(+), 0 deletions(-)
     create mode 100644 tests.py

  1. 观察以验证 Jenkins 是否启动了成功的测试运行:

  1. 转到测试结果页面,我们可以看到其中有四个测试被运行。

它是如何工作的...

Jenkins 提供了一种强大灵活的配置 CI 作业的方式。在这个配方中,我们配置它每分钟轮询我们的软件确认管理系统。当它检测到变化时,它会拉取软件的新副本并运行我们的测试脚本。通过使用 NoseXUnit 插件,我们生成了一个易于在 Jenkins 中收集的工件。通过少数步骤,我们能够配置一个监视我们源代码的网页。

还有更多...

Jenkins 有很多选项。如果您检查 Web 界面,您可以深入到输出日志中查看实际发生了什么。它还收集趋势,显示我们成功运行了多长时间,上次构建失败了多长时间等。

我必须使用 git 进行源代码管理吗?

答案是否定的。我们在这个配方中使用它,只是为了快速展示如何从 Web 界面内安装 Jenkins 插件。要应用插件,我们必须重新启动 Jenkins。Subversion 和 CVS 是开箱即用的。Jenkins 还有支持每个主要源代码控制系统的插件,因此满足您的需求应该很容易。事实上,还支持 GitHub 和 BitKeeper 等社交编码网站的插件。我们可以配置 Jenkins 安装以监视某个 GitHub 帐户的更新,而不是使用 Git 插件。

轮询的格式是什么?

我们使用了* * * * *配置了轮询,这意味着每分钟运行一次。这是基于配置 crontab 文件所使用的格式。从左到右的列是:

  • MINUTE: 小时内的分钟(0-59)

  • HOUR: 一天中的小时(0-23)

  • DOM: 月份的第几天(1-31)

  • MONTH: 月份(1-12)

  • DOW: 一周的第几天(0-7),其中 0 和 7 代表星期日

另请参阅

使用 NoseXUnit 为 Jenkins 生成 CI 报告

配置 Jenkins 在预定时间运行 Python 测试

我们刚刚探讨了如何配置 Jenkins 在提交代码更改时运行我们的测试套件。Jenkins 也可以配置为在预定的时间间隔内调用我们的测试套件。这非常有用,因为我们可以将其调整为进行定期发布。每天或每周发布可以为潜在客户提供一个不错的发布节奏。CI 发布通常被理解为不一定是最终版本,而是提供最新支持,以便客户可以尽早调查和集成新功能。

准备工作

以下步骤用于设置 Jenkins 以及我们测试的副本,以便我们可以在预定的时间间隔内轮询它:

  1. 按照之前的配方配置 Jenkins 在提交时运行 Python 测试来设置 Jenkins。这应该包括已设置 Git 插件。

  2. 为这个配方创建一个空文件夹:

    gturnquist$ mkdir /tmp/recipe47

  1. 初始化源代码维护的文件夹:
    gturnquist$ git init /tmp/recipe47
    Initialized empty Git repository in /private/tmp/recipe47/.git/

  1. 将购物车应用程序复制到文件夹中,添加并提交更改:
    gturnquist$ cp cart.py /tmp/recipe47/
    gturnquist$ cd /tmp/recipe47/
    gturnquist$ git add cart.py
    gturnquist$ git commit -m "Added shopping cart application to setup this recipe."
    [master (root-commit) 057d936] Added shopping cart application to setup this recipe.
     1 files changed, 35 insertions(+), 0 deletions(-)
     create mode 100644 cart.py

如何做...

以下步骤将让我们探索创建一个 Jenkins 作业,定期运行我们的自动化测试套件:

  1. 打开 Jenkins 控制台。

  2. 点击“新建作业”。

  3. recipe47作为作业名称,并选择“构建自由风格的软件项目”。

  4. 点击“确定”。

  5. 在“源代码管理”部分,选择 Git。对于 URL,输入/tmp/recipe47/

  6. 在“构建触发器”部分,选择“定期构建”并输入未来的某个时间。在为本书编写这个配方时,作业是在下午 6:10 左右创建的,因此在计划框中输入15 18 * * *会在下午 6:15 将作业安排到未来的五分钟内。

  7. 在“构建”部分,选择“执行 shell”并输入以下临时脚本,加载虚拟环境并运行测试套件:

. /Users/gturnquist/ptc/bin/activatenosetests tests.py -with-nosexunit

您需要用激活虚拟环境的命令替换这个,然后是运行测试的步骤。

  1. 在“后构建操作”部分,选择“发布 JUnit 测试结果报告”并输入target/NoseXUnit/core/*.xml,以便 Jenkins 收集测试结果。

  2. 点击“保存”以存储所有作业设置。

  3. 点击“启用自动刷新”。

  4. 将测试套件复制到受控源文件夹中,添加并提交:

    gturnquist$ cp tests.py /tmp/recipe47/
    gturnquist$ cd /tmp/recipe47/
    gturnquist$ git add tests.py
    gturnquist$ git commit -m "Added tests for the recipe."
    [master 0f6ef56] Added tests for the recipe.
     1 files changed, 20 insertions(+), 0 deletions(-)
     create mode 100644 tests.py

  1. 观察以验证 Jenkins 是否启动了成功的测试运行:

  1. 导航到测试结果,我们可以看到我们的四个测试都已运行。

工作原理...

这与上一个配方非常相似,只是这次我们配置了一个轮询间隔来运行我们的测试套件,而不是轮询版本控制系统。这很有用,因为我们可以每天运行一次构建,以确保事情稳定并正常工作。

还有更多...

Jenkins 有很多选项。如果您查看 Web 界面,可以深入到输出日志中,看看实际发生了什么。它还收集趋势,显示我们成功运行了多长时间,上次构建失败是什么时候,等等。老实说,Jenkins 有很多插件和选项,可以专门探索其功能。本章的后半部分只是一个简单介绍,介绍了一些以测试为导向的常见作业。

Jenkins 与 TeamCity

到目前为止,我们已经探索了使用 Jenkins。在本章的后面,我们将访问 TeamCity。它们有什么区别?为什么我们应该选择其中一个?在功能上,它们都提供了强大的选择。这就是为什么它们都包含在这本书中的原因。它们都提供的关键功能是设置运行测试的作业,以及其他一些功能,比如打包。一个关键区别是 Jenkins 是一个开源产品,而 TeamCity 是商业产品。您或您的公司可能更喜欢与产品相关联的付费公司(www.jetbrains.com/),这就是 TeamCity 提供的。这并不能让决定变得非常清晰,因为 Jenkins 的主要开发人员目前为 CloudBees 工作(www.cloudbees.com/),他们也在 Jenkins 以及周围产品上投入了努力。如果商业支持并不迫切,您可能会发现 Jenkins 的开发速度更快,插件数量更多样化。最重要的是,选择满足您 CI 需求的产品需要进行详细的分析,这里无法简单回答。

另请参阅

使用 NoseXUnit 为 Jenkins 生成 CI 报告

使用 teamcity-nose 为 TeamCity 生成 CI 报告

有一个nose插件,可以自动检测在 TeamCity 内运行测试时。这方便地捕获测试结果并将其与 TeamCity 通信。通过这个配方,我们将探索如何在 TeamCity 内设置一个 CI 作业,运行我们的测试,然后手动调用该作业。

做好准备

需要以下步骤来准备运行 TeamCity CI 作业:

  1. 安装nosetests

  2. 通过输入pip install teamcity-nose来安装teamcity-nose

  3. 使用 Wget 下载 TeamCity(download.jetbrains.com/teamcity/TeamCity-6.0.tar.gz)。

  4. 解压下载文件。

  5. 切换到TeamCity/bin目录。

  6. 启动它:./runAll.sh start

  7. 打开浏览器,输入http://localhost:8111

  8. 如果这是您第一次启动 TeamCity,请接受许可协议。

  9. 通过选择用户名和密码创建管理员帐户。

  10. 在您的计算机上安装 Git 源代码控制。

  11. 为这个配方创建一个空文件夹:

    gturnquist$ mkdir /tmp/recipe48

  1. 初始化源代码维护的文件夹:
    gturnquist$ git init /tmp/recipe48
    Initialized empty Git repository in /private/tmp/recipe48/.git/

  1. 将购物车应用程序和测试复制到文件夹中,添加并提交更改:
    gturnquist$ cp cart.py /tmp/recipe48/
    gturnquist$ cp tests.py /tmp/recipe48/
    gturnquist$ cd /tmp/recipe48/
    gturnquist$ git add cart.py tests.py
    gturnquist$ git commit -m "Added shopping cart and tests to setup this recipe."
    [master (root-commit) ccc7155] Added shopping cart and tests to setup this recipe.
     2 files changed, 55 insertions(+), 0 deletions(-)
     create mode 100644 cart.py
     create mode 100644 tests.py

如何做...

以下步骤显示了如何在 TeamCity 中配置 CI 作业:

  1. 登录 TeamCity 控制台。

  2. 在项目选项卡下,点击创建项目。

  3. 输入recipe48,然后点击创建。

  4. 为此项目添加一个构建配置。

  5. 输入nose testing作为名称,然后点击 VCS 设置。

  6. 点击创建并附加新的 VCS 根。

  7. 在 VCS 根名称中输入recipe48

  8. 选择 Git 作为 VCS 的类型。

  9. /tmp/recipe48输入为获取 URL。

  10. 点击测试连接以确认设置,然后点击保存。

  11. 点击添加构建步骤。

  12. 选择命令行作为运行器类型。

  13. 选择自定义脚本作为运行类型,并输入以下脚本:

. /Users/gturnquist/ptc/bin/activatenosetests tests.py

您需要使用激活您的虚拟环境所需的命令来自定义此设置。

  1. 点击保存。

  2. 返回到项目,手动运行它:

它是如何工作的...

这个插件设计成不是以经典方式通过命令行参数调用。相反,它在每次执行nosetests时自动运行,并检查是否设置了 TeamCity 特定的环境变量。如果是,它会通过打印可查看的结果以及发送有用信息回 TeamCity 来启动:

否则,插件会被绕过并且什么也不做。如果插件没有安装,以下截图将是输出:

依次深入细节,显示以下输出,但细节很少。有四个周期,每个测试方法一个,但我们不知道更多:

这意味着不需要额外的参数来使用 TeamCity 插件,但是从命令行外部运行它,会导致没有变化。

配置 TeamCity 在提交时运行 Python 测试

TeamCity 可以配置为在提交时调用您的测试套件。

准备就绪

以下步骤将帮助我们准备 TeamCity 在代码更改提交时运行我们的测试套件:

  1. 像上一个配方一样设置 TeamCity,并启动它。如前面的章节中提到的,您还需要安装git

  2. 为这个配方创建一个空文件夹:

    gturnquist$ mkdir /tmp/recipe49

  1. 初始化源代码维护的文件夹:
    gturnquist$ git init /tmp/recipe49
    Initialized empty Git repository in /private/tmp/recipe49/.git/

  1. 将购物车应用程序复制到文件夹中,添加并提交更改:
    gturnquist$ cp cart.py /tmp/recipe49/
    gturnquist$ cd /tmp/recipe49/
    gturnquist$ git add cart.py
    gturnquist$ git commit -m "Added shopping cart application to setup this recipe."
    [master (root-commit) 057d936] Added shopping cart application to setup this recipe.
     1 files changed, 35 insertions(+), 0 deletions(-)
     create mode 100644 cart.py

如何做...

这些步骤将向我们展示如何创建一个 TeamCity 作业,该作业轮询版本控制以检测更改,然后运行测试套件:

  1. 登录到 TeamCity 控制台。

  2. 在项目选项卡下,点击创建项目。

  3. 输入recipe49,然后点击创建。

  4. 点击为此项目添加构建配置。

  5. 输入nose testing作为名称,然后点击 VCS 设置。

  6. 点击创建并附加新的 VCS 根。

  7. 在 VCS 根名称中输入recipe49

  8. 选择 Git 作为 VCS 的类型。

  9. **/**tmp/recipe49输入为获取 URL。

  10. 点击测试连接以确认设置,然后点击保存。

  11. 点击添加构建步骤。

  12. 选择命令行作为运行器类型。

  13. 选择自定义脚本作为运行类型,并输入以下脚本:

. /Users/gturnquist/ptc/bin/activatenosetests tests.py

您必须用激活您自己的虚拟环境并调用nosetests的命令替换它。

  1. 点击保存。

  2. 点击构建触发。

  3. 点击添加新的触发器。

  4. 从触发类型中选择 VCS 触发器。

  5. 在顶部,应该显示 VCS 触发器将在检测到 VCS 签入时将构建添加到队列中。点击保存。

  6. 导航回到项目。不应该有任何已安排的工作或显示的结果。

  7. 点击运行。它应该失败,因为我们还没有将测试添加到存储库中:

  1. 从命令行中,将测试文件复制到存储库中。然后添加并提交:
    gturnquist$ cp tests.py /tmp/recipe49/
    gturnquist$ cd /tmp/recipe49/
    gturnquist$ git add tests.py
    gturnquist$ git commit -m "Adding tests."
    [master 4c3c418] Adding tests.
     1 files changed, 20 insertions(+), 0 deletions(-)
     create mode 100644 tests.py

  1. 返回浏览器。TeamCity 可能需要一分钟来检测代码的更改并启动另一个构建作业。它应该自动更新屏幕:

它是如何工作的...

在这个配方中,我们配置了 TeamCity 来执行一个与特定触发器相关的工作。当软件基线进行检入时,触发器会被激活。我们必须采取几个步骤来配置这个,但这展示了 TeamCity 提供的灵活功能。我们还安装了teamcity-nose插件,它为我们提供了更多关于结果的细节。

还有更多...

TeamCity 将我们的nose testing作业称为构建作业。这是因为运行测试并不是 TeamCity 唯一用途。相反,它旨在构建软件包,部署到站点,以及我们可能希望它在提交时执行的任何其他操作。这就是为什么 CI 服务器有时被称为构建服务器。但是,如果我们从测试基线开始,我们就可以发现 TeamCity 提供的其他有用功能。

teamcity-nose 给了我们什么?

这是一个nose插件,为我们提供了更详细的输出。在这个配方中我们没有详细介绍。

另请参阅

  • 使用teamcity-nose为 TeamCity 生成 CI 报告

  • 配置 Jenkins 在提交时运行 Python 测试

配置 TeamCity 在计划时运行 Python 测试

TeamCity 可以配置为在计划的时间间隔内调用我们的测试套件并收集结果。

准备工作

这些步骤将通过启动 TeamCity 并准备一些代码进行测试来为我们准备这个食谱:

  1. 像本章前面所做的那样设置 TeamCity,并让其运行起来。

  2. 为此食谱创建一个空文件夹:

    gturnquist$ mkdir /tmp/recipe50
  1. 初始化源代码维护的文件夹:
    gturnquist$ git init /tmp/recipe50
    Initialized empty Git repository in /private/tmp/recipe50/.git/
  1. 将购物车应用程序复制到文件夹中,添加并提交更改:
    gturnquist$ cp cart.py /tmp/recipe50/
    gturnquist$ cp tests.py /tmp/recipe50/
    gturnquist$ cd /tmp/recipe50/
    gturnquist$ git add cart.py tests.py
    gturnquist$ git commit -m "Adding shopping cart and tests for this recipe."
    [master (root-commit) 01cd72a] Adding shopping cart and tests for this recipe.
     2 files changed, 55 insertions(+), 0 deletions(-)
     create mode 100644 cart.py
     create mode 100644 tests.py  

如何做...

这些步骤显示了配置 TeamCity 定期运行我们的测试套件的详细信息:

  1. 登录 TeamCity 控制台。

  2. 在“项目”选项卡下,单击“创建项目”。

  3. 输入recipe50,然后单击“创建”。

  4. 为此项目添加构建配置。

  5. 输入nose testing作为名称,然后单击 VCS 设置。

  6. 单击“创建”并附加新的 VCS 根。

  7. 在 VCS 根名称中输入recipe50

  8. 选择 Git 作为 VCS 的类型。

  9. /tmp/recipe50输入为获取 URL。

  10. 单击“测试连接”以确认设置,然后单击“保存”。

  11. 单击“添加构建步骤”。

  12. 选择命令行作为运行器类型。

  13. 选择自定义脚本作为运行类型,并输入以下脚本:

. /Users/gturnquist/ptc/bin/activatenosetests tests.py

用您自己的步骤替换此步骤,以激活您的虚拟环境,然后使用nosetests运行测试。

  1. 单击“保存”。

  2. 单击“构建触发”。

  3. 单击“添加新触发器”。

  4. 从触发类型中选择计划触发器。

  5. 选择每日频率,并选择大约未来五分钟的时间。

  6. 取消选择仅在有待处理更改时触发构建的选项。

  7. 单击“保存”。

  8. 返回到项目。不应该有计划的工作或显示的结果。

  9. 等待计划时间到来。以下屏幕截图显示了工作何时被激活:

以下屏幕截图显示了我们测试的结果总结为已通过:

它是如何工作的...

这看起来难道不是与上一个食谱非常相似吗?当然!我们稍微变化了一下,通过创建基于时间的触发器而不是基于源的触发器。我们选择的时间触发器是每天在固定时间进行计划构建。重点是展示一个常用的触发规则。通过看到相同之处和不同之处,我们可以开始看到如何调整 TeamCity 以满足我们的需求。TeamCity 还有其他非常有用的触发器,比如当另一个作业完成时触发一个作业。这让我们可以构建许多小型、简单的作业,并将它们链接在一起。我们还安装了teamcity-nose插件,这让我们在结果中获得了更多细节。

另请参阅

  • 使用teamcity-nose为 TeamCity 生成 CI 报告

  • 配置 Jenkins 在计划时运行 Python 测试

第七章:通过测试覆盖率来衡量您的成功

在本章中,我们将涵盖:

  • 构建一个网络管理应用程序

  • 在测试套件上安装和运行覆盖率

  • 使用覆盖率生成 HTML 报告

  • 使用覆盖率生成 XML 报告

  • 使用覆盖率工具进行测试

  • 从覆盖率中过滤出测试噪音

  • 让 Jenkins 使用覆盖率

  • 更新项目级别的脚本以提供覆盖率报告

介绍

覆盖率 分析是衡量程序中哪些行被执行,哪些行没有被执行。这种分析也被称为代码覆盖率,或更简单的覆盖率

在生产系统中运行时可以使用覆盖率分析器,但这样做有什么利弊呢?在运行测试套件时使用覆盖率分析器又有什么好处呢?与在生产环境中检查系统相比,这种方法会提供什么好处?

覆盖率帮助我们查看我们是否充分测试了我们的系统。但必须带着一定的怀疑心态进行。这是因为,即使我们实现了 100%的覆盖率,也就是说我们的系统的每一行都被执行了,这绝不意味着我们没有错误。测试只能揭示错误的存在。一个快速的例子涉及到我们编写的代码,以及它处理的是系统调用的返回值。如果有三种可能的值,但我们只处理了其中的两种,我们可能编写了两个测试用例来覆盖我们对它的处理,这当然可以实现 100%的语句覆盖。然而,这并不意味着我们已经处理了第三种可能的返回值,因此可能留下了一个潜在的未发现的错误。100%的代码覆盖率也可以通过条件覆盖率实现,但可能无法通过语句覆盖率实现。我们计划针对的覆盖类型应该是清楚的。

另一个关键点是,并非所有测试都旨在修复错误。另一个关键目的是确保应用程序满足我们客户的需求。这意味着,即使我们有 100%的代码覆盖率,也不能保证我们覆盖了用户所期望的所有场景。这就是正确构建构建正确的东西之间的区别。

在本章中,我们将探讨各种示例,以构建网络管理应用程序,运行覆盖率工具,并收集结果。我们将讨论覆盖率如何引入噪音并向我们展示比我们需要了解的更多内容,并且在实现我们的代码时会引入性能问题。我们还将看到如何剔除我们不需要的信息,以获得简洁、有针对性的视图。

本章在许多示例中使用了几个第三方工具:

  • Spring Python (springpython.webfactional.com) 包含许多有用的抽象。本章中使用的是其DatabaseTemplate,它提供了一种简单的方法来编写 SQL 查询和更新,而无需处理 Python 冗长的 API。通过输入pip install springpython来安装它。

  • 通过输入pip install coverage来安装覆盖率工具。这可能会失败,因为其他插件可能会安装较旧版本的覆盖率。如果是这样,请通过输入pip uninstall coverage来卸载覆盖率,然后再次使用pip install coverage来安装。

  • Nose,一个有用的测试运行器,涵盖在第二章中,使用 Nose 运行自动化测试套件。请参考该章节了解如何安装 nose。

构建一个网络管理应用程序

对于本章,我们将构建一个非常简单的网络管理应用程序,编写不同类型的测试,并检查它们的覆盖率。这个网络管理应用程序专注于处理警报,也称为网络 事件。这与某些其他专注于从设备中收集 SNMP 警报的网络管理工具不同。

出于简单起见,这个相关引擎不包含复杂的规则,而是包含网络事件与设备和客户服务库存的简单映射。在接下来的几段中,我们将通过代码来探讨这一点。

如何做...

通过以下步骤,我们将构建一个简单的网络管理应用程序:

  1. 创建一个名为network.py的文件,用于存储网络应用程序。

  2. 创建一个类定义来表示网络事件:

class Event(object):
   def init  (self, hostname, condition, severity, event_time):
       self.hostname = hostname
       self.condition = condition
       self.severity = severity
       self.id = -1
   def str(self):
       return "(ID:%s) %s:%s - %s" % (self.id, self.hostname,\ 
               self.condition,self.severity)

让我们来看看self的一些属性:

  • hostname:假设所有网络警报都来自具有主机名的设备。

  • condition:这表示正在生成的警报类型。来自同一设备的两种不同的警报条件。

  • severity1表示清晰的绿色状态,5表示故障的红色状态。

  • id:这是事件存储在数据库中时使用的主键值。

  1. 创建一个名为network.sql的新文件,包含 SQL 代码。

  2. 创建一个 SQL 脚本,用于设置数据库并添加存储网络事件定义:

   CREATE TABLE EVENTS(
       ID INTEGER PRIMARY KEY,
       HOST_NAME TEXT,
       SEVERITY INTEGER,
       EVENT_CONDITION TEXT
       );  
  1. 编写一个高级算法,评估事件对设备和客户服务的影响,并将其添加到network.py中:
from springpython.database.core import*

class EventCorrelator(object):
   def init(self, factory):
      self.dt = DatabaseTemplate(factory)
   def del(self):
      del(self.dt)
   def process(self, event):
     stored_event, is_active = self.store_event(event)
     affected_services, affected_equip = self.impact(event)
     updated_services = [
          self.update_service(service, event) 
          for service in affected_services]
     updated_equipment = [
          self.update_equipment(equip, event)
          for equip in affected_equip]
     return (stored_event, is_active,
         updated_services,updated_equipment)

init方法包含一些用于创建DatabaseTemplate的设置代码。这是 Spring Python 的一个用于数据库操作的实用类。有关更多详细信息,请参阅docs.spring.io/spring-python/1.2.x/sphinx/html/dao.html。我们还使用SQLite3作为我们的数据库引擎,因为它是 Python 的标准部分。

process 方法包含一些简单的步骤来处理传入的事件:

    • 我们首先需要将事件存储在EVENTS表中。这包括评估它是否是一个活动事件,这意味着它正在积极影响一件设备。
  • 然后我们确定事件影响的设备和服务。

  • 接下来,我们通过确定它是否导致任何服务中断或恢复来更新受影响的服务。

  • 然后我们通过确定它是否失败或清除设备来更新受影响的设备。

  • 最后,我们返回一个包含所有受影响资产的元组,以支持可能在其上开发的任何屏幕界面。

  1. 实现store_event算法:
def store_event(self,event):
   try:
     max_id = self.dt.query_for_init("""select max(ID) 
        from EVENTS""")
   except DataAccessException, e:
     max_id=0
   event.id = max_id+1
   self.dt.update("""insert into EVENTS
                     (ID, HOST_NAME, SEVERITY, EVENT_CONDITION)
                        values(?,?,?,?)""",
                     (event.id, event.hostname, event.severity,
                        event.condition))
   is active = self.add_or_remove_from_active_events(event)

这种方法存储了每个被处理的事件。这支持许多事情,包括数据挖掘和故障的事后分析。这也是其他与事件相关的数据可以使用外键指向的权威位置:

    • store_event方法从EVENTS表中查找最大的主键值。
  • 将其增加1

  • 将其分配给event.id

  • 然后将其插入EVENTS表中。

  • 接下来,它调用一个方法来评估事件是否应该添加到活动事件列表,或者是否清除现有的活动事件。活动事件是指正在积极导致设备不清晰的事件。

  • 最后,它返回一个包含事件以及是否被分类为活动事件的元组。

对于更复杂的系统,需要实现某种分区解决方案。针对包含数百万行的表进行查询非常低效。但是,这仅用于演示目的,因此我们将跳过扩展以及性能和安全性。

  1. 实现一个方法来评估是否添加或删除活动事件:
def add_or_remove_from_active_events(self,event):
    """Active events are current ones that cause equipment
       and\or services to be down."""
    if event.severity == 1:
       self.dt.update ("""DELETE FROM ACTIVE_EVENTS
                          WHERE EVENT_FK in (
                          SELECT ID FROM EVENTS
                          WHERE HOST_NAME=?
                          AND EVENT_CONDITION=?)""",
                       (event.hostname,event.condition))
      return False
    else:
      self.dt.execute ("""INSERT INTO ACTIVE_EVENTS (EVENT_FK) values (?) """, event.id,))
      return True

当设备故障时,它会发送一个severity 5事件。这是一个活动事件,在这种方法中,将一行插入ACTIVE_EVENTS表中,并使用外键指向EVENTS表。然后我们返回True,表示这是一个活动事件。

  1. ACTIVE_EVENTS的表定义添加到 SQL 脚本中:
CREATE TABLE ACTIVE_EVENTS(ID INTEGER PRIMARY KEY, EVENT_FK,
    FOREIGN KEY(EVENT_FK) REFERENCES EVENTS(ID)
    );

这个表使得查询当前导致设备故障的事件变得容易。

稍后,当设备上的故障条件消除时,它会发送一个severity 1事件。这意味着severity 1事件从不是活动的,因为它们不会导致设备停机。在我们之前的方法中,我们搜索具有相同主机名和条件的任何活动事件,并将其删除。然后我们返回False,表示这不是一个活动事件。

  1. 编写一个评估受网络事件影响的服务和设备的方法:
def impact(self, event):
   """Look up this event has impact on either equipment 
      or services."""
   affected_equipment = self.dt.query(\
               """select * from EQUIPMENT 
                  where HOST_NAME = ?""",
               (event.hostname,), 
               rowhandler=DictionaryRowMapper())
   affected_services = self.dt.query(\
               """select SERVICE.* from   SERVICE
                  join SERVICE_MAPPING SM
                  on (SERVICE.ID = SM.SERVICE_FK)
                  join EQUIPMENT
                  on (SM.EQUIPMENT_FK = EQUIPMENT.ID) where
                  EQUIPMENT.HOST_NAME = ?""",
                  (event.hostname,),                   
                  rowhandler=DictionaryRowMapper())
   return (affected_services, affected_equipment)
    • 我们首先查询EQUIPMENT表,看看event.hostname是否匹配任何内容。
  • 接下来,我们通过SERVICE_MAPPING表跟踪的多对多关系将SERVICE表与EQUIPMENT表连接起来。捕获与报告事件相关的设备相关的任何服务。

  • 最后,我们返回一个包含可能受到影响的设备列表和服务列表的元组。

Spring Python 提供了一个方便的查询操作,该操作返回映射到查询每一行的对象列表。它还提供了一个开箱即用的DictionaryRowMapper,将每一行转换为 Python 字典,其中键与列名匹配。

  1. EQUIPMENTSERVICESERVICE_MAPPING的表定义添加到 SQL 脚本中:
CREATE TABLE EQUIPMENT(
      ID INTEGER PRIMARY KEY, 
      HOST_NAME TEXT UNIQUE,
      STATUS INTEGER );
CREATE TABLE SERVICE (
      ID INTEGER PRIMARY KEY, 
      NAME TEXT UNIQUE, 
      STATUS TEXT );
CREATE TABLE SERVICE_MAPPING (
      ID INTEGER PRIMARY KEY, 
      SERVICE_FK,EQUIPMENT_FK,
      FOREIGN KEY(SERVICE_FK) REFERENCES SERVICE(ID),
      FOREIGN KEY(EQUIPMENT_FK) REFERENCES EQUIPMENT(ID));
  1. 编写update_service方法,该方法存储或清除与服务相关的事件,然后根据剩余的活动事件更新服务的状态:
def update_service(self, service, event):
    if event.severity == 1:
        self.dt.update("""delete from SERVICE_EVENTS
                          where EVENT_FK in (select ID from EVENTS
                          where HOST_NAME = ?
                          and EVENT_CONDITION = ?)""",
                          (event.hostname,event.condition))
    else:
      self.dt.execute("""insert into SERVICE_EVENTS 
                         (EVENT_FK, SERVICE_FK) values (?,?)""",
                         (event.id,service["ID"]))
    try:
      max = self.dt.query_for_int(\
                      """select max(EVENTS.SEVERITY)   
                         from SERVICE_EVENTS SE join EVENTS
                         on (EVENTS.ID = SE.EVENT_FK) join SERVICE
                         on (SERVICE.ID = SE.SERVICE_FK)
                         where SERVICE.NAME = ?""", 
                         (service["NAME"],))
    except DataAccessException, e:
           max=1
    if max > 1 and service["STATUS"] == "Operational":
       service["STATUS"] = "Outage"
       self.dt.update("""update SERVICE
                         set STATUS = ? 
                         where ID = ?""",
                     (service["STATUS"], service["ID"]))

    if max == 1 and service["STATUS"] == "Outage":
       service["STATUS"] = "Operational"
       self.dt.update("""update SERVICE set STATUS = ?
                         where ID = ?""",
                     (service["STATUS"], service["ID"]))
    if event.severity == 1:
       return {"service":service, "is_active":False}
    else:
       return {"service":service, "is_active":True}

与服务相关的事件是与服务相关的活动事件。单个事件可以与多个服务相关联。例如,如果我们监视提供互联网服务给许多用户的无线路由器,并且它报告了严重错误,那么这一个事件将被映射为对所有最终用户的影响。当处理新的活动事件时,它将存储在SERVICE_EVENTS中,以供每个相关服务使用。

然后,当清除事件被处理时,必须从SERVICE_EVENTS表中删除先前的服务事件。

  1. SERVICE_EVENTS的表定义添加到 SQL 脚本中:
CREATE TABLE SERVICE_EVENTS ( 
    ID INTEGER PRIMARY KEY, 
    SERVICE_FK,
    EVENT_FK,FOREIGN KEY(SERVICE_FK) REFERENCES SERVICE(ID),
    FOREIGN KEY(EVENT_FK) REFERENCES EVENTS(ID));

重要的是要认识到,从SERVICE_EVENTS中删除条目并不意味着我们从EVENTS表中删除原始事件。相反,我们只是表明原始活动事件不再是活动的,它不会影响相关服务。

  1. 在整个 SQL 脚本之前加上DROP语句,使其可以运行多个配方:
DROP TABLE IF EXISTS SERVICE_MAPPING;
DROP TABLE IF EXISTS SERVICE_EVENTS;
DROP TABLE IF EXISTS ACTIVE_EVENTS;
DROP TABLE IF EXISTS EQUIPMENT;
DROP TABLE IF EXISTS SERVICE;
DROP TABLE IF EXISTS EVENTS;
  1. 附加用于设置数据库的 SQL 脚本,其中包含用于预先加载一些设备和服务的插入操作:
INSERT into EQUIPMENT (ID, HOST_NAME, STATUS) values (1,'pyhost1', 1);
INSERT into EQUIPMENT (ID, HOST_NAME, STATUS) values (2,'pyhost2', 1);
INSERT into EQUIPMENT (ID, HOST_NAME, STATUS) values (3,'pyhost3', 1);
INSERT into SERVICE (ID, NAME, STATUS) values (1, 'service-abc', 'Operational');
INSERT into SERVICE (ID, NAME, STATUS) values (2, 'service-xyz', 'Outage');
INSERT into SERVICE_MAPPING (SERVICE_FK, EQUIPMENT_FK) values (1,1);
INSERT into SERVICE_MAPPING (SERVICE_FK, EQUIPMENT_FK) values (1,2);
INSERT into SERVICE_MAPPING (SERVICE_FK, EQUIPMENT_FK) values (2,1);
INSERT into SERVICE_MAPPING (SERVICE_FK, EQUIPMENT_FK) values (2,3);
  1. 最后,编写一个根据当前活动事件更新设备状态的方法:
def update_equipment(self,equip,event):
    try:
      max = self.dt.query_for_int(\
                  """select max(EVENTS.SEVERITY) 
                     from ACTIVE_EVENTS AE
                     join EVENTS on (EVENTS.ID = AE.EVENT_FK) 
                     where EVENTS.HOST_NAME = ?""",
                  (event.hostname,))
    except DataAccessException:
        max = 1
    if max != equip["STATUS"]:
         equip["STATUS"] = max 
         self.dt.update("""update EQUIPMENT
                           set STATUS = ?""",
                        (equip["STATUS"],))
    return equip

在这里,我们需要从给定主机名的活动事件列表中找到最大的严重性。如果没有活动事件,那么 Spring Python 会引发 DataAccessException,我们将其转换为严重性为 1。

我们检查这是否与现有设备的状态不同。如果是,我们发出 SQL 更新。最后,我们返回设备的记录,并适当更新其状态。

它是如何工作的...

该应用程序使用基于数据库的机制来处理传入的网络事件,并将其与设备和服务清单进行比对,以评估故障和恢复。我们的应用程序不处理专门的设备或不寻常的服务类型。这种现实世界的复杂性已被交换为一个相对简单的应用程序,可以用来编写各种测试配方。

事件通常映射到单个设备和零个或多个服务。可以将服务视为用于向客户提供某种类型服务的设备字符串。新的失败事件在清除事件到来之前被视为活动。活动事件在与设备聚合时定义其当前状态。活动事件在与服务聚合时定义服务的当前状态。

在测试套件上安装和运行覆盖率

安装覆盖工具并运行它对测试套件进行测试。然后您可以查看报告,显示测试套件覆盖了哪些行。

如何做...

通过以下步骤,我们将构建一些 unittests,然后通过覆盖工具运行它们:

  1. 创建一个名为recipe52.py的新文件,包含本配方的测试代码。

  2. 编写一个简单的单元测试,向系统注入一个警报事件:

from network import * 
import unittest
from springpython.database.factory import *
from springpython.database.core import *
class EventCorrelationTest(unittest.TestCase):
      def setUp(self):
          db_name = "recipe52.db"
          factory = Sqlite3ConnectionFactory(db_name)
          self.correlator = EventCorrelator(factory)
          dt = DatabaseTemplate(factory)
          sql = open("network.sql").read().split(";")
          for statement in sql:
              dt.execute(statement + ";")
      def test_process_events(self):
          evt1 = Event("pyhost1", "serverRestart", 5)
          stored_event, is_active, \
                updated_services, updated_equipment = \
                self.correlator.process(evt1)
          print "Stored event: %s" % stored_event
          if is_active:
             print "This event was an active event."
             print "Updated services: %s" % updated_services
             print "Updated equipment: %s" % updated_equipment
             print "---------------------------------"
if __name__ == "main":
     unittest.main()          
  1. 使用coverage -e清除任何现有的覆盖报告数据。

  2. 使用覆盖工具运行测试套件:

gturnquist$ coverage -x recipe52.py
Stored event: (ID:1) pyhost1:serverRestart - 5 This event was an active event.
Updated services: [{'is_active': True, 'service': {'STATUS': 'Outage', 'ID': 1, 'NAME': u'service-abc'}}, {'is_active': True, 'service': {'STATUS': u'Outage', 'ID': 2, 'NAME': u'service- xyz'}}] 
Updated equipment: [{'STATUS': 5, 'ID': 1, 'HOST_NAME': u'pyhost1'}]
---------------------------------

.

------------------------------------------------------------------
----
Ran 1 test in 0.211s OK 
  1. 通过输入coverage -r打印出上一个命令捕获的报告。如果报告显示了 Python 标准库中列出的几个其他模块,这表明您安装了旧版本的覆盖工具。如果是这样,请通过输入pip uninstall coverage卸载旧版本,然后使用pip install coverage重新安装:

  1. 创建另一个名为recipe52b.py的文件,包含不同的测试套件。

  2. 编写另一个测试套件,生成两个故障,然后清除它们:

from network import*
import unittest
from springpython.database.factory import*
from springpython.database.core import*
class EventCorrelationTest(unittest.TestCase):
      def setUp(self):
          db_name = "recipe52b.db"
          factory = Sqlite3ConnectionFactory(db=db_name)
          self.correlator = EventCorrelator(factory)
          dt = DatabaseTemplate(factory)
          sql = open("network.sql").read().split(";")
          for statement in sql:
             dt.execute(statement + ";")
      def test_process_events(self):
          evt1 = Event("pyhost1", "serverRestart", 5)
          evt2 = Event("pyhost2", "lineStatus", 5)
          evt3 = Event("pyhost2", "lineStatus", 1)
          evt4 = Event("pyhost1", "serverRestart", 1)
          for event in [evt1, evt2, evt3, evt4]:
              stored_event, is_active, \ 
                 updated_services, updated_equipment = \
                  self.correlator.process(event)
              print "Stored event: %s" % stored_event
              if is_active:
                print "This event was an active event."
                print "Updated services: %s" % updated_services
                print "Updated equipment: %s" % updated_equipment
                print "---------------------------------"
  if __name__ == "__main__": 
     unittest.main()
  1. 通过coverage -x recipe52b.py使用覆盖工具运行此测试套件。

  2. 通过输入coverage -r打印报告:

第一个测试套件只注入了一个警报。我们期望它会导致服务中断并使其相关的设备停机。由于这不会执行任何事件清除逻辑,我们当然不期望达到 100%的代码覆盖率。

在报告中,我们可以看到它说network.py有 65 个语句,执行了其中的 55 个,覆盖率为 85%。我们还看到recipe52.py有 23 个语句,全部执行了。这意味着我们的所有测试代码都运行了。

在这一点上,我们意识到我们只测试了事件相关器的警报部分。为了使这更有效,我们应该注入另一个警报,然后进行一些清除操作,以确保一切都清除干净,服务返回到运行状态。这应该导致我们的简单应用程序实现 100%的覆盖率。

第二个截图确实显示我们已经完全覆盖了network.py

还有更多...

我们还看到 Spring Python 也被报告了。如果我们使用了其他第三方库,它们也会出现。这是正确的吗?这取决于情况。先前的评论似乎表明我们并不真的关心 Spring Python 的覆盖范围,但在其他情况下,我们可能会非常感兴趣。覆盖工具如何知道在哪里划定界限呢?

在后续的配方中,我们将研究如何更有选择性地进行测量,以便过滤掉噪音。

为什么 unittest 中没有断言?

的确,unittest 在测试结果方面是不够的。为了制定这个配方,我目视检查输出,以查看网络管理应用程序是否按预期运行。但这是不完整的。一个真正的生产级单元测试需要以一组断言完成,这样就不需要进行视觉扫描了。

为什么我们没有编写任何代码呢?因为这个配方的重点是如何生成覆盖报告,然后利用这些信息来增强测试。我们涵盖了这两个方面。通过思考测试了什么和没有测试的内容,我们编写了一个全面的测试,显示了服务进入故障和恢复到运行状态。我们只是没有自动确认这一点。

使用覆盖生成 HTML 报告

使用覆盖工具生成 HTML 可视化覆盖报告。这很有用,因为我们可以深入到源代码中,看看测试过程中没有运行的代码行。

阅读覆盖报告而不阅读源代码是没有什么用的。根据覆盖百分比比较两个不同的项目可能很诱人。但是除非实际代码被分析,否则这种比较可能导致对软件质量的错误结论。

如何做...

通过这些步骤,我们将探讨如何创建一个可视化的 HTML 覆盖报告:

  1. 通过按照在您的测试套件上安装和运行覆盖的步骤生成覆盖度指标,并且只运行第一个测试套件(覆盖率低于 100%)。

  2. 通过输入coverage.html生成 HTML 报告。

  3. 使用您喜欢的浏览器打开htmlcov/index.html并检查整体报告:

  1. 点击“network”,并向下滚动以查看由于未处理清除事件而未运行的事件清除逻辑:

它是如何工作的...

覆盖工具具有内置功能生成 HTML 报告。这提供了一种强大的方式来直观地检查源代码,并查看哪些行未被执行。

通过查看这份报告,我们可以清楚地看到未执行的行与正在处理的网络事件清除相关。这提示我们另一个测试案例,涉及需要起草的事件清除。

使用覆盖率生成 XML 报告

覆盖工具可以以 Cobertura 格式(cobertura.sourceforge.net/)生成 XML 覆盖报告。如果我们想要在另一个工具中处理覆盖信息,这是很有用的。在这个配方中,我们将看到如何使用覆盖命令行工具,然后手动查看 XML 报告。

重要的是要理解,阅读覆盖率报告而不阅读源代码并不是很有用。可能会诱人根据覆盖率百分比比较两个不同的项目。但除非实际代码被分析,这种比较可能导致对软件质量的错误结论。

例如,一个覆盖率为 85%的项目表面上看起来可能比一个 60%的项目测试得更好。然而,如果 60%的应用程序有更加彻底详尽的场景——因为它们只覆盖了系统中使用频繁的核心部分——那么它可能比 85%的应用程序更加稳定。

当比较迭代之间的测试结果,并且用它来决定哪些场景需要添加到我们的测试库时,覆盖分析就发挥了作用。

如何做...

通过这些步骤,我们将发现如何创建一个可以被其他工具使用的 XML 报告,使用覆盖工具:

  1. 通过按照安装和运行覆盖 在您的测试套件上配方中的步骤生成覆盖度指标(在第一章中提到,使用 Unittest 开发基本 测试),并且只运行第一个测试套件(覆盖率低于 100%)。

  2. 通过输入coverage xml生成 XML 报告。

  3. 使用您喜欢的文本或 XML 编辑器打开coverage.xml。XML 的格式与 Cobertura 一样——这是一个 Java 代码覆盖率分析器。这意味着许多工具,如 Jenkins,可以解析结果:

它是如何工作的...

覆盖工具具有内置功能,可以生成 XML 报告。这提供了一种使用某种外部工具解析输出的强大方式。

在上一张截图中,我使用了 Spring Tool Suite 打开它(您可以从www.springsource.com/developer/sts)下载它),部分原因是因为我每天都在使用 STS,但您可以使用任何您喜欢的文本或 XML 编辑器。

XML 报告有什么用?

XML 不是向用户传达覆盖信息的最佳方式。生成带覆盖的 HTML 报告是在涉及人类用户时更实用的配方。

如果我们想捕获覆盖率报告并将其发布到 Jenkins 等持续集成系统中,我们只需要安装 Cobertura 插件(参考wiki.jenkins-ci.org/display/JENKINS/Cobertura+Plugin),这份报告就可以被追踪。Jenkins 可以很好地监控覆盖率的趋势,并在我们开发系统时给予更多反馈。

另请参阅

  • 让 Jenkins 与覆盖一起多管闲事

  • 使用覆盖生成 HTML 报告

与覆盖一起变得多管闲事

安装覆盖鼻子插件,并使用鼻子运行测试套件。这提供了一个快速和方便的报告,使用无处不在的 nosetests 工具。本教程假设您已经按照构建网络 管理应用程序部分的描述创建了网络管理应用程序。

如何做到...

通过这些步骤,我们将看到如何将覆盖工具与鼻子结合使用:

  1. 创建一个名为recipe55.py的新文件来存储我们的测试代码。

  2. 创建一个测试用例,注入一个有故障的警报:

from network import*
import unittest
from springpython.database.factory import*
from springpython.database.core import*
class EventCorrelationTest(unittest.TestCase):
      def setUp(self):
         db_name = "recipe55.db"
         factory = Sqlite3ConnectionFactory(db=db_name)
         self.correlator = EventCorrelator(factory)
         dt = DatabaseTemplate(factory)
         sql = open("network.sql").read().split(";")
         for statement in sql:
            dt.execute(statement + ";")
      def test_process_events(self):
         evt1 = Event("pyhost1", "serverRestart", 5)
         stored_event, is_active, \ 
              updated_services, updated_equipment = \
         self.correlator.process(evt1)
         print "Stored event: %s" % stored_event
         if is_active:
            print "This event was an active event."
            print "Updated services: %s" % updated_services
            print "Updated equipment: %s" % updated_equipment
            print "---------------------------------"
  1. 使用覆盖插件运行测试模块,输入nosetests recipe55 – with-coverage

它是如何工作的...

覆盖鼻子插件调用覆盖工具并提供格式化的报告。对于每个模块,它显示以下内容:

  • 总语句数

  • 错过的语句数量

  • 覆盖语句的百分比

  • 错过语句的行号

还有更多...

鼻子的一个常见行为是改变stdout,禁用嵌入在测试用例中的print语句。

为什么使用鼻子插件而不是直接使用覆盖工具?

覆盖工具本身可以很好地工作,就像本章中的其他教程中所演示的那样。然而,鼻子是许多开发人员使用的无处不在的测试工具。提供一个插件可以轻松支持这个庞大的社区,使用户能够运行他们想要的确切的测试插件集,其中覆盖是其中的一部分。

为什么包括 SQLite3 和 Spring Python?

SQLite3 是 Python 附带的关系数据库库。它是基于文件的,这意味着不需要单独的进程来创建和使用数据库。有关 Spring Python 的详细信息可以在本章的早期部分找到。

本教程的目的是测量我们的网络管理应用程序和相应的测试用例的覆盖范围。那么为什么包括这些第三方库?覆盖工具无法自动知道我们想要的和我们不想从覆盖的角度看到的东西。要深入了解这一点,请参阅下一节,过滤掉 覆盖中的测试噪音

从覆盖中过滤掉测试噪音

使用命令行选项,可以过滤掉计算的行。本教程假设您已经按照构建网络 管理应用程序部分的描述创建了网络管理应用程序。

如何做到...

通过这些步骤,我们将看到如何过滤掉某些模块在我们的覆盖报告中被计算。

  1. 创建一个测试套件,测试所有代码功能:
from network import*
import unittest
from springpython.database.factory import*
from springpython.database.core import *
class EventCorrelationTest(unittest.TestCase):
   def setUp(self):
      db_name = "recipe56.db"
      factory = Sqlite3ConnectionFactory(db=db_name)
      self.correlator = EventCorrelator(factory)
      dt = DatabaseTemplate(factory)
      sql = open("network.sql").read().split(";")
      for statement in sql:
        dt.execute(statement + ";")
   def test_process_events(self):
       evt1 = Event("pyhost1", "serverRestart", 5)
       evt2 = Event("pyhost2", "lineStatus", 5)
       evt3 = Event("pyhost2", "lineStatus", 1)
       evt4 = Event("pyhost1", "serverRestart", 1)
       for event in [evt1, evt2, evt3, evt4]:
           stored_event, is_active,\
              updated_services, updated_equipment=\
                  self.correlator.process(event)
           print "Stored event: %s" % stored_event
       if is_active:
          print "This event was an active event."
          print "Updated services: %s" % updated_services
          print "Updated equipment: %s" % updated_equipment
          print "---------------------------------"
if __name__=="__main__":
   unittest.main()
  1. 通过运行coverage -e清除任何先前的覆盖数据。

  2. 使用coverage -x recipe56.py运行它。

  3. 使用coverage -r生成控制台报告。在下面的截图中,观察 Spring Python 如何包含在报告中,并将总度量减少到 73%:

  1. 通过运行coverage -e清除覆盖数据。

  2. 再次使用coverage run –source network.py,recipe56.py,recipe56.py运行测试。

  3. 使用coverage -r生成另一个控制台报告。请注意在下一个截图中,Spring Python 不再列出,将我们的总覆盖范围提高到 100%:

  1. 清除覆盖数据,运行coverage -e

  2. 使用coverage -x recipe56.py运行测试。

  3. 使用coverage -r recipe56.py network.py生成控制台报告:

它是如何工作的...

覆盖提供了决定分析哪些文件和报告哪些文件的能力。前一节中的步骤多次收集度量标准,要么通过使用一组受限的源文件运行覆盖(以过滤掉 Spring Python),要么通过在报告中请求一组显式的模块。

所有这一切引发的一个问题是,“最好的选择是什么?”对于我们的测试场景,这两个选择是等效的。大约输入相同数量的内容,我们过滤掉了 Spring Python,并得到了一个报告,显示network.pyrecipe56.py的覆盖率都达到了 100%。然而,一个真实的项目可能会更好地收集所有可用的度量数据,并在报告级别进行过滤,尤其是在有很多模块和可能不同团队在不同领域工作的情况下。

这样,可以根据需要运行子系统的不同报告,而无需不断重新捕获度量数据,并且仍然可以从相同的收集数据中运行整个系统覆盖率的总体报告。

还有更多...

前一节中使用的选项是包容性的。我们选择了要包含的内容。覆盖工具还带有一个-omit选项。挑战在于它是一个基于文件的选项,而不是基于模块的选项。不能使用-omit springpython。相反,必须指定每个文件,而在这种情况下,这将需要排除四个完整的文件。

更进一步,Spring Python 文件的完整路径需要包含在内。这导致一个非常冗长的命令,与我们演示的方式相比并没有太多好处。

在其他情况下,如果要排除的文件是在运行覆盖率的地方,则可能更实际。

覆盖工具还有其他在本章未涉及的选项,例如测量分支覆盖率而不是语句覆盖率,排除行,以及能够并行运行以管理从多个进程收集度量。

如前所述,覆盖工具有能力过滤掉单独的行。在我看来,这听起来很像试图使覆盖率报告达到某个规定的百分比。覆盖工具最好用于努力编写更全面的测试、修复错误和改进开发,而不是用于构建更好的报告。

另请参阅

在本章前面的“构建网络管理应用程序”配方中

让 Jenkins 与覆盖率挂钩

配置 Jenkins 运行一个使用 nose 的测试套件,生成一个覆盖率报告。本配方假设您已经按照“构建网络管理应用程序”部分的描述创建了网络管理应用程序。

准备就绪

让我们看看以下步骤:

  1. 如果您已经下载了 Jenkins 并在之前的配方中使用过它,请在您的主目录中查找一个.jenkins文件夹并将其删除,以避免此配方引起的意外差异。

  2. 安装 Jenkins。

  3. 打开控制台以确认 Jenkins 是否正常工作:

  4. 点击“管理 Jenkins”。

  5. 点击“管理插件”。

  6. 点击“可用”选项卡。

  7. 找到“Cobertura 插件”并在其旁边点击复选框。

  8. 找到“Git 插件”并在其旁边点击复选框。

  9. 在页面底部,点击“安装”按钮。

  10. 返回仪表板屏幕。

  11. 关闭 Jenkins 并重新启动它。

  12. 在您的计算机上安装 Git 源代码控制。

  13. 为这个配方创建一个空文件夹:

gturnquist$ mkdir /tmp/recipe57
  1. 初始化源代码维护的文件夹:
gturnquist$ git init /tmp/recipe57
  1. 将网络应用程序和 SQL 脚本复制到文件夹中,添加并提交更改:
gturnquist$ cp network.py /tmp/recipe57/ 
gturnquist$ cp network.sql /tmp/recipe57/ 
gturnquist$ cd /tmp/recipe57/
gturnquist$ git add network.py network.sql
gturnquist$ git commit -m "Add network app"
[master (root-commit) 7f78d46] Add network app
2 files changed, 221 insertions(+), 0 deletions(-)
create mode 100644 network.py
create mode 100644 network.sql 

如何做...

通过这些步骤,我们将探讨如何配置 Jenkins 来构建覆盖率报告,并通过 Jenkins 的界面提供服务。

  1. 创建一个名为recipe57.py的新文件,用于包含本配方的测试代码。

  2. 编写一个部分执行网络管理应用程序的测试用例:

from network import*
import unittest
from springpython.database.factory import*
from springpython.database.core import*
class EventCorrelationTest(unittest.TestCase):
    def setUp(self):
        db_name = "recipe57.db"
        factory = Sqlite3ConnectionFactory(db=db_name)
        self.correlator = EventCorrelator(factory)
        dt = DatabaseTemplate(factory)
        sql = open("network.sql").read().split(";")
        for statement in sql:
           dt.execute(statement + ";")
    def test_process_events(self):
        evt1 = Event("pyhost1", "serverRestart", 5)
        stored_event, is_active, updated_services, updated_equipment = \
            self.correlator.process(evt1)
        print "Stored event: %s" % stored_event
        if is_active:
        print "This event was an active event."
        print "Updated services: %s" % updated_services
        print "Updated equipment: %s" % updated_equipment
        print "---------------------------------"
  1. 将其复制到源代码存储库中。添加并提交更改:
gturnquist$ cp recipe57.py /tmp/recipe57/ gturnquist$ cd /tmp/recipe57/ gturnquist$ git add recipe57.py
gturnquist$ git commit -m "Added tests."
[master 0bf1761] Added tests.
1 files changed, 37 insertions(+), 0 deletions(-)
create mode 100644 recipe57.py
  1. 打开 Jenkins 控制台。

  2. 点击“新建任务”。

  3. 输入recipe57作为作业名称,并选择构建自由样式软件项目。

  4. 点击“确定”。

  5. 在“源代码管理”部分,选择“Git”。对于“URL”,输入“/tmp/recipe57/”。

  6. 在构建触发器部分,选择轮询 SCM并在计划框中输入* * * * *以触发每分钟轮询一次。

  7. 在构建部分,选择执行 Shell并输入以下脚本,加载虚拟环境并运行测试套件:

. /Users/gturnquist/ptc/bin/activate
coverage -e
coverage run /Users/gturnquist/ptc/bin/nosetests
recipe57.py coverage xml --include=network.py,recipe57.py

您需要包括激活虚拟环境并运行覆盖工具的步骤,如下所示。

  1. 在后构建操作部分,选择发布 Cobertura 覆盖率报告

  2. 输入coverage.xml作为Cobertura xml 报告模式

  3. 点击保存以保存所有作业设置。

  4. 导航回仪表板。

  5. 点击启用自动刷新

  6. 等待约一分钟以运行构建作业:

  1. 点击结果(在上一个截图中的#1)。

  2. 点击覆盖率 报告。观察下一个截图,其中报告了 89%的覆盖率:

  1. 点击模块.(点)以查看network.pyrecipe57.py

  2. 点击recipe57.py以查看哪些行被覆盖,哪些行被忽略。

它是如何工作的...

覆盖工具生成一个有用的 XML 文件,Jenkins Cobertura 插件可以收集。可以只生成 HTML 报告并通过 Jenkins 提供,但 XML 文件允许 Jenkins 很好地绘制覆盖率趋势。它还提供了查看源代码以及已覆盖和未覆盖行的手段。

我们还将其与源代码控制集成,因此,当更改提交到存储库时,将运行新的作业。

还有更多...

不要过于关注覆盖率报告很重要。覆盖工具对于跟踪测试很有用,但纯粹为了增加覆盖率而工作并不能保证构建更好的代码。它应该被用作一个工具来阐明缺少的测试场景,而不是考虑测试缺失的代码行。

Nose 不直接支持覆盖率的 XML 选项

覆盖工具的 nose 插件不包括生成 XML 文件的功能。这是因为覆盖插件是 nose 的一部分,不是覆盖项目的一部分。它没有更新到最新的功能,包括 XML 报告。

我问过覆盖项目的创建者 Ned Batchelder 关于 nose 缺乏 XML 支持的问题。他建议我在coverage内运行nosetests,就像在 Jenkins 作业中之前展示的那样。它会生成相同的.coverage跟踪数据文件。然后很容易执行coverage xml并使用所需的参数来获得我们想要的报告。实际上,我们可以在这个阶段使用覆盖的任何报告功能。不幸的是,覆盖工具需要明确指定nosetests的路径,并且在 Jenkins 中运行需要明确指定路径。

更新项目级脚本以提供覆盖率报告

更新项目级脚本以生成 HTML、XML 和控制台覆盖率报告作为可运行选项。

准备工作

  • 通过输入pip install coverage安装覆盖率

  • 创建构建网络管理应用程序配方中描述的网络管理应用程序

如何做...

通过这些步骤,我们将探讨如何在项目管理脚本中以编程方式使用覆盖率:

  1. 创建一个名为recipe58.py的新文件来存储这个命令行脚本。

  2. 创建一个使用getopt解析命令行参数的脚本:

import getopt
import logging
import nose 
import os
import os.path
import re
import sys
from glob import glob
def usage():
print
print "Usage: python recipe58.py [command]"
print "\t--help"
print "\t--test"
print "\t--package"
print "\t--publish"
print "\t--register"
print
try:
 optlist, args = getopt.getopt(sys.argv[1:],
 "h",
 ["help", "test", "package", "publish", "register"])
except getopt.GetoptError:
# print help information and exit:
 print "Invalid command found in %s" % sys.argv
 usage()
 sys.exit(2)
  1. 添加一个使用覆盖率 API 收集指标并生成控制台报告、HTML 报告和 XML 报告的测试函数,同时使用 nose 的 API 运行测试:
def test():
   from coverage import coverage
   cov = coverage() cov.start()
   suite = ["recipe52", "recipe52b", "recipe55", "recipe56", "recipe57"]
   print("Running suite %s" % suite)
   args = [""]
   args.extend(suite)
   nose.run(argv=args)
   cov.stop()
   modules_to_report = [module + ".py" for module in suite]
   modules_to_report.append("network.py")
   cov.report(morfs=modules_to_report)
   cov.html_report(morfs=modules_to_report, \
                         directory="recipe58")
   cov.xml_report(morfs=modules_to_report, \
                        outfile="recipe58.xml")      
  1. 添加一些其他的存根函数来模拟打包、发布和注册这个项目:
def package():
  print "This is where we can plug in code to run " + \ 
        "setup.py to generate a bundle."
def publish():
  print "This is where we can plug in code to upload " +\
        "our tarball to S3 or some other download site."
def publish():
  print "This is where we can plug in code to upload " +\
        "our tarball to S3 or some other download site."
def register():
  print "setup.py has a built in function to " + \
        "'register' a release to PyPI. It's " + \
        "convenient to put a hook in here."
# os.system("%s setup.py register" % sys.executable)
  1. 添加处理命令行参数并调用之前定义的函数的代码:
if len(optlist) == 0:
   usage()
   sys.exit(1)
# Check for help requests, which cause all other
# options to be ignored. for option in optlist:
if option[0] in ("--help", "-h"):
    usage()
    sys.exit(1)
# Parse the arguments, in order for option in optlist:
if option[0] in ("--test"):
   test()
if option[0] in ("--package"):
   package()
if option[0] in ("--publish"):
   publish()
if option[0] in ("--register"):

  1. 使用--test选项运行脚本:

  1. 使用您喜欢的浏览器打开 HTML 报告:

  1. 检查recipe58.xml

它是如何工作的...

如下步骤所示,覆盖 API 易于使用:

  1. 在测试方法中,我们创建了一个coverage()实例:
from coverage import coverage
cov = coverage()
  1. 我们需要调用start方法开始跟踪:
cov.start()
  1. 接下来,我们需要运行主要的代码。在这种情况下,我们正在使用 nose API。我们将使用它来运行本章中编写的各种配方:
suite = ["recipe52", "recipe52b", "recipe55", "recipe56", "recipe57"]
print("Running suite %s" % suite) 
args = [""]
args.extend(suite)
nose.run(argv=args)
  1. 然后我们需要停止覆盖跟踪:
cov.stop()
  1. 现在我们已经收集了指标,我们可以生成一个控制台报告,一个 HTML 报告和一个 XML 报告:
modules_to_report = [module + ".py" for module in suite] 
modules_to_report.append("network.py")
cov.report(morfs=modules_to_report)
cov.html_report(morfs=modules_to_report, directory="recipe58")
cov.xml_report(morfs=modules_to_report, outfile="recipe58.xml")

第一个报告是一个控制台报告。第二个报告是写入到recipe58子目录的 HTML 报告。第三个报告是以 Cobertura 格式写入到recipe58.xml的 XML 报告。

还有更多...

还有许多其他选项可以微调收集和报告。只需访问nedbatchelder.com/code/coverage/api.html上的在线文档获取更多详细信息。

我们只能使用 getopt 吗?

Python 2.7 引入了argparse作为getopt的替代方案。当前的文档没有指示getopt已被弃用,所以我们可以像刚才那样安全地使用。getopt模块是一个很好用的命令行解析器。

第八章:冒烟/负载测试-测试主要部分

在本章中,我们将涵盖以下主题:

  • 使用导入语句定义测试用例的子集

  • 省略集成测试

  • 针对端到端场景

  • 针对测试服务器

  • 编写数据模拟器

  • 实时记录和播放实时数据

  • 尽快记录和播放实时数据

  • 自动化管理演示

介绍

冒烟测试并不是被编写自动化测试的团队广泛接受的。编写测试来验证事物是否正常工作或暴露错误是一种常见的做法,许多团队也采用了使用验收测试来验证他们的应用程序是否满足客户需求的想法。

但冒烟测试有所不同。冒烟测试的一个关键想法是看系统是否有脉搏。这是什么意思?这类似于医生第一次看到患者时的情况。他们做的第一件事就是检查患者的脉搏,以及其他重要的生命体征。没有脉搏;重要的脉搏!那么,软件中的脉搏究竟是什么?这就是我们将在本章的示例中探讨的内容。

与考虑确保系统的每个角落都经过检查的全面测试套件不同,冒烟测试采用了更广泛的视角。一组冒烟测试旨在确保系统正常运行。这几乎就像是一个 ping 检查。将其与理智测试进行比较。理智测试用于证明一小部分情况实际上是有效的。冒烟测试,从快速和浅显的意义上来说是类似的,旨在查看系统是否处于足够的状态以进行更广泛的测试。

如果想象一个用于摄取发票的应用程序,一组冒烟测试可能包括以下内容:

  • 验证测试文件已被消耗

  • 验证解析的行数

  • 验证账单的总额

这听起来像是一小部分测试吗?它是不完整的吗?是的。这就是意图。我们不是在验证我们的软件是否正确解析了所有内容,而是在验证一些必须正常工作的关键领域。如果它无法读取一个文件,那么就需要解决一个重大问题。如果账单的总额不正确,那么再次,必须解决一个重大问题。

冒烟测试的一个关键副作用是这些测试应该运行得很快。如果我们改变了处理文件的功能会怎么样?如果我们的测试套件涉及解析许多不同类型的文件,那么验证我们没有破坏任何东西可能需要很长时间。与其花费 30 分钟来运行全面的测试套件,不如运行一分钟的快速测试,然后花费其他 29 分钟来处理软件会更好吗?

冒烟测试在准备客户演示时也很有用。在紧张的情况下,经常运行测试以确保我们没有破坏任何东西是很好的。在启动演示之前,可能需要进行最后一次脉冲检查以确保系统是活跃的。

本章还深入探讨了负载测试。负载测试对于验证我们的应用程序是否能够承受真实世界情况的压力至关重要。这通常涉及收集真实世界数据,并通过我们的软件进行可重现的环境回放。虽然我们需要知道我们的系统能够处理今天的负载,但明天的负载可能会有多大的可能性呢?不太可能。

寻找应用程序中的下一个瓶颈非常有用。这样,我们就可以在生产中遇到负载之前消除它。压力系统的一种方法是尽快播放实时数据。

在本章中,我们将看一些配方,其中我们既对网络管理应用程序进行了烟雾测试,又对其进行了负载测试。我们将对应用程序施加的负载类型也可以描述为浸泡测试压力测试浸泡测试被描述为在相当长的时间内对系统施加重大负载。压力测试被描述为将系统负载到崩溃。

在我看来,浸泡测试和压力测试是负载测试的不同方面。这就是为什么本章在各种配方可以轻松扩展到这些类型的测试时,简单地使用负载测试这个术语的原因。

本章中的代码还使用了 Spring Python 提供的几个实用工具(springpython.webfactional.com)。

本章中的许多配方与 MySQL 数据库交互。通过输入pip install mysql-python来安装 Python MySQLdb 库。

本章中的几个配方使用了Python Remote ObjectsPyro)(www.xs4all.nl/~irmen/pyro3/)。这是一个支持在线程和进程之间通信的远程过程调用RPC)库。通过输入pip install pyro来安装 Pyro。

使用导入语句定义一组测试用例的子集

创建一个 Python 模块,有选择地导入要运行的测试用例。

如何做...

通过这些步骤,我们将探讨有选择地选择一小组测试,以便进行更快的测试运行:

  1. 创建一个名为recipe59_test.py的测试模块,用于针对我们的网络应用编写一些测试,如下所示:
import logging
from network import *
import unittest
from springpython.database.factory import *
from springpython.database.core import *
  1. 创建一个测试用例,删除数据库连接并将数据访问函数存根化,如下所示:
class EventCorrelatorUnitTests(unittest.TestCase):
def setUp(self):
  db_name = "recipe59.db"
  factory = Sqlite3ConnectionFactory(db=db_name)
  self.correlator = EventCorrelator(factory)
  # We "unplug" the DatabaseTemplate so that
  # we don't talk to a real database.
  self.correlator.dt = None
  # Instead, we create a dictionary of
  # canned data to return back
  self.return_values = {}
  # For each sub-function of the network app,
  # we replace them with stubs which return our
  # canned data.
def stub_store_event(event):
  event.id = self.return_values["id"]
  return event, self.return_values["active"]
  self.correlator.store_event = stub_store_event
def stub_impact(event):
  return (self.return_values["services"],
  self.return_values["equipment"])
  self.correlator.impact = stub_impact
def stub_update_service(service, event):
  return service + " updated"self.correlator.update_service = 
  tub_update_service

def stub_update_equip(equip, event):
  return equip + " updated"
  self.correlator.update_equipment = stub_update_equip
  1. 创建一个测试方法,创建一组预定义数据值,调用应用程序的处理方法,然后验证这些值,如下所示:
def test_process_events(self):
  # For this test case, we can preload the canned data,
  # and verify that our process function is working
  # as expected without touching the database.
  self.return_values["id"] = 4668
  self.return_values["active"] = True
  self.return_values["services"] = ["service1",
                                    "service2"]
  self.return_values["equipment"] = ["device1"]
  evt1 = Event("pyhost1", "serverRestart", 5)
  stored_event, is_active,
  updated_services, updated_equipment =
  self.correlator.process(evt1)
  self.assertEquals(4668, stored_event.id)
  self.assertTrue(is_active)
self.assertEquals(2, len(updated_services))
self.assertEquals(1, len(updated_equipment))
  1. 创建另一个测试用例,使用 SQL 脚本预加载数据库,如下所示:
class EventCorrelatorIntegrationTests(unittest.TestCase):
  def setUp(self):
      db_name = "recipe59.db"
      factory = Sqlite3ConnectionFactory(db=db_name)
      self.correlator = EventCorrelator(factory)
      dt = DatabaseTemplate(factory)
      sql = open("network.sql").read().split(";")
for statement in sql:
   dt.execute(statement + ";")
  1. 编写一个调用网络应用程序的处理方法然后打印结果的测试方法,如下所示:
def test_process_events(self):
    evt1 = Event("pyhost1", "serverRestart", 5)
    stored_event, is_active,
       updated_services, updated_equipment =
                 self.correlator.process(evt1)
    print "Stored event: %s" % stored_event
    if is_active:
         print "This event was an active event."
    print "Updated services: %s" % updated_services
    print "Updated equipment: %s" % updated_equipment
    print "---------------------------------"
  1. 创建一个名为recipe59.py的新文件,只导入基于 SQL 的测试用例,如下所示:
from recipe59_test import EventCorrelatorIntegrationTests
if __name__ == "__main__":
     import unittest
     unittest.main()
  1. 运行测试模块。查看以下屏幕截图:

工作原理...

我们需要编写各种测试用例,以覆盖我们需要的不同测试级别。通过将测试运行程序与测试用例分开,我们可以决定仅运行与数据库集成的测试。

为什么要这样做?在我们的情况下,我们只有一个单元测试,而且运行速度相当快。你认为一个具有数月或数年开发时间和相应测试套件的真实应用程序会像这样快速运行吗?当然不会!

有些测试可能会很复杂。它们可能涉及与真实系统交流,解析大型样本数据文件和其他耗时任务。这可能需要几分钟甚至几小时才能运行。

当我们即将向客户做演示时,我们不需要一个运行时间很长的测试套件。相反,我们需要能够运行这些测试的一个快速子集,以确保一切正常。使用 Python 的导入语句可以很容易地定义这一点。

我们可能需要考虑的一些测试套件包括以下内容:

  • pulse.py:导入一组测试用例,对应用程序进行广泛而浅显的测试,以验证系统是否正常运行

  • checkin.py:导入一组当前正常运行的测试用例,并提供足够的信心,表明代码已准备好提交

  • integration.py:导入一组测试用例,启动、交互,然后关闭 LDAP、数据库或其他子系统等外部系统

  • security.py:导入一组专注于各种安全场景的测试用例,确认良好和不良凭据处理

  • all.py:导入所有测试用例以确保一切正常

这只是我们可以定义的测试模块类型的一个示例。我们可以为我们处理的每个子系统定义一个模块。但是,由于我们正在讨论冒烟测试,我们可能希望思考得更广泛,而是从每个子系统中挑选一些关键测试,并将它们联系在一起,以便让我们感觉应用程序正在运行。

还有更多...

让我们也看看这些。

安全性、检查和集成不是冒烟测试!

这是绝对正确的。前面的列表显示,使用 Python 导入语句不仅限于定义冒烟测试套件。它可以用于捆绑满足各种需求的测试用例。那么,为什么要提到这一点,因为我们正在谈论冒烟测试?嗯,因为我想传达这种机制在组织测试方面有多么有用,它不仅限于冒烟测试。

什么提供了很好的灵活性?

为了能够灵活地选择测试类,我们应该避免使测试类太大。但是,将每个测试方法放在不同的类中可能太多了。

另请参阅

本章中的省略集成测试配方

省略集成测试

快速测试套件避免连接到远程系统,如数据库和 LDAP。只验证核心单元并避免外部系统可能导致运行更快的测试套件并覆盖更多内容。这可以导致一个有用的冒烟测试,让开发人员对系统有信心,而不运行所有测试。

如何做...

通过这些步骤,我们将看到如何剔除与外部系统交互的测试用例:

  1. 创建一个名为recipe60_test.py的测试模块,用于编写我们的网络应用程序的一些测试,如下所示:
import logging
from network import *
import unittest
from springpython.database.factory import *
from springpython.database.core import *
  1. 创建一个测试用例,删除数据库连接并将数据访问函数存根出来:
class EventCorrelatorUnitTests(unittest.TestCase):
def setUp(self):
db_name = "recipe60.db"
factory = Sqlite3ConnectionFactory(db=db_name)
self.correlator = EventCorrelator(factory)
# We "unplug" the DatabaseTemplate so that
# we don't talk to a real database.
self.correlator.dt = None
# Instead, we create a dictionary of
# canned data to return back
self.return_values = {}
# For each sub-function of the network app,
# we replace them with stubs which return our
# canned data.
def stub_store_event(event):
event.id = self.return_values["id"]
return event, self.return_values["active"]
self.correlator.store_event = stub_store_event
def stub_impact(event):
return (self.return_values["services"],self.return_values["equipment"])
self.correlator.impact = stub_impact
def stub_update_service(service, event):
return service + " updated"
self.correlator.update_service = stub_update_service
def stub_update_equip(equip, event):
return equip + " updated"
self.correlator.update_equipment = stub_update_equip
  1. 创建一个测试方法,它创建一组预定义的数据值,调用应用程序的处理方法,然后验证这些值,如下所示:
def test_process_events(self):
# For this test case, we can preload the canned data,
# and verify that our process function is working
# as expected without touching the database.
self.return_values["id"] = 4668
self.return_values["active"] = True
self.return_values["services"] = ["service1",
"service2"]
self.return_values["equipment"] = ["device1"]
evt1 = Event("pyhost1", "serverRestart", 5)
stored_event, is_active,
updated_services, updated_equipment =
self.correlator.process(evt1)
self.assertEquals(4668, stored_event.id)
self.assertTrue(is_active)
self.assertEquals(2, len(updated_services))
self.assertEquals(1, len(updated_equipment))
  1. 创建另一个测试用例,使用 SQL 脚本预加载数据库:
class EventCorrelatorIntegrationTests(unittest.TestCase):
def setUp(self):
db_name = "recipe60.db"
factory = Sqlite3ConnectionFactory(db=db_name)
self.correlator = EventCorrelator(factory)
dt = DatabaseTemplate(factory)
sql = open("network.sql").read().split(";")
for statement in sql:
dt.execute(statement + ";")
  1. 编写一个测试方法,调用网络应用程序的处理方法,然后打印结果:
def test_process_events(self):
evt1 = Event("pyhost1", "serverRestart", 5)
stored_event, is_active,
updated_services, updated_equipment =
self.correlator.process(evt1)
print "Stored event: %s" % stored_event
if is_active:
print "This event was an active event."
print "Updated services: %s" % updated_services
print "Updated equipment: %s" % updated_equipment
print "---------------------------------"
  1. 创建一个名为recipe60.py的模块,它只导入避免进行 SQL 调用的单元测试。看看这段代码:
from recipe60_test import EventCorrelatorUnitTests
if __name__ == "__main__":
import unittest
unittest.main()
  1. 运行测试模块。查看以下截图:

它是如何工作的...

这个测试套件运行单元测试,并避免运行与实时数据库集成的测试用例。它使用 Python 的import语句来决定包括哪些测试用例。

在我们虚构的场景中,性能几乎没有提高。但是对于一个真实的项目,可能会花费更多的计算机周期来进行集成测试,因为与外部系统交互会产生额外的成本。

这个想法是创建一组测试的子集,以某种程度上验证我们的应用程序通过在很短的时间内覆盖大部分内容来工作。

冒烟测试的诀窍在于决定什么构成了足够好的测试。自动化测试无法完全确认我们的应用程序没有错误。我们受到这样一个事实的干扰,即特定的错误不存在,或者我们还没有编写一个暴露这样一个错误的测试用例。要进行冒烟测试,我们决定使用这些测试的一个子集进行快速脉冲读取。再次决定哪个子集给我们足够好的脉冲可能更多地是一种艺术而不是科学。

这个配方侧重于单元测试可能会更快地运行,并且剔除集成测试将删除较慢的测试用例。如果所有单元测试都通过了,那么我们就有了一些信心,我们的应用程序状态良好。

还有更多...

我必须指出,测试用例不仅仅容易归类为单元测试集成测试。它更多的是一个连续体。在这个配方的示例代码中,我们编写了一个单元测试和一个集成测试,然后我们选择了单元测试作为我们的冒烟测试套件。

这看起来是任意的,也许是刻意的吗?当然是。这就是为什么烟雾测试不是一成不变的,而是需要一些分析和判断来选择。随着开发的进行,还有改进的空间。

我曾经开发过一个系统,可以从不同供应商那里获取发票。我编写了设置空数据库表、摄取多种格式文件,然后检查数据库内容以验证处理的单元测试。测试套件运行超过 45 分钟。这迫使我不能像期望的那样频繁地运行测试套件。我设计了一个烟雾测试套件,只运行不涉及数据库的单元测试(因为它们很快),并摄取一个供应商发票。它运行时间少于五分钟,并提供了一个更快的方法来确保代码的基本更改没有破坏整个系统。我可以在一天中运行多次这个测试,并且每天只运行一次全面的测试套件。

烟雾测试是否应包括集成或单元测试?

这段代码是否与使用导入语句定义测试用例的子集食谱中显示的代码相似?是的,它是。那么,为什么要包含在这个食谱中呢?因为选择烟雾测试套件和用于实现它的策略一样关键。另一个食谱决定选择一个集成测试,同时剔除单元测试,以创建一个更小、更快的测试套件。

这个示例表明,另一个可能性是剔除更长的集成测试,而是尽可能多地运行单元测试,考虑它们可能更快。

如前所述,烟雾测试并不是一成不变的。它涉及选择最佳的测试代表,而不会花费太多时间来运行它们。很可能到目前为止写的测试中没有一个确切地针对捕捉系统脉搏的想法。一个好的烟雾测试套件可能涉及混合一部分单元测试和集成测试。

另请参阅

使用导入语句定义测试用例的子集食谱

针对端到端场景

选择一组测试,运行足够的部分来定义一个执行线程。这有时被称为线程测试。不是因为我们使用软件线程,而是因为我们专注于一个故事线。很多时候,我们的线程要么来自客户场景,要么至少受到它们的启发。其他线程可能涉及其他团队,比如运营团队。

例如,一个网络管理系统可能会推送影响客户的警报,但必须解决网络问题的内部运营团队可能有完全不同的视角。这两种情况都展示了有效的端到端线程,是值得投资自动化测试的好地方。

如果将不同团队视为不同类型的客户,那么验收测试的概念肯定适用。还可以将其与 BDD 的概念重叠。

准备工作

  1. 将 SQL 脚本复制到一个名为recipe61_network.sql的新文件中,并在底部替换插入语句为以下内容:
INSERT into EQUIPMENT (ID, HOST_NAME, STATUS) values (1, 'pyhost1', 1);
INSERT into EQUIPMENT (ID, HOST_NAME, STATUS) values (2, 'pyhost2', 1);
INSERT into EQUIPMENT (ID, HOST_NAME, STATUS) values (3, 'pyhost3', 1);
INSERT into SERVICE (ID, NAME, STATUS) values (1, 'service-abc', 
'Operational');
INSERT into SERVICE_MAPPING (SERVICE_FK, EQUIPMENT_FK) values (1,1);
INSERT into SERVICE_MAPPING (SERVICE_FK, EQUIPMENT_FK) values (1,2);

在这组测试数据中,pyhost1pyhost2映射到service-abcpyhost3没有映射到任何服务。

如何做...

通过这些步骤,我们将建立一个端到端的测试场景。

  1. 创建一个名为recipe61_test.py的测试模块。

  2. 创建一个测试用例,其中每个测试方法捕获不同的执行线程,如下所示:

import logging
from network import *
import unittest
from springpython.database.factory import *
from springpython.database.core import *
class EventCorrelatorEquipmentThreadTests(unittest.TestCase):
def setUp(self):
db_name = "recipe61.db"
factory = Sqlite3ConnectionFactory(db=db_name)
self.correlator = EventCorrelator(factory)
dt = DatabaseTemplate(factory)
sql = open("recipe61_network.sql").read().split(";")
for statement in sql:
dt.execute(statement + ";")
def tearDown(self):
self.correlator = None
  1. 创建一个捕获设备故障和恢复的线程的测试方法,如下所示:
def test_equipment_failing(self):
# This alarm maps to a device
# but doesn't map to any service.
  1. 测试方法应注入单个故障警报,然后确认相关设备已经失败,如下所示:
evt1 = Event("pyhost3", "serverRestart", 5)
stored_event, is_active,
updated_services, updated_equipment =
self.correlator.process(evt1)
self.assertTrue(is_active)
self.assertEquals(len(updated_services), 0)
self.assertEquals(len(updated_equipment), 1)
self.assertEquals(updated_equipment[0]["HOST_NAME"],
"pyhost3")
# 5 is the value for a failed piece of equipment
self.assertEquals(updated_equipment[0]["STATUS"], 5)
  1. 在同一个测试方法中,添加代码,注入单个清除警报,并确认设备已经恢复,如下所示:
evt2 = Event("pyhost3", "serverRestart", 1)
stored_event, is_active,
updated_services, updated_equipment =
self.correlator.process(evt2)
self.assertFalse(is_active)
self.assertEquals(len(updated_services), 0)
self.assertEquals(len(updated_equipment), 1)
self.assertEquals(updated_equipment[0]["HOST_NAME"],
"pyhost3")
# 1 is the value for a clear piece of equipment
self.assertEquals(updated_equipment[0]["STATUS"], 1)
  1. 创建另一个捕获服务失败和清除线程的测试方法,如下所示:
def test_service_failing(self):
# This alarm maps to a service.
  1. 编写一个测试方法,注入一个单一的故障警报,并确认设备和相关服务都失败了,如下所示:
evt1 = Event("pyhost1", "serverRestart", 5)
stored_event, is_active,
updated_services, updated_equipment =
self.correlator.process(evt1)
self.assertEquals(len(updated_services), 1)
self.assertEquals("service-abc",
updated_services[0]["service"]["NAME"])
self.assertEquals("Outage",
updated_services[0]["service"]["STATUS"])
  1. 在同一个测试方法中,添加代码,注入一个单一的清除警报,并确认设备和服务都已恢复,如下所示:
evt2 = Event("pyhost1", "serverRestart", 1)
stored_event, is_active,
updated_services, updated_equipment =
self.correlator.process(evt2)
self.assertEquals(len(updated_services), 1)
self.assertEquals("service-abc",
updated_services[0]["service"]["NAME"])
self.assertEquals("Operational",
updated_services[0]["service"]["STATUS"])
  1. 创建一个名为recipe61.py的测试运行器,导入这两个线程测试,如下所示:
from recipe61_test import *
if __name__ == "__main__":
import unittest
unittest.main()
  1. 运行测试套件。查看以下截图:

它是如何工作的...

在这个配方中,我们编写了两个端到端测试场景。现在考虑以下:

  • 第一个场景测试了我们的应用程序如何处理故障,然后是只影响设备的清除。

  • 第二个场景测试了我们的应用程序如何处理故障,然后是影响服务的清除。

我们注入了一个故障,然后检查结果,确认适当的库存部分失败了。然后我们注入了一个清除,再次确认适当的库存部分恢复了。

这两种情况都展示了我们的应用程序如何处理从开始到结束的不同类型的事件。

还有更多...

在这个应用程序的更复杂、更真实的版本中,你认为还有哪些其他系统会涉及到端到端的线程?安全性呢?交易?将结果发布到外部接口?

这就是我们需要定义端点的地方。想象一下,我们的应用程序已经发展到接收到来自网络请求的事件,并且设备和服务更新被推送为 JSON 数据,以便由网页接收。

一个好的端到端测试还应包括这些部分。对于 JSON 输出,我们可以使用 Python 的 JSON 库来解码输出,然后确认结果。对于传入的网络请求,我们可以使用许多不同的技术,包括接受测试工具,如机器人框架。

这如何定义冒烟测试?

如果运行所有端到端测试需要太长时间,我们应该选择其中一部分覆盖一些关键部分。例如,我们可以跳过基于设备的线程,但保留基于服务的线程。

另请参阅

  • 使用机器人框架测试 Web 基础知识 配方在第十章中,使用 Selenium 进行 Web UI 测试

  • 使用机器人验证 Web 应用程序安全 配方在第十章中,使用 Selenium 进行 Web UI 测试

针对测试服务器

你的测试服务器是否有所有的部分?如果没有,那么定义一组替代测试。

这个配方假设生产服务器有一个企业级的 MySQL 数据库系统,而开发人员的工作站没有。我们将探讨编写一些使用 MySQL 数据库的测试。但是当我们需要在开发实验室中运行它们时,我们将进行调整,使它们在 Python 捆绑的 SQLite 上运行。

你是否想知道为什么 MySQL 不在开发人员的工作站上?MySQL 确实很容易安装,而且性能负载不大。但是,如果生产服务器是 Oracle,并且管理层认为为我们的开发人员授予单独的许可证成本太高,那么这种情况同样适用。由于设置商业数据库的成本,这个配方使用的是 MySQL 和 SQLite,而不是 Oracle 和 SQLite。

准备工作

让我们看看以下步骤:

  1. 确保 MySQL 生产数据库服务器正在运行:

  1. 以 root 用户身份打开命令行 MySQL 客户端 shell。

  2. 为这个配方创建一个名为recipe62的数据库,并创建一个具有访问权限的用户。

  3. 退出 shell。与以下截图所示的情况相反,绝对不要创建一个存储在明文中的生产数据库密码。这个数据库仅用于演示目的:

如何做...

在这些步骤中,我们将看到如何构建针对不同服务器的测试:

  1. 创建一个名为recipe62_network.mysql的 SQL 脚本的备用版本,该脚本在早期的配方中使用了 MySQL 约定,如下所示:
DROP TABLE IF EXISTS SERVICE_MAPPING;
DROP TABLE IF EXISTS SERVICE_EVENTS;
DROP TABLE IF EXISTS ACTIVE_EVENTS;
DROP TABLE IF EXISTS EQUIPMENT;
DROP TABLE IF EXISTS SERVICE;
DROP TABLE IF EXISTS EVENTS;
CREATE TABLE EQUIPMENT (
ID SMALLINT PRIMARY KEY AUTO_INCREMENT,
HOST_NAME TEXT,
STATUS SMALLINT
);
CREATE TABLE SERVICE (
ID SMALLINT PRIMARY KEY AUTO_INCREMENT,
NAME TEXT,
STATUS TEXT
);
CREATE TABLE SERVICE_MAPPING (
ID SMALLINT PRIMARY KEY AUTO_INCREMENT,
SERVICE_FK SMALLINT,
EQUIPMENT_FK SMALLINT
);
CREATE TABLE EVENTS (
ID SMALLINT PRIMARY KEY AUTO_INCREMENT,
HOST_NAME TEXT,
SEVERITY SMALLINT,
EVENT_CONDITION TEXT
);
CREATE TABLE SERVICE_EVENTS (
ID SMALLINT PRIMARY KEY AUTO_INCREMENT,
SERVICE_FK SMALLINT,
EVENT_FK SMALLINT
);
CREATE TABLE ACTIVE_EVENTS (
ID SMALLINT PRIMARY KEY AUTO_INCREMENT,
EVENT_FK SMALLINT
);
INSERT into EQUIPMENT (ID, HOST_NAME, STATUS) values (1, 'pyhost1', 1);
INSERT into EQUIPMENT (ID, HOST_NAME, STATUS) values (2, 'pyhost2', 1);
INSERT into EQUIPMENT (ID, HOST_NAME, STATUS) values (3, 'pyhost3', 1);
INSERT into SERVICE (ID, NAME, STATUS) values (1, 'service-abc', 
'Operational');
INSERT into SERVICE_MAPPING (SERVICE_FK, EQUIPMENT_FK) values (1,1);
INSERT into SERVICE_MAPPING (SERVICE_FK, EQUIPMENT_FK) values (1,2)

你可能没有注意到,但这个模式定义没有外键约束。在真实的 SQL 脚本中,它们肯定应该被包含进去。在这种情况下,它们被省略是为了减少复杂性。

  1. 创建一个新模块recipe62_test.py来放置我们的测试代码。

  2. 创建一个具有一个测试方法来验证事件到服务相关性的抽象测试用例,如下所示:

import logging
from network import *
import unittest
from springpython.database.factory import *
from springpython.database.core import *
class AbstractEventCorrelatorTests(unittest.TestCase):
def tearDown(self):
self.correlator = None
def test_service_failing(self):
# This alarm maps to a service.
evt1 = Event("pyhost1", "serverRestart", 5)
stored_event, is_active,
updated_services, updated_equipment =
self.correlator.process(evt1)
self.assertEquals(len(updated_services), 1)
self.assertEquals("service-abc",
updated_services[0]["service"]["NAME"])
self.assertEquals("Outage",
updated_services[0]["service"]["STATUS"])
evt2 = Event("pyhost1", "serverRestart", 1)
stored_event, is_active,
updated_services, updated_equipment =
self.correlator.process(evt2)
self.assertEquals(len(updated_services), 1)
self.assertEquals("service-abc",
updated_services[0]["service"]["NAME"])
self.assertEquals("Operational",
updated_services[0]["service"]["STATUS"])
  1. 创建一个连接到 MySQL 数据库并使用 MySQL 脚本的具体子类,如下所示:
class MySQLEventCorrelatorTests(AbstractEventCorrelatorTests):
def setUp(self):
factory = MySQLConnectionFactory("user", "password",
"localhost", "recipe62")
self.correlator = EventCorrelator(factory)
dt = DatabaseTemplate(factory)
sql = open("recipe62_network.mysql").read().split(";")
for statement in sql:
dt.execute(statement + ";")
  1. 创建一个名为recipe62_production.py的相应的生产测试运行程序,如下所示:
from recipe62_test import MySQLEventCorrelatorTests
if __name__ == "__main__":
import unittest
unittest.main()

运行它并验证它是否连接到生产数据库:

  1. 现在创建一个 SQLite 版本的 SQL 脚本,名为recipe62_network.sql,如下所示:
DROP TABLE IF EXISTS SERVICE_MAPPING;
DROP TABLE IF EXISTS SERVICE_EVENTS;
DROP TABLE IF EXISTS ACTIVE_EVENTS;
DROP TABLE IF EXISTS EQUIPMENT;
DROP TABLE IF EXISTS SERVICE;
DROP TABLE IF EXISTS EVENTS;
CREATE TABLE EQUIPMENT (
ID INTEGER PRIMARY KEY,
HOST_NAME TEXT UNIQUE,
STATUS INTEGER
);
CREATE TABLE SERVICE (
ID INTEGER PRIMARY KEY,
NAME TEXT UNIQUE,
STATUS TEXT
);
CREATE TABLE SERVICE_MAPPING (
ID INTEGER PRIMARY KEY,
SERVICE_FK,
EQUIPMENT_FK,
FOREIGN KEY(SERVICE_FK) REFERENCES SERVICE(ID),
FOREIGN KEY(EQUIPMENT_FK) REFERENCES EQUIPMENT(ID)
);
CREATE TABLE EVENTS (
ID INTEGER PRIMARY KEY,
HOST_NAME TEXT,
SEVERITY INTEGER,
EVENT_CONDITION TEXT
);
CREATE TABLE SERVICE_EVENTS (
ID INTEGER PRIMARY KEY,
SERVICE_FK,
EVENT_FK,
FOREIGN KEY(SERVICE_FK) REFERENCES SERVICE(ID),
FOREIGN KEY(EVENT_FK) REFERENCES EVENTS(ID)
);
CREATE TABLE ACTIVE_EVENTS (
ID INTEGER PRIMARY KEY,
EVENT_FK,
FOREIGN KEY(EVENT_FK) REFERENCES EVENTS(ID)
);
INSERT into EQUIPMENT (ID, HOST_NAME, STATUS) values (1, 'pyhost1', 1);
INSERT into EQUIPMENT (ID, HOST_NAME, STATUS) values (2, 'pyhost2', 1);INSERT into EQUIPMENT (ID, HOST_NAME, STATUS) values (3, 'pyhost3', 1);
INSERT into SERVICE (ID, NAME, STATUS) values (1, 'service-abc', 'Op
erational');
INSERT into SERVICE_MAPPING (SERVICE_FK, EQUIPMENT_FK) values (1,1);
INSERT into SERVICE_MAPPING (SERVICE_FK, EQUIPMENT_FK) values (1,2);
  1. 创建抽象测试用例的另一个具体子类,让它使用 SQLite 连接 SQLite 脚本,并将其添加到recipe62_test.py中,如下所示:
class Sqlite3EventCorrelatorTests(AbstractEventCorrelatorTests):
def setUp(self):
factory = Sqlite3ConnectionFactory("recipe62.db")
self.correlator = EventCorrelator(factory)
dt = DatabaseTemplate(factory)
sql = open("recipe62_network.sql").read().split(";")for statement in sql:
dt.execute(statement + ";")
  1. 创建一个名为recipe62_dev.py的相应的开发工作站测试运行程序,如下所示:
from recipe62_test import Sqlite3EventCorrelatorTests
if __name__ == "__main__":
import unittest
unittest.main()
  1. 运行它并验证它是否连接到开发数据库:

它是如何工作的...

在生产环境中拥有完整的服务器和安装的软件,同时在较小的开发环境中也有是很常见的。一些商店甚至有一个介于这些配置之间的测试床。

我们的网络应用通过允许将数据库连接信息注入其中来处理这种情况。在每个测试用例中,我们使用完全相同的应用程序,但使用不同的数据库系统。

我们编写了一个使用生产 MySQL 数据库的测试用例,还编写了一个使用开发 SQLite 数据库的测试用例。当然,MySQL,即使在许多生产环境中使用,也不像是开发人员无法使用的东西。但它提供了一个很容易看到的例子,即必须切换数据库系统。

还有更多...

在这个配方中,我们展示了需要切换数据库系统。这不是唯一可能需要为测试目的提供备用配置的外部系统。其他东西,比如 LDAP 服务器、第三方 Web 服务和独立子系统,可能有完全不同的配置。

我曾在几个合同上工作过,经常看到管理人员削减开发实验室资源以节省成本。他们似乎得出结论,即维护多个配置和处理不可重现的错误的成本要低于拥有完全相同的设备和软件的成本。我觉得这个结论是错误的,因为在将来的某个时候,他们最终会因涉及平台差异的问题增加硬件并升级设备。

这意味着我们并不总是能够编写针对生产环境的测试。编写我们的软件,使其具有最大的灵活性,比如注入数据库配置,正如我们之前所做的那样,是一个最低要求。

重要的是,我们尽可能多地编写在开发人员平台上运行的测试。当开发人员不得不开始共享服务器端资源时,我们就会遇到资源冲突。例如,两个开发人员共享一个单一的数据库服务器将不得不做以下事情之一:

  • 有单独的模式,这样它们就可以清空和加载测试数据

  • 协调时间,使它们都可以访问相同的模式

  • 为每个开发人员设置不同的服务器

第三个选项是极不可能的,因为我们谈论的是一个比生产环境占地面积小得多的开发实验室。

一个积极的消息是,开发人员的机器变得更快更强大。与 10 年前相比,常见的工作站远远超过了旧的服务器机器。但是,即使我们每个人都能在自己的机器上运行整个软件堆栈,也并不意味着管理层会支付所有必要的许可费用。

不幸的是,这种限制可能永远不会改变。因此,我们必须准备为备用配置编写测试,并管理与生产环境的差异。

开发和生产环境使用两种不同的数据库系统的可能性有多大?

诚然,很少有人会在 SQLite 和 MySQL 之间进行如此大的切换。这本身就需要使用略有不同的 SQL 方言来定义模式。有些人可能会立刻认为这太难以管理了。但是环境中的较小差异仍然可能导致对减少测试的同样需求。

我曾经在一个系统上工作了很多年,该系统的生产系统使用的是 Oracle 9i RAC,而开发实验室只有 Oracle 9i。RAC 需要额外的硬件,而我们从未为此分配资源。更糟糕的是,Oracle 9i 太大了,无法安装在我们开发的相对轻量级的个人电脑上。虽然所有内容都使用 Oracle 的 SQL 方言,但是 RAC 和非 RAC 之间的运行时间差异产生了大量我们无法在开发实验室中复现的错误。这确实可以算作两个不同的数据库系统。鉴于我们无法在生产环境中工作,我们尽可能在开发实验室进行测试,然后安排时间在测试实验室进行测试,那里有一个 RAC 实例。由于许多人需要访问该实验室,我们限制了我们对 RAC 特定问题的使用,以避免时间延迟。

这不仅仅局限于数据库系统

正如前面所述,这不仅仅是关于数据库系统。我们已经讨论了 MySQL、SQLite 和 Oracle,但这也涉及到我们工作或依赖的任何在生产和开发环境之间有差异的系统。

能够编写子集测试以获得信心可以帮助减少我们不可避免地必须处理的实际问题。

编写数据模拟器

编写一个模拟器,以定义的速率产生数据,可以帮助模拟真实负载。

这个食谱假设读者的机器已经安装了 MySQL。

准备工作

  1. 确保 MySQL 生产数据库服务器正在运行。

  2. 以 root 用户身份打开命令行 MySQL 客户端 shell。

  3. 为这个食谱创建一个名为recipe63的数据库,以及一个具有访问权限的用户。

  4. 退出 shell,如下所示:

如何做到...

通过这些步骤,我们将探讨编写一个测试模拟器:

  1. 创建一个名为recipe63.py的测试生成器脚本,使用各种 Python 库,如下所示:
import getopt
import random
import sys
import time
from network import *
from springpython.remoting.pyro import *
  1. 创建一个打印命令行选项的使用方法,如下所示:
def usage():
print "Usage"
print "====="
print "-h, --help read this help"
print "-r, --rate [arg] number of events per second"
print "-d, --demo demo by printing events"
  1. 使用 Python 的 getopt 库来解析命令行参数,如下所示:
try:
opts, args = getopt.getopt(sys.argv[1:], "hr:d", ["help", "rate=",
"demo"])
except getopt.GetoptError, err:
print str(err)
usage()
sys.exit(1)
rate = 10
demo_mode = False
for o, a in opts:
if o in ("-h", "--help"):
usage()
sys.exit(1)
elif o in ("-r", "--rate"):
rate = a
elif o in ("-d", "--demo"):
demo_mode = True
  1. 添加一个开关,这样当它不在演示模式下,它将使用 Spring Python 的PyroProxyFactory连接到网络管理应用程序的服务器实例:
if not demo_mode:
print "Sending events to live network app. Ctrl+C to exit..."
proxy = PyroProxyFactory()
proxy.service_url = "PYROLOC://127.0.0.1:7766/network"
  1. 创建一个创建随机事件的无限循环,如下所示:
while True:
hostname = random.choice(["pyhost1","pyhost2","pyhost3"])
condition = random.choice(["serverRestart", "lineStatus"])
severity = random.choice([1,5])
evt = Event(hostname, condition, severity)
  1. 如果在演示模式下,打印出事件,如下所示:
if demo_mode:
now = time.strftime("%a, %d %b %Y %H:%M:%S +0000",
time.localtime())
print "%s: Sending out %s" % (now, evt)
  1. 如果不是在演示模式下,通过代理进行远程调用到网络应用程序的处理方法,如下所示:
else:
stored_event, is_active, updated_services,
updated_equipment = proxy.process(evt)
print "Stored event: %s" % stored_event
print "Active? %s" % is_active
print "Services updated: %s" % updated_services
print "Equipment updated; %s" % updated_equipment
print "================"
  1. 在重复循环之前休眠一段时间,使用这行代码:
time.sleep(1.0/float(rate))
  1. 运行生成器脚本。在下面的屏幕截图中,请注意出现了错误,因为我们还没有启动服务器进程。如果客户端和服务器的 URL 不匹配,也会出现这种情况:

  1. 创建一个名为recipe63_server.py的服务器脚本,该脚本将运行我们的网络管理应用程序,使用定位测试服务器食谱中显示的recipe62_network.sql SQL 脚本连接到 MySQL。
from springpython.database.factory import *
from springpython.database.core import *
from springpython.remoting.pyro import *
from network import *
import logging
logger = logging.getLogger("springpython")
loggingLevel = logging.DEBUG
logger.setLevel(loggingLevel)
ch = logging.StreamHandler()
ch.setLevel(loggingLevel)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s -
%(message)s")
ch.setFormatter(formatter)
logger.addHandler(ch)
# Initialize the database
factory = MySQLConnectionFactory("user", "password",
"localhost", "recipe63")
dt = DatabaseTemplate(factory)
sql = open("recipe62_network.mysql").read().split(";")
for statement in sql:
dt.execute(statement + ";")
  1. 添加代码以使用 Pyro 公开应用程序,如下所示:
# Create an instance of the network management app
target_service = EventCorrelator(factory)
# Expose the network app as a Pyro service
exporter = PyroServiceExporter()
exporter.service_name = "network"
exporter.service = target_service
exporter.after_properties_set()
  1. 在不同的 shell 中运行服务器脚本:

  1. 默认速率为每秒 10 个事件。以每秒一个事件的速率运行生成器脚本。在下面的截图中,注意脚本生成了一个清晰的故障,然后又一个故障。服务从运行状态转移到故障状态,并保持在那里:

它是如何工作的...

Python 的random.choice方法使得创建一系列随机事件变得容易。通过使用time.sleep方法,我们可以控制事件创建的速率。

我们使用 Pyro 将测试生成器连接到网络管理应用程序。这不是连接事物的唯一方式。我们可以通过其他方式暴露应用程序,比如 REST、JSON,或者通过数据库表进行通信。这并不重要。重要的是我们建立了一个独立的工具,将数据输入到我们的应用程序中,就好像它来自于一个真实的网络。

还有更多...

我们建立了一个测试生成器。在不同的 shell 中以不同的速率运行多个副本很容易。我们有一种简单的方法来模拟不同子网产生不同量的流量。

我们还可以添加更多的命令行选项来微调事件。例如,我们可以将事件条件作为参数,并模拟不同类型事件的不同速率。

为什么服务器脚本要初始化数据库?

生产版本的服务器不会这样做。对于这个配方的演示目的,把它放在那里很方便。每次我们停止和启动服务器脚本时,它都会重新启动数据库。

为什么使用 MySQL 而不是 SQLite?

SQLite 在多线程方面有一些限制。Pyro 使用多线程,而 SQLite 无法在线程之间传递对象。SQLite 也相对轻量,并且可能不适合真正的网络管理应用程序。

另请参阅

针对测试服务器配方

实时录制和播放数据

没有什么比真实的生产数据更好。通过这个配方,我们将编写一些代码来记录实时数据。然后我们将播放它,并添加延迟以模拟播放实时数据流。

准备工作

让我们看看以下步骤:

  1. 确保 MySQL 生产数据库服务器正在运行。

  2. 以 root 用户身份打开命令行 MySQL 客户端 shell。

  3. 为这个配方创建一个名为recipe64的数据库,以及一个具有访问权限的用户。

  4. 在这里退出 shell:

如何做...

通过这些步骤,我们将看到如何以实时速度记录和播放数据:

  1. 编写一个名为recipe64_livedata.py的脚本,模拟每一到十秒发送一次实时数据,如下所示:
import random
import sys
import time
from network import *
from springpython.remoting.pyro import *print "Sending events to live network app. Ctrl+C to exit..."
proxy = PyroProxyFactory()
proxy.service_url = "PYROLOC://127.0.0.1:7766/network_advised"
while True:
hostname = random.choice(["pyhost1","pyhost2","pyhost3"])
condition = random.choice(["serverRestart", "lineStatus"])
severity = random.choice([1,5])
evt = Event(hostname, condition, severity)
stored_event, is_active, updated_services,
updated_equipment = proxy.process(evt)
print "Stored event: %s" % stored_event
print "Active? %s" % is_active
print "Services updated: %s" % updated_services
print "Equipment updated; %s" % updated_equipment
print "================"
time.sleep(random.choice(range(1,10)))
  1. 编写一个名为recipe64_server.py的服务器脚本,使用 SQL 脚本recipe62_network.mysql来初始化数据库,如下所示:
from springpython.database.factory import *
from springpython.database.core import *
from springpython.remoting.pyro import *
from springpython.aop import *
from network import *
from datetime import datetime
import os
import os.path
import pickle
import logging
logger = logging.getLogger("springpython.remoting")
loggingLevel = logging.DEBUG
logger.setLevel(loggingLevel)
ch = logging.StreamHandler()
ch.setLevel(loggingLevel)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s -
%(message)s")
ch.setFormatter(formatter)
logger.addHandler(ch)
# Initialize the database
factory = MySQLConnectionFactory("user", "password",
"localhost", "recipe64")
dt = DatabaseTemplate(factory)
sql = open("recipe62_network.mysql").read().split(";")
for statement in sql:
dt.execute(statement + ";")
  1. 添加一些代码,创建网络管理应用程序的实例,并使用 Pyro 和 Spring Python 进行广告发布,如下所示:
# Create an instance of the network management app
target_service = EventCorrelator(factory)
# Expose the original network app as a Pyro service
unadvised_service = PyroServiceExporter()
unadvised_service.service_name = "network"
unadvised_service.service = target_service
unadvised_service.after_properties_set()
  1. 添加一些代码,定义一个拦截器,将传入的事件数据和时间戳捕获到磁盘上,如下所示:
class Recorder(MethodInterceptor):
"""
An interceptor that catches each event,
write it to disk, then proceeds to the
network management app.
"""
def __init__(self):
self.filename = "recipe64_data.txt"
self.special_char = "&&&"
if os.path.exists(self.filename):
os.remove(self.filename)
def invoke(self, invocation):
# Write data to disk
with open(self.filename, "a") as f:
evt = invocation.args[0]
now = datetime.now()
output = (evt, now)
print "Recording %s" % evt
f.write(pickle.dumps(output).replace("n", "&&&") + "n")
# Now call the advised service
return invocation.proceed()
  1. 添加一些代码,使用拦截器包装网络管理应用程序,并使用 Pyro 进行广告发布,如下所示:
# Wrap the network app with an interceptor
advisor = ProxyFactoryObject()
advisor.target = target_service
advisor.interceptors = [Recorder()]
# Expose the advised network app as a Pyro service
advised_service = PyroServiceExporter()
advised_service.service_name = "network_advised"
advised_service.service = advisor
advised_service.after_properties_set()
  1. 通过输入python recipe64_server.py启动服务器应用程序。请注意以下截图中 Pyro 注册了network服务和network_advised服务:

通过输入python recipe64_livedata.py运行实时数据模拟器,直到生成一些事件,然后按 Ctrl+C 退出:

  1. 看看服务器端的情况,并注意它记录了几个事件:

  1. 检查recipe64_data.txt数据文件,注意每行代表一个单独的事件和时间戳。虽然很难解读封存格式中存储的数据,但仍然有可能发现一些片段。

  2. 创建一个名为recipe64_playback.py的脚本,反封存数据文件的每一行,如下所示:

from springpython.remoting.pyro import *
from datetime import datetime
import pickle
import time
with open("recipe64_data.txt") as f:
lines = f.readlines()
events = [pickle.loads(line.replace("&&&", "n"))
for line in lines]
  1. 添加一个函数,找到当前事件和上一个事件之间的时间间隔,如下所示:
def calc_offset(evt, time_it_happened, previous_time):
if previous_time is None:
return time_it_happened - time_it_happened
else:
return time_it_happened - previous_time
  1. 定义一个客户端代理,连接到我们网络管理应用程序的未建议接口,如下所示:
print "Sending events to live network app. Ctrl+C to exit..."
proxy = PyroProxyFactory()
proxy.service_url = "PYROLOC://127.0.0.1:7766/network"
  1. 添加代码,迭代每个事件,计算差异,然后延迟下一个事件那么多秒,如下所示:
previous_time = None
for (e, time_it_happened) in events:
diff = calc_offset(e, time_it_happened, previous_time)
print "Original: %s Now: %s" % (time_it_happened, datetime.now())
stored_event, is_active, updated_services,
updated_equipment = proxy.process(e)
print "Stored event: %s" % stored_event
print "Active? %s" % is_active
print "Services updated: %s" % updated_services
print "Equipment updated; %s" % updated_equipment
print "Next event in %s seconds" % diff.seconds
print "================"
time.sleep(diff.seconds)
previous_time = time_it_happened
  1. 通过输入python recipe64_playback.py来运行播放脚本,并观察它与原始实时数据模拟器具有相同的延迟:

它是如何工作的...

通常,我们会记录来自实时网络的数据。在这种情况下,我们需要一个生成随机数据的模拟器。我们在这个配方中编写的模拟器与编写数据模拟器配方中显示的模拟器非常相似。

为了捕获数据,我们编写了一个拦截器,嵌入在 Pyro 和网络管理应用程序之间。发布到network_advised Pyro 服务名称的每个事件都无缝地通过这个拦截器。考虑以下内容:

  1. 每个进来的事件都被追加到拦截器首次创建时初始化的数据文件中。

  2. 事件还与datetime.now()的副本一起存储,以捕获时间戳。

  3. 事件和时间戳被合并成一个元组并被封存,这样可以轻松地写入并稍后从磁盘读取。

  4. 数据被封存以便于在磁盘上进行传输。

  5. 将其写入磁盘后,拦截器调用目标服务,并将结果传递回原始调用者。

最后,我们有一个播放脚本,它读取数据文件,每行一个事件。它将每行反封存成最初存储的元组格式,并构建事件列表。

然后逐个扫描事件列表。通过将当前事件的时间戳与上一个事件进行比较,计算出秒数差,使用 Python 的time.sleep()方法以相同的速率播放事件。

播放脚本使用 Pyro 将事件发送到网络管理应用程序。但它与不同的暴露点进行通信。这是为了避免重新记录相同的事件。

还有更多...

这个配方中的代码使用 Pyro 作为连接客户端和服务器在发布/订阅范式中通信的机制。这不是构建这样一个服务的唯一方法。Python 也内置了 XML-RPC。只是它不像 Pyro 那样灵活。需要更彻底的实际流量分析来确定这个接口是否足够好。替代方案包括通过数据库 EVENT 表推送事件,客户端插入行,服务器轮询表格以获取新行,然后在消耗时删除它们。

这个配方还大量使用 Spring Python 的面向方面的编程功能来插入数据记录代码(static.springsource.org/spring-python/1.1.x/reference/html/aop.html)。这提供了一种干净的方式来添加我们需要的额外功能层,以便在不必触及已构建的网络管理代码的情况下,嗅探和记录网络流量。

我以为这个配方是关于实时数据的!

嗯,这个配方更多地是关于记录实时数据和控制回放速度。为了在一个可重复使用的配方中捕捉这个概念,需要模拟实时系统。但在网络管理处理器前插入一个监听点的基本概念,就像我们所做的那样,同样有效。

为每个事件打开和关闭文件是一个好主意吗?

编写该配方是为了确保停止记录不会造成损失尚未写入磁盘的捕获数据的最小风险。需要分析生产数据以确定存储数据的最有效方式。例如,批量写入数据可能需要较少的 I/O 强度,也许是 10 个或者 100 个事件的批量。但风险在于数据可能会在类似的捆绑中丢失。

如果流量量足够低,按照本配方中所示,逐个写入每个事件可能根本不是问题。

关于数据存储的卸载呢?

通常情况下,打开文件、追加数据,然后关闭文件的实际逻辑包含在一个单独的类中。然后可以将此实用程序注入到我们构建的拦截器中。如果需要一些更复杂的方式来存储或传输数据,这可能变得很重要。例如,另一个 Pyro 服务可能存在于另一个位置,希望获得实时数据源的副本。

将数据消费者注入我们编写的方面将使我们更加灵活。在这个配方中,我们没有这样的要求,但很容易想象在新要求到来时进行这样的调整。

另请参阅

  • 写一个数据模拟器的配方

  • 尽可能快地记录和回放实时数据的配方

记录和尽可能快地回放实时数据

尽可能快地重放生产数据(而不是实时),可以让您了解瓶颈在哪里。

准备工作

  1. 确保 MySQL 生产数据库服务器正常运行。

  2. 以 root 用户身份打开命令行 MySQL 客户端 shell。

  3. 为这个配方创建一个名为recipe65的数据库,以及一个具有访问权限的用户。

  4. 退出 shell,如下所示:

如何做...

在这些步骤中,我们将编写一些代码,让我们的系统承受重大负载:

  1. 编写一个名为recipe65_livedata.py的脚本,模拟每一到十秒发送一次实时数据,如下所示:
import random
import sys
import time
from network import *
from springpython.remoting.pyro import *
print "Sending events to live network app. Ctrl+C to exit..."
proxy = PyroProxyFactory()
proxy.service_url = "PYROLOC://127.0.0.1:7766/network_advised"
while True:
hostname = random.choice(["pyhost1","pyhost2","pyhost3"])
condition = random.choice(["serverRestart", "lineStatus"])
severity = random.choice([1,5])
evt = Event(hostname, condition, severity)
stored_event, is_active, updated_services,
updated_equipment = proxy.process(evt)
print "Stored event: %s" % stored_event
print "Active? %s" % is_active
print "Services updated: %s" % updated_services
print "Equipment updated; %s" % updated_equipment
print "================"
time.sleep(random.choice(range(1,10)))
  1. 编写一个名为recipe65_server.py的服务器脚本,使用 SQL 脚本recipe62_network.mysql来初始化数据库,如下所示:
from springpython.database.factory import *
from springpython.database.core import *
from springpython.remoting.pyro import *
from springpython.aop import *
from network import *
from datetime import datetime
import os
import os.path
import pickle
import logging
logger = logging.getLogger("springpython.remoting")
loggingLevel = logging.DEBUG
logger.setLevel(loggingLevel)
ch = logging.StreamHandler()
ch.setLevel(loggingLevel)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s -%(message)s")
ch.setFormatter(formatter)
logger.addHandler(ch)
# Initialize the database
factory = MySQLConnectionFactory("user", "password",
"localhost", "recipe65")
dt = DatabaseTemplate(factory)
sql = open("recipe62_network.mysql").read().split(";")
for statement in sql:
dt.execute(statement + ";")
  1. 添加一些代码,创建网络管理应用程序的实例,并使用 Pyro 和 Spring Python 进行广告发布,如下所示:
# Create an instance of the network management app
target_service = EventCorrelator(factory)
# Expose the original network app as a Pyro service
unadvised_service = PyroServiceExporter()
unadvised_service.service_name = "network"
unadvised_service.service = target_service
unadvised_service.after_properties_set()
  1. 添加一些更多的代码,定义一个拦截器,捕获磁盘上的传入事件数据以及时间戳,如下所示:
class Recorder(MethodInterceptor):
"""
An interceptor that catches each event,
write it to disk, then proceeds to the
network management app.
"""
def __init__(self):
self.filename = "recipe65_data.txt"
self.special_char = "&&&"
if os.path.exists(self.filename):
os.remove(self.filename)
def invoke(self, invocation):
# Write data to disk
with open(self.filename, "a") as f:
evt = invocation.args[0]
now = datetime.now()
output = (evt, now)
print "Recording %s" % evt
f.write(pickle.dumps(output).replace(
"n", "&&&") + "n")
# Now call the advised service
return invocation.proceed()
  1. 添加一些代码,用拦截器包装网络管理应用程序,并使用 Pyro 进行广告发布,如下所示:
# Wrap the network app with an interceptor
advisor = ProxyFactoryObject()
advisor.target = target_service
advisor.interceptors = [Recorder()]
# Expose the advised network app as a Pyro service
advised_service = PyroServiceExporter()
advised_service.service_name = "network_advised"
advised_service.service = advisor
advised_service.after_properties_set()
  1. 通过输入python recipe65_server.py来启动服务器应用程序。在下面的屏幕截图中,请注意 Pyro 注册了network服务和network_advised服务:

  1. 通过输入python recipe65_livedata.py来运行实时数据模拟器,并观察它运行直到生成一些事件,然后按下Ctrl+C来中断它:

  1. 查看服务器端的情况,并注意它记录了几个事件:

  1. 检查recipe65_data.txt数据文件,注意每行代表一个单独的事件和时间戳。虽然很难解密以 pickled 格式存储的数据,但可能会发现一些片段。

  2. 创建一个名为recipe65_playback.py的回放脚本,解 pickle 化数据文件的每一行,如下所示:

from springpython.remoting.pyro import *
from datetime import datetime
import pickle
import time
with open("recipe65_data.txt") as f:
lines = f.readlines()
events = [pickle.loads(line.replace("&&&", "n"))
for line in lines]
  1. 定义一个客户端代理,连接到我们网络管理应用程序的未建议接口,如下所示:
print "Sending events to live network app. Ctrl+C to exit..."
proxy = PyroProxyFactory()
proxy.service_url = "PYROLOC://127.0.0.1:7766/network"

添加代码,迭代每个事件,尽可能快地回放事件,如下所示:

for (e, time_it_happened) in events:
stored_event, is_active, updated_services,
updated_equipment = proxy.process(e))
print "Stored event: %s" % stored_event
print "Active? %s" % is_active
print "Services updated: %s" % updated_services
print "Equipment updated; %s" % updated_equipment
print "================"
  1. 通过输入python recipe65_playback.py来运行播放脚本,观察它不会延迟事件,而是尽可能快地回放事件:

工作原理...

通常,我们会记录来自实时网络的数据。在这种情况下,我们需要一个生成随机数据的模拟器。我们在这个方法中编写的模拟器与编写数据模拟器方法中显示的模拟器非常相似。

为了捕获数据,我们编写了一个拦截器,它嵌入在 Pyro 和网络管理应用程序之间。每个发布到network_advised Pyro 服务名称的事件都无缝地通过这个拦截器。考虑以下情况:

  • 每个进来的事件都被追加到拦截器首次创建时初始化的数据文件中。

  • 事件还存储了datetime.now()的副本以捕获时间戳。

  • 事件和时间戳被合并成一个元组,并被序列化,这样可以轻松地写入和稍后从磁盘中读取。

  • 数据被序列化以便于在磁盘上传输。

  • 将其写入磁盘后,拦截器调用目标服务,并将结果传递回原始调用者。

最后,我们有一个回放脚本,它读取数据文件,每行一个事件。它将每一行反序列化成最初存储的元组格式,并构建一个事件列表。

然后逐个扫描事件列表。不是通过评估时间戳来确定延迟播放事件的时间,而是立即将其注入到网络管理应用程序中。

回放脚本使用 Pyro 将事件发送到网络管理应用程序,但它与不同的暴露点进行通信。这是为了避免重新记录相同的事件。

还有更多...

这个方法中的代码使用 Pyro 作为连接客户端和服务器的机制,以发布/订阅范式进行通信。这并不是构建这样一个服务的唯一方式。Python 也内置了 XML-RPC。只是它没有 Pyro 灵活。需要更彻底的实时流量分析来确定这个接口是否足够好。替代方案包括通过数据库 EVENT 表推送事件,其中客户端插入行,服务器轮询表以获取新行,然后在消耗后删除它们。

这个方法还大量使用了 Spring Python 的面向方面的编程功能来插入数据记录代码(static.springsource.org/spring-python/1.1.x/reference/html/aop.html)。这提供了一种清晰的方式来添加我们需要的额外功能层,以便在不触及现有网络管理代码的情况下嗅探和记录网络流量。

这与实时回放有什么不同?

实时回放对于查看系统如何处理生产负载很有用。但这并不能回答系统预计会在哪里出现问题的问题。流量永远不是稳定的。相反,它经常有意外的突发情况。这时以加速的速率回放实时数据将有助于暴露系统的下一个瓶颈。

预先解决一些这些问题将使我们的系统更具弹性。

这个应用程序的瓶颈在哪里?

诚然,当我们以最快的速度回放四个事件时,这个方法并没有崩溃。在生产中会得到相同的结果吗?事情以不同的方式崩溃。我们可能不会得到真正的异常或错误消息,而是发现系统的某些部分变得积压。

这就是这个方法的极限所在。虽然我们已经演示了如何通过大量的流量来过载系统,但我们没有展示如何监视瓶颈所在。

如果负载下的应用程序使用数据库表来排队工作,那么我们需要编写监视它们并报告以下情况的代码:

  • 哪一个是最长的

  • 哪一个正在变得更长,并且没有显示追赶的迹象

  • 哪一个是活动管道中最早的

在具有处理阶段的系统中,通常会出现一个明显的瓶颈。当解决了该瓶颈时,很少是唯一的瓶颈。它只是最关键的瓶颈或者是链中的第一个瓶颈。

此外,这个配方不能解决你的瓶颈问题。这个配方的目的是找到它。

我曾经构建过一个非常类似的网络负载测试工具。代码可以并行处理大量的流量,但来自同一设备的事件必须按顺序处理。一次性重放一天的事件暴露了这样一个事实,即来自同一设备的太多事件会导致整个队列系统过载并使其他设备的处理受阻。改进服务更新算法后,我们能够重放相同的负载测试并验证它能够跟上。这有助于避免在非工作时间或周末发生的不可重现的故障。

应该收集多少实时数据?

捕获诸如一天的交通量块之类的东西是很有用的,这样可以让整天的事件都被回放。另一个可能性是整整一周。实时系统可能在周末和工作日有不同的负载,一周的数据将有助于更好地调查。

这么多数据的问题在于很难挑选一个窗口进行调查。这就是为什么周末的 24 小时数据和工作日的 24 小时数据可能更实际。

如果存在某种网络不稳定性,导致大规模的故障并引起大量的流量,打开收集器并等待另一个类似的故障发生可能是有用的。在发生这样的故障之后,可能有必要浏览数据文件并将其修剪到流量增加的位置。

这些类型的捕获场景在加载测试新版本时非常有价值,因为它们确认新补丁是否像预期的那样提高了性能,或者至少在修复非性能问题时没有降低性能。

另请参阅

  • 编写数据模拟器配方

  • 实时录制和播放数据配方

自动化您的管理演示

有演示吗?编写模拟执行你将要执行的步骤的自动化测试。然后打印出你的测试套件,并像脚本一样使用它。

如何做...

通过这些步骤,我们将看到如何以可运行的方式编写我们的管理演示脚本:

  1. 创建一个名为recipe66.py的新文件,用于我们管理演示的测试代码。

  2. 创建一个unittest测试场景来捕捉你的演示。

  3. 编写一系列操作,就好像你正在从这个自动化测试中驱动应用程序。

  4. 在演示期间的每个点都包含断言。看一下这段代码:

import unittest
from network import *
from springpython.database.factory import *
class ManagementDemo(unittest.TestCase):
def setUp(self):
factory = MySQLConnectionFactory("user", "password",
"localhost", "recipe62")
self.correlator = EventCorrelator(factory)
dt = DatabaseTemplate(factory)
sql = open("recipe62_network.mysql").read().split(";")
for statement in sql:
dt.execute(statement + ";")
def test_processing_a_service_affecting_event(self):
# Define a service-affecting event
evt1 = Event("pyhost1", "serverRestart", 5)
# Inject it into the system
stored_event, is_active,
updated_services, updated_equipment =
self.correlator.process(evt1)
# These are the values I plan to call
# attention to during my demo
self.assertEquals(len(updated_services), 1)
self.assertEquals("service-abc",
updated_services[0]["service"]["NAME"])
self.assertEquals("Outage",
updated_services[0]["service"]["STATUS"])
if __name__ == "__main__":
unittest.main()
  1. 通过输入python recipe66.py来运行测试套件:

它是如何工作的...

这个配方更多的是哲学性的,而不是基于代码的。虽然这个配方的概念很有价值,但很难用一个可重复使用的代码片段来捕捉。

在这个测试用例中,我注入一个事件,处理它,然后确认它的影响。这个测试用例是无头的,但我们的演示可能不会是。到目前为止,在本章中,我们还没有构建任何用户屏幕。随着我们开发用户屏幕,我们需要确保它们调用与这个自动化测试相同的 API。

鉴于此,我们设置使用屏幕来定义测试中显示的相同事件。事件被消化后,可能会存在另一个屏幕显示当前的服务状态。我们期望它能反映对故障的更新。

在我们的管理演示中,我们将指出/放大屏幕的这一部分,并展示service-abc运行切换到故障

如果屏幕被构建为委托给底层逻辑,那么屏幕逻辑只不过是组件组合在一起显示信息。被测试的核心逻辑保持其无头和易于测试的特性。

我们的代码示例并不完整,也不会超过一分钟的演示。但这个概念是正确的。通过以可运行的形式捕捉我们计划在演示中执行的步骤,我们的管理演示应该会顺利进行。

我说了没有问题吗?嗯,演示很少能够那么顺利。管理层的出现难道不会导致问题出现吗?有一次,我提前一个月准备了一次高级管理演示,使用了这个秘诀。我发现并随后修复了几个错误,以至于我的演示完美无缺。管理层印象深刻。我在这里并不做任何承诺,但真诚地让你的演示 100%可运行将极大地增加你的成功几率。

还有更多...

这个秘诀是什么?它似乎有点缺少代码。虽然让演示 100%可运行很重要,但关键是打印出测试并像脚本一样使用它。这样,你所采取的步骤都已经被证明有效。

如果我的经理喜欢绕道走?

如果你的经理喜欢问很多假设性问题,让你偏离原计划,那么你就是在未知的领域航行。你成功进行演示的几率可能会迅速下降。

你可以客气地推迟他们的假设问题,留到未来的演示中再试,努力保持当前的演示在正轨上。如果你冒险尝试其他事情,要意识到你所承担的风险。

不要害怕承诺未来的演示,届时你将按照要求的路径前行,而不是在这次演示中冒险。经理们实际上相当愿意接受这样的回答:我还没有测试过那个。下个月我们再做一个演示,包括那个内容,怎么样?失败的演示会给管理层留下不好的印象,并危及你的声誉。成功的演示对你作为开发者的声誉也会产生同样积极的影响。管理层倾向于更乐观地看待系统 70%成功 100%,而不是系统 100%成功 70%。

这就是工程师和经理之间需要遵守的界限。虽然经理们想要看到现有的东西,但我们的工作是向他们展示目前正在运行的东西,并准确地报告目前可用和不可用的情况。要求看到我们尚未测试过的东西绝对值得反驳,并告诉他们这样的演示还没有准备好。

第九章:新系统和旧系统的良好测试习惯

在本章中,我们将涵盖以下主题:

  • 有总比没有好

  • 覆盖率并不是一切

  • 愿意投资于文本夹具

  • 如果你对测试的价值不太确信,你的团队也不会确信

  • 收集指标

  • 在自动化测试中捕获错误

  • 将算法与并发分开

  • 当测试套件运行时间过长时,暂停重构。

  • 充分利用你的信心

  • 愿意放弃一整天的更改

  • 与其追求 100%的覆盖率,不如试图实现稳定的增长

  • 随机地打破你的应用程序可能会导致更好的代码

介绍

希望你喜欢本书的前几章。到目前为止,我们已经探讨了许多自动化测试领域,比如:

  • 单元测试

  • Nose 测试

  • Doctest 测试

  • 行为驱动开发

  • 验收测试

  • 持续集成

  • 冒烟和负载测试

在本章中,我们将做一些不同的事情。与其为各种技巧提供大量代码示例,我更想分享一些我作为软件工程师在职业生涯中学到的想法。

本书中以前的所有示例都非常详细地介绍了如何编写代码、运行代码并审查其结果。希望你已经接受了这些想法,对其进行了扩展、改进,并最终应用到解决自己的软件问题中。

在本章中,让我们探讨一些关于测试的更大的想法,以及它们如何增强我们开发质量系统的能力。

有总比没有好

不要陷入纯粹的隔离或担心晦涩的测试方法。首先要做的是开始测试。

如何做...

你刚刚接手了一个由其他人开发的应用程序,而这些人已经不在你公司了。以前有过这种经历吗?我们都有过,可能不止一次。我们能预测一些常见的症状吗?嗯,它们可能类似于这些:

  • 几乎没有(如果有的话)自动化测试。

  • 几乎没有文档。

  • 有一些代码块已经被注释掉了。

  • 代码中要么没有注释,要么有些注释是很久以前写的,现在已经不正确了。

而这就是有趣的部分——我们一开始并不知道所有这些问题。我们基本上是被告知在源代码树中检查,并开始工作。例如,只有当我们遇到问题并寻求文档时,我们才会发现存在(或不存在)什么。

也许我没有捕捉到你在列表中遇到的所有问题,但我敢打赌你经历了其中很多。我不想听起来像一个怨恨的软件开发者,因为我不是。并非每个项目都是这样。但我相信我们都曾经不得不处理这种情况。那么,我们该怎么办?我们开始测试。

但魔鬼在细节中。我们写单元测试吗?线程测试呢?还是集成测试?你知道吗?其实,我们写什么类型的测试并不重要。事实上,使用正确的名称也并不重要。

当你独自一人坐在小隔间里和代码对峙时,术语并不重要。重要的是编写测试。如果你能挑出一个小的代码单元并编写一个测试,那就去做吧!但如果你拿到了一堆混乱的代码,而这些代码并没有很好地隔离单元,怎么办呢?

考虑一个系统,最小的单元是一个模块,用于解析电子文件,然后将解析结果存储在数据库中。解析结果不会通过 API 返回。它们只是悄悄地、神秘地出现在数据库中。我们如何自动化呢?嗯,我们可以做以下几件事:

  1. 编写一个测试,首先清空与应用程序相关的所有表。

  2. 找到一个用户拥有这些文件之一,并获取一份副本。

  3. 向测试中添加调用顶层 API 来摄取文件的代码。

  4. 添加一些从数据库中提取数据并检查结果的代码。(你可能需要获取那个用户,以确保代码正常工作。)

恭喜!你刚刚编写了一个自动化测试!它可能不符合单元测试的标准。事实上,它可能对你来说看起来有点丑陋,但又怎样呢?也许它需要五分钟才能运行,但这难道不比没有测试好吗?

它是如何工作的…

由于数据库是我们可以断言结果的地方,我们需要在每次运行测试之前清理出一个经过清理的版本。如果其他开发人员正在使用相同的表,这肯定需要协调。我们可能需要分配给我们自己的模式,这样我们就可以随意清空表格。

模块可能受到缺乏凝聚力和过于紧密耦合的影响。虽然我们可以尝试确定代码为什么不好,但这并不会推动我们构建自动化测试的事业。

相反,我们必须认识到,如果我们试图立即跳入单元级别的测试,我们将不得不重构模块以支持我们。没有或很少的安全网,风险是非常高的,我们可以感受到!如果我们试图坚持教科书上的单元测试,那么我们可能会放弃,并认为自动化测试是不可能的。

因此,我们必须迈出第一步,编写一个昂贵的、端到端的自动化测试来构建链条的第一环。这个测试可能需要很长时间才能运行,而且可能在我们可以断言的内容上并不是非常全面,但这是一个开始,这才是重要的。希望在稳步取得进展编写更多这样的测试之后,我们将建立一个安全网,这将防止我们不得不回头重构这段代码。

这还不是全部!

只是编写测试听起来有点太简单了吗?嗯,概念很简单,但工作会很艰难——非常艰难。

你将被迫爬行通过大量的 API,并确切地找出它们是如何工作的。而且,猜猜看?你可能不会得到很多中间结果来断言。理解 API 只是为了让你追踪数据的流向。

当我描述我们的情况的数据神秘地最终出现在数据库中时,我指的是你可能的 API 并不是为了可测试性而设计的大量返回值。

只是不要让任何人告诉你,你在构建长时间运行的测试用例上浪费时间。一个需要一个小时才能运行一次,并且每天至少运行一次的自动化测试套件,可能比手动点击屏幕更能增强信心。有总比没有好。

另请参阅

  • 充分利用你的信心的配方

覆盖率并不是一切

你已经知道如何运行覆盖率报告了。然而,不要认为覆盖率越高就越好。以覆盖率为名牺牲测试质量是失败的开始。

如何做到…

覆盖率报告提供了很好的反馈。它们告诉我们哪些被执行了,哪些没有。但是,仅仅因为一行代码被执行,并不意味着它做了它应该做的一切。

你是否曾经想要在休息室里吹嘘覆盖率得分?为良好的覆盖率感到自豪并不是没有根据的,但当它导致使用这些统计数据来比较不同的项目时,我们就会涉足风险领域。

它是如何工作的…

覆盖率报告是要在运行的代码的上下文中阅读的。报告告诉我们什么被覆盖了,什么没有被覆盖,但这并不是结束的地方。相反,这才是开始的地方。我们需要看看什么被覆盖了,并分析测试对系统的执行程度。

很明显,模块的覆盖率为 0%表示我们有工作要做。但是当我们的覆盖率达到 70%时,这意味着什么呢?我们需要编写测试来覆盖其他 30%吗?当然需要!但是对于如何解决这个问题,有两种不同的思路。一种是正确的,一种是错误的。

  • 第一种方法是编写新的测试,专门针对未覆盖的部分,同时尽量避免重叠原始 70%。对已经在另一个测试中覆盖的代码进行冗余测试是资源的低效使用。

  • 第二种方法是编写新的测试,以便它们针对代码预期处理的场景,但我们尚未解决。未被覆盖的部分应该给我们一个提示,表明哪些场景尚未被测试。

正确的方法是第二种。好吧,我承认我以引导的方式写了这个。但关键是很容易看到未被覆盖的部分,并编写一个旨在尽快弥补差距的测试。

还有更多...

Python 给了我们令人难以置信的能力来进行 monkey patch,注入替代方法,以及其他技巧来执行未覆盖的代码。但这听起来不是有点可疑吗?以下是我们为自己设置的一些风险:

  • 当新的测试不基于合理的场景时,可能会更加脆弱。

  • 对我们的算法进行重大更改可能需要我们完全重写这些测试。

  • 曾经写过基于模拟的测试吗?可能会将目标系统模拟得无影无踪,最终只是测试模拟。

  • 即使我们的一些(甚至大多数)测试质量很好,低质量的测试也会使整个测试套件的质量变低。

如果我们做一些干扰行计数机制的事情,覆盖工具可能不会让我们逃脱一些策略。但无论覆盖工具是否计算代码,都不应该是我们确定测试质量的标准。

相反,我们需要查看我们的测试,看看它们是否试图执行我们应该处理的真实用例。当我们仅仅寻求获得更高的覆盖率百分比时,我们停止了思考我们的代码应该如何运行,这是不好的。

我们不应该增加覆盖率吗?

我们应该通过改进我们的测试、覆盖更多的场景和删除不再支持的代码来增加覆盖率。这些都将使我们朝着整体更好的质量迈进。仅仅为了覆盖率而增加覆盖率并不能提高我们系统的质量。

但我想要夸耀一下我的系统覆盖率!

我认为庆祝良好的覆盖率是可以的。与经理分享覆盖率报告是可以的。但不要让它消耗你。

如果你开始每周发布覆盖率报告,请仔细检查你的动机。如果你的经理也要求发布报告,也是一样。

如果你发现自己在比较你的系统覆盖率和另一个系统的覆盖率,那就要小心了!除非你熟悉两个系统的代码,并且真正了解报告的底线以上的内容,否则你可能会陷入风险区域。你可能会陷入可能导致团队编写脆弱测试的错误竞争中。

愿意投资于测试装置

花时间研究一些测试装置。起初可能写不了很多测试,但这项投资将会有回报。

如何做....

当我们开始构建一个新的绿地项目时,编写面向测试的模块会更容易,但在处理遗留系统时,构建一个有效的测试装置可能需要更多的时间。这可能会很艰难,但这是一项有价值的投资。

例如,在“有总比没有好”的部分,我们谈到了一个扫描电子文件并将解析结果放入数据库表的系统。我们的测试装置需要哪些步骤?也许我们应该考虑以下问题:

  • 设置步骤来清理适当的表格。

  • 很可能,我们可能需要使用代码或脚本来创建一个新的数据库模式,以避免与其他开发人员发生冲突。

  • 可能需要将文件放在特定位置,以便解析器能够找到它。

这些都是需要时间来构建一个有效的测试用例的步骤。更复杂的遗留系统可能需要更多的步骤来准备进行测试。

它是如何工作的...

所有这些可能会让我们感到害怕,并可能推动我们放弃自动化测试,只是继续通过屏幕点击来验证事物。但是,花时间投资编写这个测试装置将在我们编写更多使用我们的测试装置的测试用例时开始产生回报。

你有没有建立过一个测试装置,然后不得不为特定情况进行修改?在使用我们的测试装置开发足够的测试用例之后,我们可能会遇到另一个需要测试的用例,超出了我们测试装置的限制。由于我们现在对它很熟悉,创建另一个测试装置可能会更容易。

这是编写第一个测试装置的另一种回报方式。未来的测试装置很有可能更容易编写。然而,这并不是改进的一种明确保证。通常,我们的测试装置的第一个变体是一个简单的。

还有更多...

我们可能会遇到需要另一个与我们构建的完全不同的测试装置的情况。在这一点上,对第一个测试装置的投资并没有同样的回报。但是,到那时,我们将成为更有经验的测试编写者,并且对于测试系统时什么有效、什么无效有更好的把握。

到目前为止所做的所有工作都将锻炼我们的技能,并且这本身就是对测试装置投资的巨大回报。

这只是关于设置数据库吗?

这不仅仅是关于设置数据库。如果我们的系统与 LDAP 服务器有广泛的交互,我们可能需要编写一个清理目录结构并加载测试数据的测试装置。

如果传统系统足够灵活,我们可以将整个测试结构放入层次结构中的一个子节点。但同样可能的是,它期望数据存在于特定位置。在这种情况下,我们可能需要开发一个脚本,启动一个单独的空的 LDAP 服务器,然后在测试完成后关闭它。

设置和关闭 LDAP 服务器可能不是最快、也不是最有效的测试装置。但是,如果我们投入时间来构建这个测试装置,以便自己编写自动化测试,最终我们将能够重构原始系统,使其与实时 LDAP 服务器解耦。整个过程将锻炼我们的技能。这就是为什么创建原始测试装置真的是一种投资。

如果你对测试的价值不太确信,你的团队也不会。

受测试影响的开发人员表现出热情;他们很高兴运行他们的测试套件并看到结果。

完全成功。这种情感和自豪感往往会感染其他开发人员。

但反之亦然。如果你对这一切不感到兴奋,也不传播这个想法,你的队友也不会。向你的系统添加自动化测试的想法将会悲惨地消失。

这不仅仅局限于我的个人经验。在 2010 年的 DevLink 大会上,我参加了一个关于测试的开放空间讨论,并在其他十几个我不认识的开发人员中看到了这种反应(pythontestingcookbook.posterous.com/)。

问候程序)。测试人员在传达他们的经验时表现出一种特定类型的兴奋。

他们对测试的经验。那些对于接受自动化测试持保留态度的人听起来很高兴,津津有味。那些对此不感兴趣的人根本就不会参与讨论。

如果你正在阅读这本书(当然你是),你很有可能是你的团队中唯一一个真正对自动化测试感兴趣的人。你的队友可能听说过它,但对这个想法没有你那么感兴趣。将其添加到你的系统将需要你大量的投资,但不要仅仅局限于分享代码;考虑以下内容:

  • 展示你在取得进展时的兴奋,并解决棘手的问题。

  • 通过在墙上张贴测试结果来分享它们,让其他人看到。

  • 在休息室与同事聊天时谈论您的成就。

测试不是一个冷冰冷的机械过程;它是一个令人兴奋的、火热的开发领域。沉迷于测试的开发人员迫不及待地想与他人分享。如果您寻找传播自动化测试之火的方法,最终其他人会对此感兴趣,您会发现自己与他们讨论新的测试技术。

收集指标

开始一个电子表格,显示行数、代码、测试数量、总测试执行时间和错误数量,并在每次发布时进行跟踪。这些数字将捍卫您的投资。

如何做...

这些高层次的步骤展示了如何随着时间的推移捕获指标:

  1. 创建一个电子表格来跟踪测试用例的数量,运行测试套件所需的时间,测试运行的日期,任何错误以及每个测试的平均时间。

  2. 将电子表格作为另一个受控制的工件添加到您的代码库中。

  3. 添加一些图表,显示测试时间与测试数量的曲线。

  4. 每次发布时至少添加一行新数据。如果您可以更频繁地捕获数据,比如每周一次甚至每天一次,那就更好了。

它是如何工作的...

随着您编写更多的测试,测试套件的运行时间会变长。但您还会发现错误的数量往往会减少。您进行的测试越多,频率越高,您的代码就会越好。捕获您的测试指标可以作为时间花在编写和运行测试上的硬证据,这是一个明智的投资。

还有更多...

为什么我需要这个文档?难道我不知道测试有效吗?把它看作是对质量断言的备份。几个月后,管理层可能会要求您加快速度。也许他们需要更快的东西,他们认为您只是在测试这些东西上花费了太多时间。

如果您可以拿出您的电子表格,并展示随着测试工作的减少而减少的错误,他们将无从辩驳。但如果您没有这个,只是争辩说测试会让事情变得更好,您可能会输掉争论。

度量标准不仅仅是为了向管理层辩护

我个人很享受看到测试增加而错误减少。这是一种追踪自己并掌握进展情况的个人方式。而且,说实话,我的上一个经理完全支持自动化测试。他有自己的成功指标,所以我从未拿出过我的。

在自动化测试中捕获错误

在修复您发现的一行错误之前,先编写一个自动化测试,并确保它是可重复的。这有助于建立我们的系统免受回归到过去修复的故障的影响。

如何做...

这些高层次的步骤捕获了在自动化测试中捕获错误之前的工作流程:

我们修复它们:

  1. 当发现新错误时,编写一个重现它的测试用例。测试用例是否运行时间长、复杂或与许多组件集成并不重要。关键的是要重现错误。

  2. 将错误添加到您的测试套件中。

  3. 修复错误。

  4. 在检查更改之前,确保测试套件通过。

它是如何工作的...

向从未进行过自动化测试的应用程序引入自动化测试的最简单方法是一次测试一个错误。这种方法确保新发现的错误不会在以后悄悄地重新进入系统。

测试可能会给人一种松散的感觉,而不是全面的感觉,但这并不重要。重要的是,随着时间的推移,您将慢慢建立一个可靠的测试用例安全网,验证系统的预期性能。

还有更多...

我并没有说这会很容易。为没有考虑可测试性的软件编写自动化测试是一项艰苦的工作。正如在配方有总比没有好中提到的,第一个测试用例可能是最难的。但随着时间的推移,随着你编写更多的测试,你将获得信心回头重构事物。你一定会感到有能力,因为你知道你不会不知不觉地破坏事物。

当需要添加一个全新的模块时,你将为此做好准备。

用这种方法通过测试用例捕获错误是有用的,但速度慢。但没关系,因为慢慢添加测试会让你有时间以舒适的步伐提高你的测试技能。

这会有什么好处呢?嗯,最终,你将需要向你的系统添加一个新模块。这总是会发生的吧?到那时,你在测试和测试装置上的投资应该已经在提高现有代码质量方面产生了回报,但你也将在测试新模块上有一个先发优势。考虑以下:

  • 你不仅会知道,而且真正理解“面向测试的代码”的含义。

  • 你将能够以非常有效的方式同时编写代码和测试。

  • 新模块将有更高质量的先发优势,不需要像系统的传统部分那样需要太多的努力来赶上

不要屈服于跳过测试的诱惑。

正如我之前所说,第一个测试用例将非常难写。接下来的几个也不会容易。这让人很容易举手投降,跳过自动化测试。但是,如果你坚持下去,写出一些有效的东西,你就可以继续在这个成功的努力上建立。

这可能听起来像陈词滥调,但如果你坚持大约一个月,你将开始从你的工作中看到一些结果。这也是开始收集指标的好时机。记录你的进展并能够反思它可以提供积极的鼓励。

将算法与并发分离

并发性很难测试,但大多数算法在解耦后并不难。

如何做到...

Herb Sutter 在 2005 年写了一篇文章,题为免费午餐结束了,他指出微处理器正在接近串行处理的物理极限,这将迫使开发人员转向并发解决方案(www.gotw.ca/publications/concurrency-ddj.htm)。

新的处理器配备了多个核心。为了构建可扩展的应用程序,我们不能再只等待更快的芯片。相反,我们必须使用替代的并发技术。这个问题正在许多语言中得到解决。 Erlang 是第一种允许用于构建可用性为九个 9 的电信系统的语言之一,这意味着每 30 年大约有一秒的停机时间。

其中一个关键特点是在 actor 之间发送不可变数据。这提供了良好的隔离,并允许多个单元在 CPU 核心上运行。Python 有提供类似解耦的异步消息传递风格的库。最常见的两个是 Twisted 和 Kamaelia。

但是,在你开始使用这些框架之前,有一件重要的事情要记住:很难在测试并发性的同时测试算法。要使用这些库,你需要注册发出消息的代码,还需要注册处理消息的处理程序。

它是如何工作的...

将算法与所选择的并发库的机制解耦是很重要的。这将使测试算法变得更容易,但这并不意味着你不应该进行负载测试或尝试用实时数据回放场景来过载你的系统。

它的意思是,从大量测试场景开始是错误的优先级。在能够处理一次事件的自动化测试用例之前,你的系统需要正确处理一个事件。

研究并发框架提供的测试选项

一个好的并发库应该提供可靠的测试选项。寻找它们并尽量使用。但不要忘记验证你的自定义算法在简单的串行方式下也能工作。测试两边将给你很大的信心,系统在轻载和重载下都能如预期地运行。

当测试套件运行时间太长时,暂停重构

当你开始构建测试套件时,你可能会注意到运行时间变得相当长。如果它太长,以至于你不愿意每天至少运行一次,你需要停止编码并专注于加快测试的速度,无论是测试本身还是被测试的代码。

如何做…

这假设你已经开始使用以下一些实践来构建测试套件:

  • 有总比没有好

  • 愿意投资测试装置

  • 在自动化测试中捕捉错误

这些是缓慢开始的步骤,用于在最初没有任何自动化测试的系统中开始添加测试。进行自动化测试的一个权衡是编写相对昂贵的测试。例如,如果你的关键算法与数据库没有足够解耦,你将被迫编写一个涉及设置一些表、处理输入数据,然后针对数据库状态进行查询的测试用例。

随着你编写更多的测试,运行测试套件的时间肯定会增加。在某个时候,你会感到不太愿意花时间等待测试套件运行的时间。由于测试套件只有在使用时才有效,你必须暂停开发并追求重构代码或测试用例本身,以加快速度。

这是我遇到的一个问题:我的测试套件最初需要大约 15 分钟才能运行。最终它增长到需要一个半小时来运行所有的测试。我达到了一个只能每天运行一次,甚至跳过一些天的地步。有一天,我试图进行大规模的代码编辑。当大部分测试用例失败时,我意识到我没有经常运行测试套件来检测哪一步出了问题。我被迫放弃所有的代码编辑并重新开始。在继续之前,我花了几天时间重构代码和测试,将测试套件的运行时间降低到可以接受的 30 分钟。

它是如何工作的…

这是关键的衡量标准:当你对每天运行测试套件感到犹豫不决时,这可能是需要清理的迹象。测试套件应该被多次运行一天。

这是因为我们有竞争的利益:编写代码运行测试。重要的是要认识到这些事情:

  • 要运行测试,我们必须暂停编码工作

  • 要编写更多的代码,我们必须暂停测试工作

当测试占据我们日常时间表的大部分时,我们必须开始选择哪个更重要。我们倾向于更多地写代码,这可能是人们放弃自动化测试并认为它不适合他们情况的关键原因。

这很困难,但如果我们能抵制走捷径的诱惑,而是对代码或测试进行一些重构,我们将更有动力更频繁地运行测试。

还有更多…

在重构方面,这更少是科学而更多是巫术。重要的是要寻找能够给我们带来良好收益的机会。重要的是要理解,这可以是我们的测试代码,我们的生产代码,或者两者的组合都需要重构。考虑以下几点:

  • 性能分析可以告诉我们热点在哪里。重构或重写这些部分可以改进测试。

  • 紧耦合通常会迫使我们放入比我们想要的更多的系统部分,比如数据库使用。如果我们可以寻找方法将代码与数据库解耦,并用模拟或存根替换它,那么我们就可以更新相关的测试,以获得更快的测试套件运行。

从测试中获得的覆盖率可以帮助。所有这些方法对我们代码的质量都有积极的影响。更高效的算法会带来更好的性能,更松散的耦合有助于降低我们的长期维护成本。

另请参阅

  • 愿意放弃一整天的更改

利用你的信心

建立足够的测试后,你会有足够的信心重写大部分代码或进行几乎触及每个文件的大规模修改。去做吧!

如何做...

随着你构建更多的测试并每天运行多次,你会开始对系统的了解和不了解有所感觉。尤其是当你为系统的特定部分编写了足够昂贵、运行时间长的测试后,你会有强烈的愿望重写该模块。

你还在等什么?这就是构建可运行的测试安全网的关键。了解模块的各个方面可以让你攻克它。你可以重写它,更好地解耦它的部分,或者进行其他必要的改进,同时也能更好地支持测试。

它是如何工作的...

虽然你可能会有强烈的愿望攻击代码,但可能会有同样强烈的反对感。这是风险规避,我们都必须处理。我们希望避免头等跳进可能产生严重后果的情况。

假设我们已经建立了足够的安全网,现在是时候着手处理代码并开始清理它了。如果我们在进行这些更改时频繁运行测试套件,我们可以安全地进行所需的更改。这将提高代码的质量,并可能加快测试套件的运行时间。

在进行更改时,我们不必全力以赴

利用我们的信心意味着我们可以进入并对代码进行更改,但这并不意味着我们可以进入测试不充分、不足的代码区域。我们可能想要清理几个区域,但我们应该只处理我们最有信心的部分。随着我们在未来添加更多的测试,将有机会处理其他部分。

愿意放弃一整天的更改

你有没有曾经整天进行更改,只发现一半的测试失败,因为你忘记经常运行测试套件?准备好放弃这些更改。这就是自动化测试让我们能够做到的……回到一切都运行完美的时候。这会很痛苦,但下次你会记得更经常运行测试套件。

如何做...

这个方法假设你正在使用版本控制并进行定期提交。如果你两周没有提交,这个想法就不适用。

如果你每天至少运行一次测试套件,并且在通过后提交你所做的更改,那么回到某个之前的时间点,比如一天的开始,就会变得容易。

我做过很多次。第一次是最困难的。对我来说,这是一个新的想法,但我意识到软件的真正价值现在依赖于我的自动化测试套件。在下午的中途,我第一次运行了当天编辑了一半系统的测试套件。超过一半的测试失败了。

我试图深入挖掘并修复问题。问题在于,我无法弄清楚问题的根源。我花了几个小时试图追踪它。我开始意识到,如果不浪费大量时间,我是无法弄清楚的。

但我记得前一天一切都通过了。最终,我决定放弃我的更改,运行测试套件,验证一切都通过了,然后勉强回家。

第二天,我再次攻击了这个问题。只是,这一次,我更频繁地运行测试。我成功地编写了它。回顾这种情况,我意识到这个问题只花了我一天的时间。如果我试图坚持下去,我可能会花费一周的时间,最终可能还是要扔掉一切。

它是如何工作的...

根据你的组织如何管理源代码,你可能需要做以下事情:

  • 通过删除分支或取消你的检出来自己做

  • 联系你的 CM 团队删除当天的分支或提交

这实际上不是一个技术问题。无论谁负责分支管理,源代码控制系统都很容易做到这一点。困难的部分是做出放弃更改的决定。我们经常感到修复问题的愿望。我们的努力导致问题进一步恶化,我们就越想解决它。在某个时候,我们必须意识到前进的成本更高,而不是倒退重新开始。

敏捷性的轴从经典的瀑布式软件生产延伸到高度敏捷的流程。敏捷团队倾向于在较小的冲刺中工作,并以较小的块提交。这使得抛弃一天的工作更容易接受。任务越大,发布周期越长,你的更改自两周前开始任务以来就没有被检查的可能性就越大。

相信我,放弃两周的工作完全不同于放弃一天的工作。我绝不会主张放弃两周的工作。

核心思想是不要在测试套件未通过的情况下回家。如果这意味着你必须放弃一些东西才能实现这一点,那就是你必须做的。这真的强调了编写一点/测试一点的重点,直到新功能准备好发布。

还有更多...

我们还需要反思为什么我们没有经常运行测试套件。这可能是因为测试套件运行时间太长,你在犹豫是否要花费那么长的时间。也许是时候暂停重构当测试套件运行时间太长了。我真正学到这个教训的时候是当我的测试套件运行了一个半小时。在解决了整个问题之后,我意识到我需要加快速度,花了大概一两个星期将其缩短到可以接受的 30 分钟。

这与“有总比没有好”如何契合

在本章的前面,我们谈到了编写一个可能非常昂贵的测试用例来启动自动化测试。如果我们的测试变得如此昂贵以至于时间上不可行呢?毕竟,我们刚才说的不是会导致我们正在处理的情况吗?

编写一点代码/测试一点可能看起来是一个非常缓慢的进行方式。这可能是许多传统系统从未采用自动化测试的原因。我们必须攀登的山是陡峭的。但是,如果我们能坚持下去,开始构建测试,确保它们在一天结束时运行,然后最终暂停重构我们的代码和测试,我们最终可以达到更好的代码质量和系统信心的愉快平衡。

另见

  • 有总比没有好

  • 当测试套件运行时间太长时,暂停重构

而不是追求 100%的覆盖率,试着保持稳定的增长

没有覆盖分析,你不会知道自己的情况。但不要追求太高。相反,专注于逐渐增加。你会发现随着时间的推移,你的代码变得更好——甚至可能减少了量——而质量和覆盖率稳步提高。

如何做到这一点...

如果你从一个没有测试的系统开始,不要过分关注一个荒谬的高数字。我曾经在一个系统上工作,当我接手它时,覆盖率为 16%。一年后,我把它提高到了 65%。这离 100%还差得远,但由于捕获了自动化测试中的一个错误和收集了指标,系统的质量得到了飞跃式的提高。

有一次,我和我的经理讨论了我的代码质量,他给我展示了他开发的一份报告。他对他监督的每个应用程序的每个版本运行了一个代码计数工具。他说我的代码计数有一个独特的形状。所有的

其他工具的代码行数不断增加。我的代码增长,达到顶峰,然后开始减少,仍然在下降。

尽管我的软件做的比以往任何时候都多,但这种情况发生了。这是因为我开始丢弃未使用的功能、糟糕的代码,并在重构过程中清理垃圾。

它是如何工作的...

通过逐渐构建一个自动化的测试套件,你将逐渐覆盖更多的代码。通过专注于构建具有相应测试的高质量代码,覆盖率将自然增长。当我们开始关注覆盖率报告时,数字可能会增长得更快,但往往会更加人为。

不时地,当你充分利用你的信心并重写大块内容时,你应该感到有能力丢弃旧的垃圾。这也会以健康的方式增加你的覆盖度指标。

所有这些因素将导致质量和效率的提高。虽然你的代码可能最终会达到顶峰,然后下降,但由于新功能的增加,它最终会再次增长并不是不切实际的。

到那时,覆盖率可能会更高,因为你将与测试一起构建全新的功能,而不仅仅是维护传统的部分。

随机地打破你的应用程序可能会导致更好的代码

"避免失败的最好方法是不断地失败。" - Netflix

如何做到...

Netflix 建立了一个他们称之为混沌猴的工具。它的工作是随机杀死实例和服务。这迫使开发人员确保他们的系统可以平稳和安全地失败。为了建立我们自己的版本,我们需要它做以下事情:

  • 随机杀死进程

  • 在接口点注入错误数据

  • 关闭分布式系统之间的网络接口

  • 向子系统发出关闭命令。

  • 通过向接口点过载太多数据来创建拒绝服务攻击

这只是一个起点。这个想法是在你能想象到的任何地方注入错误。这可能需要编写脚本、Cron 作业或任何必要的手段来引发这些错误。

它是如何工作的...

考虑到远程系统在生产中可能不可用的可能性,我们应该在开发环境中引入这种情况。这将鼓励我们在系统中编写更高的容错性。

在我们引入像 Netflix 那样的随机运行混沌猴之前,我们需要确保我们的系统可以手动处理这些情况。例如,如果我们的系统包括两个服务器之间的通信,一个公平的测试是拔掉一个盒子的网络电缆,模拟网络故障。当我们验证我们的系统可以继续工作并且采取了可接受的手段时,我们可以添加脚本来自动执行这些操作,最终是随机的。

审计日志是验证我们的系统是否处理这些随机事件的有价值的工具。如果我们可以读取一个显示强制网络关闭的日志条目,然后看到类似时间戳的日志条目,我们可以轻松地评估系统是否处理了这种情况。

在构建好之后,我们可以开始处理系统中随机引入的下一个错误。通过遵循这个循环,我们可以提高系统的健壮性。

还有更多...

这并不完全符合自动化测试的范畴。这也是非常高层次的。很难进一步详细说明,因为要注入的错误数据类型需要对实际系统有深入的了解。

这与模糊测试有什么区别?

模糊测试是一种测试方式,其中无效的、意外的和随机的数据被注入到软件的输入点中(en.wikipedia.org/wiki/Fuzz_testing)。如果应用程序失败,这被认为是一个失败。如果没有失败,那么它就通过了。这种测试方式走的方向类似,但 Netflix 撰写的博客文章似乎比简单地注入不同的数据要深入得多。它谈到了杀死实例和中断分布式通信。基本上,任何你能想到的在生产中可能发生的事情,我们都应该在测试环境中尝试复制。Fusilbitbucket.org/haypo/fusil)是一个旨在提供模糊测试的 Python 工具。你可能想调查一下它是否适合你的项目需求。

有没有工具可以帮助这个?

Jester(用于 Java),Pester(用于 Python)和Nester(用于 C#)用于进行突变测试(jester.sourceforge.net/)。这些工具找出了测试用例未覆盖的代码,改变源代码,然后重新运行测试套件。最后,它们会提供一个报告,报告了什么被改变了,什么通过了,什么没有通过。它可以揭示测试套件覆盖和未覆盖的内容,这是覆盖工具无法做到的。

这并不是一个完整的混沌猴,但它通过试图破坏系统提供了一种帮助,迫使我们改进我们的测试制度。要真正构建一个完整的系统可能不适合在一个测试项目中,因为它需要根据它所要运行的环境编写自定义脚本。

posted @ 2024-05-04 21:28  绝不原创的飞龙  阅读(20)  评论(0编辑  收藏  举报