第 18 章:测试和持续集成

如今,自动化测试是软件开发中的基本活动。在本章中,您将看到 Jython 在此领域可用的工具的调查,从 Python 世界中常用的用于帮助进行单元测试的工具,到 Java 世界中可用的更复杂的工具,这些工具可以使用 Jython 扩展或驱动。

Python 测试工具

UnitTest

首先,我们将看一下 Python 中可用的最经典的测试工具:UnitTest。它遵循大多数“xUnit”版本(如 JUnit)的约定:您从 TestCase 类中继承,编写测试方法(其名称必须以“test”开头,并且可以选择覆盖 setup()tearDown() 方法,这些方法在测试方法周围执行,。您可以使用 TestCase 提供的多个 assert*() 方法。以下是一个针对内置 math 模块的某些函数的非常简单的测试用例

import math
import unittest

class TestMath(unittest.TestCase):
    def testFloor(self):
        self.assertEqual(1, math.floor(1.01))
        self.assertEqual(0, math.floor(0.5))
        self.assertEqual(-1, math.floor(-0.5))
        self.assertEqual(-2, math.floor(-1.1))

    def testCeil(self):
        self.assertEqual(2, math.ceil(1.01))
        self.assertEqual(1, math.ceil(0.5))
        self.assertEqual(0, math.ceil(-0.5))
        self.assertEqual(-1, math.ceil(-1.1))

当然,除了 assertEqual() 之外,还有许多其他断言方法。以下是其他可用断言方法的列表

  • assertNotEqual(a, b):与 assertEqual() 相反
  • assertAlmostEqual(a, b):仅用于数字比较。它为微不足道的差异添加了一种容忍度,方法是在将前两个参数舍入到第七位小数后减去它们,然后将结果与零进行比较。您可以在第三个参数中指定不同的位数。这对于比较浮点数很有用。
  • assertNotAlmostEqual(a, b):与 assertAlmostEqual() 相反
  • assert_(x):接受一个布尔值参数,期望它为 True。您可以使用它来编写其他检查,例如“大于”,或检查布尔函数/属性(尾部下划线是必需的,因为 assert 是一个关键字)。
  • assertFalse(x)。与 assert_() 相反。
  • assertRaises(exception, callable)。用于断言当调用作为第二个参数指定的可调用对象时,会抛出作为第一个参数传递的异常。传递给 assertRaises 的其余参数将传递给可调用对象。

例如,让我们使用其中一些其他断言函数扩展对数学函数的测试。

import math
import unittest
import operator

class TestMath(unittest.TestCase):

    # ...

    def testMultiplication(self):
        self.assertAlmostEqual(0.3, 0.1 * 3)

    def testDivision(self):
        self.assertRaises(ZeroDivisionError, operator.div, 1, 0)
        # The same assertion using a different idiom:
        self.assertRaises(ZeroDivisionError, lambda: 1 / 0)

现在,您可能想知道如何运行此测试用例。简单的答案是在定义它的文件中添加以下内容。

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

最后,只需运行模块。例如,如果您将所有这些代码写入名为 test_math.py 的文件中,则运行

$ jython test_math.py

您将看到此输出

....
----------------------------------------------------------------------
Ran 4 tests in 0.005s

OK

虚线上的每个点代表一个成功运行的测试。让我们看看如果我们添加一个失败的测试会发生什么。将 testMultiplication() 中的调用 assertAlmostEqual() 方法更改为使用 assertEqual()。如果您再次运行该模块,您将看到以下输出

...F
======================================================================
FAIL: testMultiplication (__main__.TestMath)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_math.py", line 22, in testMultiplication
    self.assertEqual(0.3, 0.1 * 3)
AssertionError: 0.3 != 0.30000000000000004

----------------------------------------------------------------------
Ran 4 tests in 0.030s

FAILED (failures=1)

如您所见,最后一个点现在是“F”,并且打印了失败的解释,指出 0.30.30000000000000004 不相等。最后一行还显示了 1 个失败的总数。

顺便说一下,现在您可以想象为什么使用 assertEquals(x, y)assert_(x == y) 更好:如果测试失败,assertEquals() 会提供有用的信息,而 assert_() 本身无法提供。为了实际演示这一点,让我们将 testMultiplication() 更改为使用 assert_()

class TestMath(unittest.TestCase):

    #...

    def testMultiplication(self):
        self.assert_(0.3 == 0.1 * 3)

如果您再次运行测试,输出将是

...F
======================================================================
FAIL: testMultiplication (__main__.TestMath)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_math.py", line 24, in testMultiplication
    self.assert_(0.3 == 0.1 * 3)
AssertionError

----------------------------------------------------------------------
Ran 4 tests in 0.054s

FAILED (failures=1)

现在我们只有回溯和“AssertionError”消息。没有提供额外的信息来帮助我们诊断失败,就像我们使用 assertEqual() 时一样。这就是为什么所有专门的 assert*() 方法如此有用。实际上,除了 assertRaises() 之外,所有断言方法都接受一个额外的参数,该参数旨在作为调试消息,如果测试失败,该消息将显示。这使您可以编写类似于以下的辅助方法

class SomeTestCase(unittest.TestCase):
    def assertGreaterThan(a, b):
        self.assert_(a > b, '%d isn't greater than %d')

    def testSomething(self):
        self.assertGreaterThan(10, 4)

随着应用程序的增长,测试用例的数量也会增加。最终,出于可维护性的原因,您可能不希望将所有测试都保存在一个 Python 模块中。

让我们创建一个名为 test_lists.py 的新模块,其中包含以下测试代码

import unittest

class TestLists(unittest.TestCase):
    def setUp(self):
        self.list = ['foo', 'bar', 'baz']

    def testLen(self):
        self.assertEqual(3, len(self.list))

    def testContains(self):
        self.assert_('foo' in self.list)
        self.assert_('bar' in self.list)
        self.assert_('baz' in self.list)

    def testSort(self):
        self.assertNotEqual(['bar', 'baz', 'foo'], self.list)
        self.list.sort()
        self.assertEqual(['bar', 'baz', 'foo'], self.list)

注意

在前面的代码中,您可以看到 setUp() 方法的示例,它使我们能够避免在每个 test*() 方法上重复相同的初始化代码。

并且,将我们的数学测试恢复到良好状态,test_math.py 将包含以下内容

import math
import unittest
import operator

class TestMath(unittest.TestCase):
    def testFloor(self):
        self.assertEqual(1, math.floor(1.01))
        self.assertEqual(0, math.floor(0.5))
        self.assertEqual(-1, math.floor(-0.5))
        self.assertEqual(-2, math.floor(-1.1))

    def testCeil(self):
        self.assertEqual(2, math.ceil(1.01))
        self.assertEqual(1, math.ceil(0.5))
        self.assertEqual(0, math.ceil(-0.5))
        self.assertEqual(-1, math.ceil(-1.1))

    def testDivision(self):
        self.assertRaises(ZeroDivisionError, operator.div, 1, 0)
        # The same assertion using a different idiom:
        self.assertRaises(ZeroDivisionError, lambda: 1 / 0)

    def testMultiplication(self):
        self.assertAlmostEqual(0.3, 0.1 * 3)

现在,我们如何在一遍中运行定义在不同模块中的测试?一种选择是手动构建一个测试套件。测试套件只是一个测试用例(和/或其他测试套件)的集合,当运行时,将运行它包含的所有测试用例(和/或测试套件)。请注意,每个测试方法都会构建一个新的测试用例实例,因此每次运行测试模块时,套件已经在幕后构建。因此,我们的工作是将这些套件“粘贴”在一起。

让我们使用交互式解释器构建套件!

首先,导入相关的模块

>>> import unittest, test_math, test_lists

然后,我们将使用 unittest.TestLoader 类获取每个测试模块的测试套件(这些测试套件是在使用 unittest.main() 快捷方式运行它们时隐式创建的)。

>>> loader = unittest.TestLoader()
>>> math_suite = loader.loadTestsFromModule(test_math)
>>> lists_suite = loader.loadTestsFromModule(test_lists)

现在我们构建一个新的套件,它将这些套件组合在一起

>>> global_suite = unittest.TestSuite([math_suite, lists_suite])

最后,我们运行套件

>>> unittest.TextTestRunner().run(global_suite)
.......
----------------------------------------------------------------------
Ran 7 tests in 0.010s

OK
<unittest._TextTestResult run=7 errors=0 failures=0>

或者,如果您想获得更详细的输出

>>> unittest.TextTestRunner(verbosity=2).run(global_suite)
testCeil (test_math.TestMath) ... ok
testDivision (test_math.TestMath) ... ok
testFloor (test_math.TestMath) ... ok
testMultiplication (test_math.TestMath) ... ok
testContains (test_lists.TestLists) ... ok
testLen (test_lists.TestLists) ... ok
testSort (test_lists.TestLists) ... ok

----------------------------------------------------------------------
Ran 7 tests in 0.020s

OK
<unittest._TextTestResult run=7 errors=0 failures=0>

使用有关加载器、套件和运行器的这些低级知识,您可以轻松地编写一个脚本来运行任何项目的测试。显然,脚本的细节将因项目而异,具体取决于您决定组织测试的方式。

另一方面,通常您不会编写自定义脚本运行所有测试。使用自动测试发现的测试工具将是一种更方便的方法。我们很快就会看一下其中之一。但首先,我必须向您展示 Python 世界中另一个非常流行的测试工具:doctests。

Doctests

Doctests 是一种非常巧妙的组合,将文档和测试结合在一起。本质上,doctest 不过是一个交互式解释器会话的快照,与文档段落混合在一起,通常位于文档字符串中。以下是一个简单的示例

def is_even(number):
    """
    Checks if an integer number is even.

    >>> is_even(0)
    True

    >>> is_even(2)
    True

    >>> is_even(3)
    False

    It works with very long numbers:

    >>> is_even(100000000000000000000000000000)
    True

    And also with negatives:

    >>> is_even(-1000000000000000000000000000001)
    False

    But not with floats:

    >>> is_even(4.1)
    Traceback (most recent call last):
    ...
    ValueError: 4.1 isn't an integer

    However, a value of type float as long as it value is an integer:

    >>> is_even(4.0)
    True
    """
    remainder = number % 2
    if 0 < remainder < 1:
        raise ValueError("%f isn't an integer" % number)
    return remainder == 0

请注意,如果我们不是在谈论测试,我们可能会认为 is_even() 的文档字符串只是普通的文档,其中采用使用解释器提示来标记示例表达式及其输出的约定(还要注意,在异常示例中,无关的堆栈跟踪已被删除)。毕竟,在很多情况下,我们会将示例用作文档的一部分。看看 Java 的 SimpleDateFormat 文档,位于 http://java.sun.com/javase/6/docs/api/java/text/SimpleDateFormat.html,您会发现类似的片段

  • “…使用 MM/dd/yy 的模式和在 1997 年 1 月 1 日创建的 SimpleDateFormat 实例,字符串 01/11/12 将被解释为 2012 年 1 月 11 日…”
  • “…使用相同的模式解析 01/02/3 或 01/02/003,结果为公元 3 年 1 月 2 日…”
  • “…“01/02/-3” 被解析为公元前 4 年 1 月 2 日…”

doctest 的魔力在于它鼓励将这些示例作为测试来使用。让我们将示例代码保存为 even.py,并在末尾添加以下代码段

if __name__ == "__main__":
    import doctest
    doctest.testmod()

然后,运行它

$ jython even.py

doctest 比较害羞,成功时不会显示任何输出。但是为了说服您它确实在测试我们的代码,请使用 -v 选项运行它

$ jython even.py -v

Trying:
    is_even(0)
Expecting:
    True
ok
Trying:
    is_even(2)
Expecting:
    True
ok
Trying:
    is_even(3)
Expecting:
    False
ok
Trying:
    is_even(100000000000000000000000000000)
Expecting:
    True
ok
Trying:
    is_even(-1000000000000000000000000000001)
Expecting:
    False
ok
Trying:
    is_even(4.1)
Expecting:
    Traceback (most recent call last):
    ...
    ValueError: 4.1 isn't an integer
ok
Trying:
    is_even(4.0)
Expecting:
    True
ok
1 items had no tests:
    __main__
1 items passed all tests:
   7 tests in __main__.is_even
7 tests in 2 items.
7 passed and 0 failed.
Test passed.

Doctests 是一种非常方便的测试方法,因为交互式示例可以直接从交互式 shell 中复制粘贴,将手动测试转化为文档示例和自动化测试。

您并不真正需要将 doctests 作为其测试功能的文档的一部分。没有什么能阻止您在例如 test_math_using_doctest.py 模块中编写以下代码

"""
Doctests equivalent to test_math unittests seen in the previous section.

>>> import math

Tests for floor():

>>> math.floor(1.01)
1
>>> math.floor(0.5)
0
>>> math.floor(-0.5)
-1
>>> math.floor(-1.1)
-2

Tests for ceil():

>>> math.ceil(1.01)
2
>>> math.ceil(0.5)
1
>>> math.ceil(-0.5)
0
>>> math.ceil(-1.1)
-1

Test for division:

>>> 1 / 0
Traceback (most recent call last):
...
ZeroDivisionError: integer division or modulo by zero

Test for floating point multiplication:

>>> (0.3 - 0.1 * 3) < 0.0000001
True

"""
if __name__ == "__main__":
    import doctest
    doctest.testmod()

在前面的示例中需要注意的是,在某些情况下,doctests 不是表达测试的最干净方法。还要注意,如果该测试失败,您不会从失败中获得有用的信息。它会告诉您输出为 False,而预期为 True,而没有 assertAlmostEquals() 会提供的额外细节。历史的寓意是,要意识到 doctest 只是工具箱中的另一个工具,它在某些情况下非常适合,而在其他情况下则不适合。

警告

说到 doctests 的陷阱:在 doctests 中使用字典输出是一个非常常见的错误,它会破坏您的 doctests 在不同 Python 实现(例如 Jython、CPython 和 IronPython)之间的可移植性。这里的陷阱是 **字典键的顺序取决于实现**,因此测试在某些实现上可能通过,而在其他实现上则可能严重失败。解决方法是将字典转换为元组序列并对其进行排序,使用 sorted(mydict.items())

这表明了 doctests 的一个重大缺陷:它始终对表达式进行文本比较,将结果转换为字符串。它不了解对象的结构。

为了利用 doctests,我们必须遵循一些简单的规则,例如使用 >>> 提示并在示例输出和下一段之间留一个空行。但如果您仔细想想,这与使文档易于人们阅读的相同类型的合理规则是一致的。

本节中显示的示例中没有显示的唯一常见规则是编写跨越多行的表达式的写法。正如您所料,您必须遵循交互式解释器使用的相同约定:用省略号(“...”)开始续行。例如

"""
Addition is commutative:

>>> ((1 + 2) ==
...  (2 + 1))
True
"""

完整示例

在了解了 Python 世界中使用的两种测试框架之后,让我们看看它们如何应用于更有意义的程序。我们将编写代码来检查八皇后棋盘问题的解决方案。该问题的思路是在棋盘上放置八个皇后,并且没有皇后互相攻击。皇后可以攻击放置在同一行、同一列或对角线上的任何棋子。图 八皇后解决方案 显示了该问题的一个解决方案。

../_images/chapter19-eightqueens.png

八皇后问题

我喜欢使用 doctests 来检查程序与外部的契约,并使用 unittest 来进行我们可能视为内部测试的测试。我这样做是因为外部接口往往有清晰的文档,并且对文档中的示例进行自动化测试始终是一件好事。另一方面,单元测试在将我们指向错误的具体来源方面表现出色,或者至少在提供比 doctests 更实用的调试信息方面表现出色。

注意

在实践中,这两种类型的测试都有优点和缺点,你可能会发现一些情况下,你会更喜欢 doctests 的可读性和简单性,并且只在你的项目中使用它们。或者你会更喜欢单元测试的粒度和隔离性,并且只在你的项目中使用它们。就像生活中很多事情一样,这是一个权衡。

我们将以测试驱动开发的方式开发这个程序。测试将首先编写,作为我们程序的一种规范,代码将在稍后编写以满足测试的要求。

让我们从指定我们拼图检查器的公共接口开始,它将位于 eightqueen 包中。这是主模块 eightqueen.checker 的开始。

"""
eightqueen.checker: Validates solutions for the eight queens puzzle.

Provides the function is_solution(board) to determine if a board represents a
valid solution of the puzzle.

The chess board is represented by list of 8 strings, each string of length
8. Positions occupied by a Queen are marked by the character 'Q', and empty
spaces are represented by an space character.

Here is a valid board:

>>> board = ['Q       ',
...          ' Q      ',
...          '  Q     ',
...          '   Q    ',
...          '    Q   ',
...          '     Q  ',
...          '      Q ',
...          '       Q']

Naturally, it is not a correct solution:

>>> is_solution(board)
False

Here is a correct solution:

>>> is_solution(['Q       ',
...              '    Q   ',
...              '       Q',
...              '     Q  ',
...              '  Q     ',
...              '      Q ',
...              ' Q      ',
...              '   Q    '])
True

Malformed boards are rejected and a ValueError is thrown:

>>> is_solution([])
Traceback (most recent call last):
...
ValueError: Malformed board

Only 8 x 8 boards are supported.

>>> is_solution(['Q   ',
...              ' Q  ',
...              '  Q ',
...              '   Q'])
Traceback (most recent call last):
...
ValueError: Malformed board

And they must only contains Qs and spaces:

>>> is_solution(['X       ',
...              '    X   ',
...              '       X',
...              '     X  ',
...              '  X     ',
...              '      X ',
...              ' X      ',
...              '   X    '])
Traceback (most recent call last):
...
ValueError: Malformed board

And the total number of Qs must be eight:

>>> is_solution(['QQQQQQQQ',
...              'Q       ',
...              '        ',
...              '        ',
...              '        ',
...              '        ',
...              '        ',
...              '        '])
Traceback (most recent call last):
...
ValueError: There must be exactly 8 queens in the board

>>> is_solution(['QQQQQQQ ',
...              '        ',
...              '        ',
...              '        ',
...              '        ',
...              '        ',
...              '        ',
...              '        '])
Traceback (most recent call last):
...
ValueError: There must be exactly 8 queens in the board

"""

这是一个良好的开端:我们知道我们必须构建什么。doctests 扮演着更精确的问题陈述的角色。实际上,它是一个可执行的问题陈述,可以用来验证我们对问题的解决方案。

现在我们将指定“内部”接口,它展示了我们如何解决编写解决方案检查器的问题。在单独的模块中编写单元测试是一种常见的做法。所以这里就是 eightqueens.test_checker 的代码。

import unittest
from eightqueens import checker

BOARD_TOO_SMALL = ['Q' * 3 for i in range(3)]
BOARD_TOO_BIG = ['Q' * 10 for i in range(10)]
BOARD_WITH_TOO_MANY_COLS = ['Q' * 9 for i in range(8)]
BOARD_WITH_TOO_MANY_ROWS = ['Q' * 8 for i in range(9)]
BOARD_FULL_OF_QS = ['Q' * 8 for i in range(8)]
BOARD_FULL_OF_CRAP = [chr(65 + i) * 8 for i in range(8)]
BOARD_EMPTY = [' ' * 8 for i in range(8)]

BOARD_WITH_QS_IN_THE_SAME_ROW = ['Q   Q   ',
                                 '        ',
                                 '       Q',
                                 '     Q  ',
                                 '  Q     ',
                                 '      Q ',
                                 ' Q      ',
                                 '   Q    ']
BOARD_WITH_WRONG_SOLUTION = BOARD_WITH_QS_IN_THE_SAME_ROW

BOARD_WITH_QS_IN_THE_SAME_COL = ['Q       ',
                                 '    Q   ',
                                 '       Q',
                                 'Q       ',
                                 '  Q     ',
                                 '      Q ',
                                 ' Q      ',
                                 '   Q    ']

BOARD_WITH_QS_IN_THE_SAME_DIAG_1 = ['        ',
                                    '        ',
                                    '        ',
                                    '        ',
                                    '        ',
                                    '        ',
                                    'Q       ',
                                    ' Q      ']

BOARD_WITH_QS_IN_THE_SAME_DIAG_2 = ['        ',
                                    '   Q    ',
                                    '        ',
                                    '     Q  ',
                                    '        ',
                                    '        ',
                                    '        ',
                                    '        ']

BOARD_WITH_QS_IN_THE_SAME_DIAG_3 = ['        ',
                                    '      Q ',
                                    '        ',
                                    '        ',
                                    '        ',
                                    '  Q     ',
                                    '        ',
                                    '        ']


BOARD_WITH_QS_IN_THE_SAME_DIAG_4 = ['        ',
                                    '    Q   ',
                                    '        ',
                                    '        ',
                                    '        ',
                                    'Q       ',
                                    '        ',
                                    '        ']


BOARD_WITH_QS_IN_THE_SAME_DIAG_5 = ['       Q',
                                    '      Q ',
                                    '     Q  ',
                                    '    Q   ',
                                    '   Q    ',
                                    '  Q     ',
                                    ' Q      ',
                                    'Q       ']



BOARD_WITH_SOLUTION = ['Q       ',
                       '    Q   ',
                       '       Q',
                       '     Q  ',
                       '  Q     ',
                       '      Q ',
                       ' Q      ',
                       '   Q    ']


class ValidationTest(unittest.TestCase):
    def testValidateShape(self):
        def assertNotValidShape(board):
            self.assertFalse(checker._validate_shape(board))

        # Some invalid shapes:
        assertNotValidShape([])
        assertNotValidShape(BOARD_TOO_SMALL)
        assertNotValidShape(BOARD_TOO_BIG)
        assertNotValidShape(BOARD_WITH_TOO_MANY_COLS)
        assertNotValidShape(BOARD_WITH_TOO_MANY_ROWS)

        def assertValidShape(board):
            self.assert_(checker._validate_shape(board))

        assertValidShape(BOARD_WITH_SOLUTION)
        # Shape validation doesn't care about board contents:
        assertValidShape(BOARD_FULL_OF_QS)
        assertValidShape(BOARD_FULL_OF_CRAP)

    def testValidateContents(self):
        # Valid content => only 'Q' and ' ' in the board
        self.assertFalse(checker._validate_contents(BOARD_FULL_OF_CRAP))
        self.assert_(checker._validate_contents(BOARD_WITH_SOLUTION))
        # Content validation doesn't care about the number of queens:
        self.assert_(checker._validate_contents(BOARD_FULL_OF_QS))


    def testValidateQueens(self):
        self.assertFalse(checker._validate_queens(BOARD_FULL_OF_QS))
        self.assertFalse(checker._validate_queens(BOARD_EMPTY))
        self.assert_(checker._validate_queens(BOARD_WITH_SOLUTION))
        self.assert_(checker._validate_queens(BOARD_WITH_WRONG_SOLUTION))


class PartialSolutionTest(unittest.TestCase):
    def testRowsOK(self):
        self.assert_(checker._rows_ok(BOARD_WITH_SOLUTION))
        self.assertFalse(checker._rows_ok(BOARD_WITH_QS_IN_THE_SAME_ROW))

    def testColsOK(self):
        self.assert_(checker._cols_ok(BOARD_WITH_SOLUTION))
        self.assertFalse(checker._cols_ok(BOARD_WITH_QS_IN_THE_SAME_COL))

    def testDiagonalsOK(self):
        self.assert_(checker._diagonals_ok(BOARD_WITH_SOLUTION))
        self.assertFalse(
            checker._diagonals_ok(BOARD_WITH_QS_IN_THE_SAME_DIAG_1))
        self.assertFalse(
            checker._diagonals_ok(BOARD_WITH_QS_IN_THE_SAME_DIAG_2))
        self.assertFalse(
            checker._diagonals_ok(BOARD_WITH_QS_IN_THE_SAME_DIAG_3))
        self.assertFalse(
            checker._diagonals_ok(BOARD_WITH_QS_IN_THE_SAME_DIAG_4))
        self.assertFalse(
            checker._diagonals_ok(BOARD_WITH_QS_IN_THE_SAME_DIAG_5))

class SolutionTest(unittest.TestCase):
    def testIsSolution(self):
        self.assert_(checker.is_solution(BOARD_WITH_SOLUTION))

        self.assertFalse(checker.is_solution(BOARD_WITH_QS_IN_THE_SAME_COL))
        self.assertFalse(checker.is_solution(BOARD_WITH_QS_IN_THE_SAME_ROW))
        self.assertFalse(checker.is_solution(BOARD_WITH_QS_IN_THE_SAME_DIAG_5))

        self.assertRaises(ValueError, checker.is_solution, BOARD_TOO_SMALL)
        self.assertRaises(ValueError, checker.is_solution, BOARD_FULL_OF_CRAP)
        self.assertRaises(ValueError, checker.is_solution, BOARD_EMPTY)

这些单元测试提出了一种解决问题的方法,将问题分解成两个主要任务(输入验证和实际解决方案验证),并且每个任务都分解成一个更小的部分,旨在由一个函数实现。在某种程度上,它们是解决方案的可执行设计。

所以我们混合了 doctests 和单元测试。我们如何一次性运行所有这些测试?之前我向你展示了如何手动为属于不同模块的单元测试组合测试套件,所以这可能是一个答案。实际上,有一种方法可以将 doctests 添加到测试套件中:doctest.DocTestSuite(module_with_doctests)。但是,由于我们正在处理一个更真实的测试示例,我们将使用一个现实世界的解决方案来解决这个问题(正如你所想象的,人们厌倦了繁琐的工作,出现了更多自动化的解决方案)。

Nose

Nose 是一个用于测试发现和执行的工具。默认情况下,nose 尝试对任何名称以“test”开头的模块运行测试。当然,你可以覆盖它。在我们的例子中,上一节的示例代码遵循了约定(测试模块名为 eightqueens.test_checker)。

我们将使用 setuptools 来安装 nose。如果你还没有安装 setuptools,请参考附录 A 获取安装说明。

安装完 setuptools 后,运行

$ easy_install nose

注意

我假设 Jython 安装的 bin 目录位于你的 PATH 中。如果不是,你将不得不显式地键入该路径,并在每个命令之前加上该路径,例如 jythoneasy_install(即,你需要键入类似 /path/to/jython/bin/easy_install 的内容,而不是仅仅键入 easy_install)。

安装完 nose 后,一个名为 nosetests 的可执行文件将出现在 Jython 安装的 bin/ 目录中。让我们试一试,将自己定位在 eightqueens 的父目录中,并运行

$ nosetests --with-doctest

默认情况下,nose 不会运行 doctests,因此我们必须显式地启用与 nose 捆绑在一起的 doctest 插件。

回到我们的示例,以下是运行 nose 后的简短输出

FEEEEEE

[Snipped output]

----------------------------------------------------------------------
Ran 8 tests in 1.133s
FAILED (errors=7, failures=1)

当然,我们所有的测试(6 个单元测试和 1 个 doctest)都失败了。现在是修复的时候了。但首先,让我们再次运行 nose,但 *不* 包括 doctests,因为我们将遵循单元测试来构建解决方案。我们知道,只要我们的单元测试失败,doctest 也可能失败。一旦所有单元测试通过,我们就可以根据高级 doctest 检查整个程序,看看我们是否遗漏了什么或是否做对了。以下是单元测试的 nose 输出

$ nosetests
EEEEEEE
======================================================================
ERROR: testIsSolution (eightqueens.test_checker.SolutionTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/eightqueens/test_checker.py", line 149, in testIsSolution
    self.assert_(checker.is_solution(BOARD_WITH_SOLUTION))
AttributeError: 'module' object has no attribute 'is_solution'

======================================================================
ERROR: testColsOK (eightqueens.test_checker.PartialSolutionTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/eightqueens/test_checker.py", line 100, in testColsOK
    self.assert_(checker._cols_ok(BOARD_WITH_SOLUTION))
AttributeError: 'module' object has no attribute '_cols_ok'

======================================================================
ERROR: testDiagonalsOK (eightqueens.test_checker.PartialSolutionTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/eightqueens/test_checker.py", line 104, in testDiagonalsOK
    self.assert_(checker._diagonals_ok(BOARD_WITH_SOLUTION))
AttributeError: 'module' object has no attribute '_diagonals_ok'

======================================================================
ERROR: testRowsOK (eightqueens.test_checker.PartialSolutionTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/eightqueens/test_checker.py", line 96, in testRowsOK
    self.assert_(checker._rows_ok(BOARD_WITH_SOLUTION))
AttributeError: 'module' object has no attribute '_rows_ok'

======================================================================
ERROR: testValidateContents (eightqueens.test_checker.ValidationTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/eightqueens/test_checker.py", line 81, in testValidateContents
    self.assertFalse(checker._validate_contents(BOARD_FULL_OF_CRAP))
AttributeError: 'module' object has no attribute '_validate_contents'

======================================================================
ERROR: testValidateQueens (eightqueens.test_checker.ValidationTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/eightqueens/test_checker.py", line 88, in testValidateQueens
    self.assertFalse(checker._validate_queens(BOARD_FULL_OF_QS))
AttributeError: 'module' object has no attribute '_validate_queens'

======================================================================
ERROR: testValidateShape (eightqueens.test_checker.ValidationTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/eightqueens/test_checker.py", line 65, in testValidateShape
    assertNotValidShape([])
  File "/path/to/eightqueens/test_checker.py", line 62, in assertNotValidShape
    self.assertFalse(checker._validate_shape(board))
AttributeError: 'module' object has no attribute '_validate_shape'

----------------------------------------------------------------------
Ran 7 tests in 0.493s

FAILED (errors=7)

让我们开始通过编写由ValidationTest指定的验证函数来清除错误。也就是说,在eightqueens.checker模块中,编写_validate_shape()_validate_contents()validate_queens()函数。

def _validate_shape(board):
    return (board and
            len(board) == 8 and
            all(len(row) == 8 for row in board))

def _validate_contents(board):
    for row in board:
        for square in row:
            if square not in ('Q', ' '):
                return False
    return True

def _count_queens(row):
    n = 0
    for square in row:
        if square == 'Q':
            n += 1
    return n

def _validate_queens(board):
    n = 0
    for row in board:
        n += _count_queens(row)
    return n == 8

现在再次运行nose。

$ nosetests

EEEE...
======================================================================
ERROR: testIsSolution (eightqueens.test_checker.SolutionTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/eightqueens/test_checker.py", line 149, in testIsSolution
    self.assert_(checker.is_solution(BOARD_WITH_SOLUTION))
AttributeError: 'module' object has no attribute 'is_solution'

======================================================================
ERROR: testColsOK (eightqueens.test_checker.PartialSolutionTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/eightqueens/test_checker.py", line 100, in testColsOK
    self.assert_(checker._cols_ok(BOARD_WITH_SOLUTION))
AttributeError: 'module' object has no attribute '_cols_ok'

======================================================================
ERROR: testDiagonalsOK (eightqueens.test_checker.PartialSolutionTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/eightqueens/test_checker.py", line 104, in testDiagonalsOK
    self.assert_(checker._diagonals_ok(BOARD_WITH_SOLUTION))
AttributeError: 'module' object has no attribute '_diagonals_ok'

======================================================================
ERROR: testRowsOK (eightqueens.test_checker.PartialSolutionTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/eightqueens/test_checker.py", line 96, in testRowsOK
    self.assert_(checker._rows_ok(BOARD_WITH_SOLUTION))
AttributeError: 'module' object has no attribute '_rows_ok'

----------------------------------------------------------------------
Ran 7 tests in 0.534s

FAILED (errors=4)

我们通过了所有验证测试!现在我们应该实现函数_rows_ok()_cols_ok()_diagonals_ok()来通过PartialSolutionTest

def _scan_ok(board, coordinates):
    queen_already_found = False
    for i, j in coordinates:
        if board[i][j] == 'Q':
            if queen_already_found:
                return False
            else:
                queen_already_found = True
    return True


def _rows_ok(board):
    for i in range(8):
        if not _scan_ok(board, [(i, j) for j in range(8)]):
            return False
    return True

def _cols_ok(board):
    for j in range(8):
        if not _scan_ok(board, [(i, j) for i in range(8)]):
            return False
    return True

def _diagonals_ok(board):
    for k in range(8):
        # Diagonal: (0, k), (1, k + 1), ..., (7 - k, 7)...
        if not _scan_ok(board, [(i, k + i) for i in range(8 - k)]):
            return False
        # Diagonal: (k, 0), (k + 1, 1), ..., (7, 7 - k)
        if not _scan_ok(board, [(k + j, j) for j in range(8 - k)]):
            return False

        # Diagonal: (0, k), (1, k - 1), ..., (k, 0)
        if not _scan_ok(board, [(i, k - i) for i in range(k + 1)]):
            return False

        # Diagonal: (7, k), (6, k - 1), ..., (k, 7)
        if not _scan_ok(board, [(7 - j, k + j) for j in range(8 - k)]):
            return False
    return True

让我们再次尝试nose。

$ nosetests

...E...
======================================================================
ERROR: testIsSolution (eightqueens.test_checker.SolutionTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/eightqueens/test_checker.py", line 149, in testIsSolution
    self.assert_(checker.is_solution(BOARD_WITH_SOLUTION))
AttributeError: 'module' object has no attribute 'is_solution'

----------------------------------------------------------------------
Ran 7 tests in 0.938s

FAILED (errors=1)

最后,我们必须将这些部分组合在一起才能通过is_solution()的测试。

def is_solution(board):
    if not _validate_shape(board) or not _validate_contents(board):
        raise ValueError("Malformed board")
    if not _validate_queens(board):
        raise ValueError("There must be exactly 8 queens in the board")
    return _rows_ok(board) and _cols_ok(board) and _diagonals_ok(board)

现在我们可以希望所有测试都通过。

$ nosetests

.......
----------------------------------------------------------------------
Ran 7 tests in 0.592s

OK

事实上,它们都通过了。此外,我们可能也通过了在我们 doctest 中表达的“问题陈述”测试。

$ nosetests --with-doctest

........
----------------------------------------------------------------------
Ran 8 tests in 1.523s

OK

目标达成!我们已经使用 Python 语言附带的两个测试工具,以及 Nose 来运行所有测试,而无需手动构建套件,从而创建了一个经过良好记录和测试的模块。

与 Java 集成?

您可能想知道如何集成 Python 和 Java 的测试框架。可以在 Jython 中编写 JUnit 测试,但这并不有趣,因为您可以使用 unittest 和 doctest 测试 Java 类。以下是一个完全有效的 doctest

"""
Tests for Java's DecimalFormat

>>> from java.text import DecimalFormat

A format for money:

>>> dolarFormat = DecimalFormat("$ ###,###.##")

The decimal part is only printed if needed:

>>> dolarFormat.format(1000)
u'$ 1.000'

Rounding is used when there are more decimal numbers than those defined by the
format:

>>> dolarFormat.format(123456.789)
u'$ 123.456,79'

The format can be used as a parser:

>>> dolarFormat.parse('$ 123')
123L

The parser ignores the unparseable text after the number:

>>> dolarFormat.parse("$ 123abcd")
123L

However, if it can't parse a number, it throws a ParseException:

>>> dolarFormat.parse("abcd")
Traceback (most recent call last):
...
ParseException: java.text.ParseException: Unparseable number: "abcd"
"""

因此,您可以使用本章中学习的所有内容来测试用 Java 编写的代码。就我个人而言,我认为这是一个非常强大的 Java 开发工具:使用 Jython 和 Python 测试工具进行简单、灵活和轻松的测试!

持续集成

Martin Fowler 将持续集成定义为“一种软件开发实践,团队成员经常集成他们的工作 […]. 每次集成都通过自动构建(包括测试)进行验证,以尽快检测集成错误”。一些软件开发团队报告称早在 1960 年代就使用过这种实践,但直到作为极限编程实践的一部分被提倡时才成为主流。如今,它是一种广泛应用的实践,在 Java 世界中,有大量的工具可以帮助解决它所涉及的技术挑战。

Hudson

Hudson 是目前发展势头强劲、用户群不断增长的工具之一。它最突出的特点是易于安装和配置,以及易于在分布式主/从环境中部署以进行跨平台测试。

但是,在我看来,Hudson 最大的优势在于其高度模块化的基于插件的架构,这导致了创建了插件来支持大多数版本控制、构建和报告工具,以及许多语言。其中之一是 Jython 插件,它允许您使用 Python 语言来驱动您的构建。

您可以在 Hudson 项目的主页 https://hudson.dev.java.net/ 上找到有关 Hudson 项目的更多详细信息。我将直接说明如何使用它测试 Jython 应用程序。

获取 Hudson

http://hudson-ci.org/latest/hudson.war 获取最新版本的 Hudson。您可以将其部署到任何 servlet 容器,如 Tomcat 或 Glassfish。但 Hudson 的一个很酷的功能是,您可以通过简单地运行以下命令来测试它

$ java -jar hudson.war

几秒钟后,您将在控制台上看到一些日志输出,Hudson 将启动并运行。如果您访问 http://localhost:8080/,您将看到一个欢迎页面,邀请您开始使用 Hudson 创建新的作业。 .. warning

Be careful: The default mode of operation of Hudson fully trusts its users,
letting them to execute any command they want on the server, with the
privileges of the user running Hudson. You can set stricter access control
policies on the "Configure System" section of the "Manage Hudson" page.

安装 Jython 插件

在创建作业之前,我们将安装 Jython 插件。点击左侧菜单上的“管理 Hudson”链接。然后点击“管理插件”。现在转到“可用”选项卡。您将看到一个非常长的插件列表(我告诉过您这是 Hudson 最大的优势!)。找到“Jython 插件”,点击其左侧的复选框,如图 选择 Jython 插件。 所示,然后滚动到页面末尾并点击“安装”按钮。

../_images/chapter19-hudson-selectingjythonplugin.png

选择 Jython 插件。

您将看到一个显示下载和安装进度条,过了一会儿,您将看到一个类似于图 Jython 插件已成功安装 的屏幕,通知您该过程已完成。按下“重启”按钮,等待片刻,您将再次看到欢迎屏幕。恭喜您,您现在拥有一个由 Jython 支持的 Hudson!

../_images/chapter19-hudson-jythonplugininstalled.png

Jython 插件已成功安装

为 Jython 项目创建 Hudson 作业

现在让我们按照欢迎屏幕的建议,点击“创建新作业”链接。一个作业大致对应于 Hudson 为构建项目所需的指令。它包括

  • 获取项目源代码的位置以及频率。
  • 如何启动项目的构建过程
  • 如何收集构建过程完成后信息

点击“创建新作业”链接(相当于左侧菜单上的“新建作业”条目)后,系统将要求您为作业命名并选择类型。我们将使用上一节中构建的 eightqueens 项目,因此将项目命名为“eightqueens”,选择“构建自由风格软件项目”选项,然后按下“确定”按钮。

在下一个屏幕中,我们需要在“源代码管理”部分设置一个选项。您可能想在这里尝试使用自己的存储库(默认情况下只支持 CVS 和 Subversion,但有适用于所有其他 VCS 的插件)。对于我们的示例,我在 http://kenai.com/svn/jythonbook~eightqueens/ 上托管了代码。因此,选择“Subversion”并在“存储库 URL”中输入 http://kenai.com/svn/jythonbook~eightqueens/trunk/eightqueens/

注意

使用公共存储库足以了解 Hudson 及其对 Jython 的支持。但是,我鼓励您创建自己的存储库,以便您可以自由地玩转持续集成,例如提交错误的代码以查看如何处理失败。

在“构建触发器”部分,我们必须指定何时进行自动构建。我们将轮询存储库,以便在任何更改后启动新的构建。选择“轮询 SCM”并在“计划”框中输入“@hourly”(如果您想了解计划的所有选项,请点击框右侧的帮助图标)。

在“构建”部分,我们必须告诉 Hudson 如何构建我们的项目。默认情况下,Hudson 支持 Shell 脚本、Windows 批处理文件和 Ant 脚本作为构建步骤。对于混合使用 Java 和 Python 代码并使用 ant 文件驱动构建过程的项目,默认的 Ant 构建步骤就足够了。在我们的例子中,我们用纯 Python 代码编写了我们的应用程序,因此我们将使用 Jython 插件,它添加了“执行 Jython 脚本”构建步骤。

因此,点击“添加构建步骤”,然后选择“执行 Jython 脚本”。我们将利用我们在 UnitTest 部分获得的关于测试套件的知识,以下脚本足以运行我们的测试

import os, sys, unittest, doctest
from eightqueens import checker, test_checker

loader = unittest.TestLoader()
suite = unittest.TestSuite([loader.loadTestsFromModule(test_checker),
                            doctest.DocTestSuite(checker)])
result = unittest.TextTestRunner().run(suite)
print result
if not result.wasSuccessful():
   sys.exit(1)

Hudson 作业配置 显示了迄今为止“源代码管理”、“构建触发器”和“构建”部分的页面外观。

../_images/chapter19-hudson-jobconfig.png

Hudson 作业配置

下一个部分名为“构建后操作”,允许您指定构建完成后要执行的操作,从收集静态分析工具或测试运行器生成的报告结果到发送电子邮件通知某人构建中断。现在我们将保留这些选项为空。点击页面底部的“保存”按钮。

此时,Hudson 将显示作业的主页。但它不会包含任何有用的内容,因为 Hudson 正在等待每小时触发器轮询存储库并启动构建。但如果我们不想等待,我们不需要等待:只需点击左侧菜单上的“立即构建”链接。很快,将在“构建历史记录”框(也在左侧,菜单下方)中显示一个新条目,如图 第一个作业的第一次构建。 所示。

../_images/chapter19-hudson-buildhistory.png

我们第一个工作的第一个构建。

如果你点击刚刚出现的链接,你将被引导到我们刚刚构建的页面。如果你点击左侧菜单中的“控制台输出”链接,你将看到图构建的控制台输出中显示的内容。

../_images/chapter19-hudson-buildresult.png

构建的控制台输出

正如你所料,它显示了我们的八个测试(记住我们有七个单元测试和模块 doctest)都通过了。

在 Hudson 上使用 Nose

你可能想知道为什么我们创建了一个自定义构建脚本而不是使用 nose,因为说过使用 nose 比手动创建套件要好得多。

问题是 Jython Hudson 插件提供的 Jython 运行时没有额外的库,因此我们不能假设 nose 的存在。一种选择是在存储库中包含 nose 和源代码树,但这并不方便。

克服这个问题的一种方法是在构建脚本中编写 nose 的安装脚本。回到作业(也被 Hudson 用户界面称为“项目”),在左侧菜单中选择“配置”,转到配置的“构建”部分,并将我们作业的 Jython 脚本更改为

# Setup the environment
import os, sys, site, urllib2, tempfile
print "Base dir", os.getcwdu()
site_dir = os.path.join(os.getcwd(), 'site-packages')
if not os.path.exists(site_dir): os.mkdir(site_dir)
site.addsitedir(site_dir)
sys.executable = ''
os.environ['PYTHONPATH'] = ':'.join(sys.path)

# Get ez_setup:
ez_setup_path = os.path.join(site_dir, 'ez_setup.py')
if not os.path.exists(ez_setup_path):
    f = file(ez_setup_path, 'w')
    f.write(urllib2.urlopen('http://peak.telecommunity.com/dist/ez_setup.py').read())
    f.close()

# Install nose if not present
try:
    import nose
except ImportError:
    import ez_setup
    ez_setup.main(['--install-dir', site_dir, 'nose'])
    for mod in sys.modules.keys():
        if mod.startswith('nose'):
            del sys.modules[mod]
    for path in sys.path:
        if path.startswith(site_dir):
            sys.path.remove(site_dir)
    site.addsitedir(site_dir)
    import nose

# Run Tests!
nose.run(argv=['nosetests', '-v', '--with-doctest', '--with-xunit'])

脚本的前半部分是管道,用于下载 setuptools(ez_setup)并在其中设置一个环境。然后,我们检查 nose 的可用性,如果它不存在,我们使用 setuptools 安装它。

有趣的部分是最后一行

nose.run(argv=['nosetests', '-v', '--with-doctest', '--with-xunit'])

在这里,我们从 python 代码中调用 nose,但使用命令行语法。注意 --with-xunit 选项的使用。它为我们的测试生成与 JUnit 兼容的 XML 报告,这些报告可以被 Hudson 读取以生成非常有用的测试报告。默认情况下,nose 会在当前目录中生成一个名为 nosetests.xml 的文件。

要让 Hudson 知道报告在哪里,请滚动到配置中的“构建后操作”部分,选中“发布 JUnit 测试结果报告”,并在“测试报告 XML”输入框中输入“nosetests.xml”。按“保存”。如果 Hudson 指出 nosetests.xml“与任何内容都不匹配”,请不要担心,只需再次按“保存”。当然,它现在与任何内容都不匹配,因为我们还没有再次运行构建。

再次触发构建,构建完成后,点击它的链接(在“构建历史”框中或转到作业页面并按照“最后构建 […]”永久链接)。图Hudson 上的 Nose 输出显示了如果你查看“控制台输出”所看到的内容,而图Hudson 的测试报告显示了你在“测试结果”页面上看到的内容。

../_images/chapter19-hudson-consolewithnose.png

Hudson 上的 Nose 输出

../_images/chapter19-hudson-testresults.png

Hudson 的测试报告

在测试结果中导航是 Hudson 的一个非常强大的功能。但它在出现故障或大量测试时才真正发挥作用,在本例中并非如此。但我希望展示它的实际应用,因此我在代码中制造了一些故障以向你展示一些屏幕截图。查看图显示故障的测试报告和图测试结果随时间变化的图表,以了解你从 Hudson 获得的内容。

../_images/chapter19-hudson-testresultswithfailures.png

显示故障的测试报告

../_images/chapter19-hudson-testresultsgraph.png

测试结果随时间变化的图表

我们不得不使用稍微复杂一点的脚本才能将 Nose 和 Hudson 结合使用,但它有一个优点,即它可能在很长一段时间内保持不变,不像最初手动构建套件的脚本,它需要在每次创建新的测试模块时进行修改。

结论

测试是 Jython 使用的沃土,因为你可以利用 Python 的灵活性为 Java API 编写简洁的测试,这些测试也往往比用 JUnit 编写的测试更易读。特别是 doctest 在 Java 世界中没有对应的工具,它可以成为一种强大的方式,将自动化测试实践引入希望它简单易用的人们。

与持续集成工具(特别是 Hudson)的集成,可以让你从测试中获得最大收益,避免测试中断被忽视,并代表项目健康状况和演变的实时历史。