Section 192.1: 在 unittest.TestCase 中的测试设置和清理

有时我们希望为每个测试准备一个上下文。setUp 方法会在类中的每个测试之前运行。tearDown 方法会在每个测试结束时运行。这些方法是可选的。请记住,TestCase 常用于协作式多重继承,因此您应该始终在这些方法中调用 super,以确保基类的 setUp 和 tearDown 方法也能被调用。TestCase 的基实现提供了空的 setUp 和 tearDown 方法,以便可以调用它们而不会引发异常:

import unittest

class SomeTest(unittest.TestCase):
  def setUp(self):
    super(SomeTest, self).setUp()
    self.mock_data = [1,2,3,4,5]

  def test(self):
    self.assertEqual(len(self.mock_data), 5)

  def tearDown(self):
    super(SomeTest, self).tearDown()
    self.mock_data = []

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

在 Python 2.7+ 中,还有 addCleanup 方法,它注册了在测试运行后要调用的函数。与 tearDown(只有在 setUp 成功时才会被调用)不同,通过 addCleanup 注册的函数即使在 setUp 中发生未处理的异常也会被调用。例如,此方法通常用于移除在测试运行期间注册的各种模拟对象:

import unittest
import some_module

class SomeOtherTest(unittest.TestCase):
  def setUp(self):
    super(SomeOtherTest, self).setUp()

    # Replace `some_module.method` with a `mock.Mock`
    my_patch = mock.patch.object(some_module, 'method')
    my_patch.start()

    # When the test finishes running, put the original method back.
    self.addCleanup(my_patch.stop)

通过这种方式注册清理函数的另一个好处是,它允许程序员将清理代码放在设置代码旁边,并且在子类继承者忘记在 tearDown 中调用 super 时,它也能保护您。

Section 192.2: 断言异常

您可以通过内置的 unittest 通过两种不同的方法来测试函数是否抛出异常。

使用上下文管理器

def division_function(dividend, divisor):
  return dividend / divisor

class MyTestCase(unittest.TestCase):

def test_using_context_manager(self):
  with self.assertRaises(ZeroDivisionError):
    x = division_function(1, 0)

这将运行上下文管理器中的代码,并且,如果它成功运行,测试将失败,因为没有抛出异常。如果代码抛出了正确类型的异常,测试将继续。

您还可以获取抛出异常的内容,如果需要对它执行额外的断言。

class MyTestCase(unittest.TestCase):
  def test_using_context_manager(self):
    with self.assertRaises(ZeroDivisionError) as ex:
      x = division_function(1, 0)
    self.assertEqual(ex.message, 'integer division or modulo by zero')

通过提供可调用函数

def division_function(dividend, divisor):
  """
  Dividing two numbers.
  :type dividend: int
  :type divisor: int
  :raises: ZeroDivisionError if divisor is zero (0).
  :rtype: int
  """
  return dividend / divisor

class MyTestCase(unittest.TestCase):
  def test_passing_function(self):
    self.assertRaises(ZeroDivisionError, division_function, 1, 0)

要检查的异常必须是第一个参数,并且必须将可调用函数作为第二个参数传递。指定的任何其他参数将直接传递给被调用的函数,允许您指定触发异常的参数。

Section 192.3: 测试异常

当输入错误时,程序会抛出错误。因此,需要确保在实际输入错误时抛出错误。为此,我们需要检查确切的异常。在这个例子中,我们将使用以下异常:

class WrongInputException(Exception):
  pass

当输入错误时,会抛出此异常。在以下上下文中,我们总是期望输入的是数字。

def convert2number(random_input):
  try:
    my_input = int(random_input)
  except ValueError:
    raise WrongInputException("Expected an integer!")
  return my_input

要检查是否抛出了异常,我们使用 assertRaises 来检查该异常。assertRaises 可以以两种方式使用:

  1. 使用常规函数调用。第一个参数是异常类型,第二个参数是一个可调用对象(通常是函数),其余参数将传递给该可调用对象。
  2. 使用 with 子句,只向函数提供异常类型。这有一个优点,可以执行更多代码,但应谨慎使用,因为多个函数可能使用相同的异常,这可能会有问题。例如:
with self.assertRaises(WrongInputException):
    convert2number("not a number")

以下测试用例中实现了第一种方法:

import unittest

class ExceptionTestCase(unittest.TestCase):
  def test_wrong_input_string(self):
    self.assertRaises(WrongInputException, convert2number, "not a number")

  def test_correct_input(self):
    try:
      result = convert2number("56")
      self.assertIsInstance(result, int)
    except WrongInputException:
      self.fail()

可能还需要检查不应抛出的异常。然而,当抛出异常时,测试会自动失败,因此可能根本不需要。为了展示选项,第二个测试方法展示了如何检查不应抛出的异常。基本上,这是捕获异常并使用 fail 方法使测试失败。

Section 192.4: 在单元测试中选择断言

虽然 Python 有一个 assert 语句,但 Python 单元测试框架提供了更适合测试的断言:它们在失败时更具信息性,并且不依赖于执行的调试模式。

最简单的断言可能是 assertTrue,可以这样使用:

import unittest

class SimplisticTest(unittest.TestCase):
  def test_basic(self):
    self.assertTrue(1 + 1 == 2)

这将正常运行,但如果将上述行替换为

self.assertTrue(1 + 1 == 3)

将失败。

assertTrue 断言可能是最通用的断言,因为任何测试都可以被表示为某种布尔条件,但通常有更好的选择。当测试相等性时,如上所示,最好写为

self.assertEqual(1 + 1, 3)

当前者失败时,消息是

======================================================================
FAIL: test (__main__.TruthTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "stuff.py", line 6, in test
self.assertTrue(1 + 1 == 3)
AssertionError: False is not true

当前者失败时,消息是

======================================================================
FAIL: test (__main__.TruthTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "stuff.py", line 6, in test
self.assertEqual(1 + 1, 3)
AssertionError: 2 != 3

更具信息性(它实际上评估了左侧的结果)。

您可以在标准文档中找到断言的列表。一般来说,选择最适合条件的断言是个好主意。因此,如上所示,对于断言 1 + 1 == 2,最好使用 assertEqual 而不是 assertTrue。同样,对于断言 a is None,最好使用 assertIsNone 而不是 assertEqual。

请注意,断言也有否定形式。因此,assertEqual 有其否定形式 assertNotEqual,assertIsNone 有其否定形式 assertIsNotNone。再次强调,适当使用否定形式将导致更清晰的错误消息。

Section 192.5: 使用 pytest 的单元测试

安装 pytest:

pip install pytest

准备测试:

mkdir tests
touch tests/test_docker.py

在 docker_something/helpers.py 中要测试的函数:

from subprocess import Popen, PIPE

# 这个 Popen 被用 fixture `all_popens` 替换了
def copy_file_to_docker(src, dest):
    try:
        result = Popen(['docker', 'cp', src, 'something_cont:{}'.format(dest)],
                       stdout=PIPE, stderr=PIPE)
        err = result.stderr.read()
        if err:
            raise Exception(err)
    except Exception as e:
        print(e)
    return result

def docker_exec_something(something_file_string):
    fl = Popen(["docker", "exec", "-i", "something_cont", "something"],
               stdin=PIPE, stdout=PIPE, stderr=PIPE)
    fl.stdin.write(something_file_string)
    fl.stdin.close()
    err = fl.stderr.read()
    fl.stderr.close()
    if err:
        print(err)
        exit()

    result = fl.stdout.read()
    print(result)

在 test_docker.py 中导入测试:

import os
from tempfile import NamedTemporaryFile
import pytest
from subprocess import Popen, PIPE

from docker_something import helpers
copy_file_to_docker = helpers.copy_file_to_docker
docker_exec_something = helpers.docker_exec_something

在 test_docker.py 中模拟文件对象:

class MockBytes():
    '''用于收集字节

    '''
    all_read = []
    all_write = []
    all_close = []

    def read(self, *args, **kwargs):
        # print('read', args, kwargs, dir(self))
        self.all_read.append((self, args, kwargs))

    def write(self, *args, **kwargs):
        # print('wrote', args, kwargs)
        self.all_write.append((self, args, kwargs))

    def close(self, *args, **kwargs):
        # print('closed', self, args, kwargs)
        self.all_close.append((self, args, kwargs))

    def get_all_mock_bytes(self):
        return self.all_read, self.all_write, self.all_close

在 test_docker.py 中使用 pytest 进行猴子补丁:

@pytest.fixture
def all_popens(monkeypatch):
    '''这个 fixture 替换 / 模拟了内置的 Popen
    并将 stdin、stdout、stderr 替换为 MockBytes 对象
    注意:monkeypatch 是神奇地导入的
    '''
    all_popens = []

    class MockPopen(object):
        def __init__(self, args, stdout=None, stdin=None, stderr=None):
            all_popens.append(self)
            self.args = args
            self.byte_collection = MockBytes()
            self.stdin = self.byte_collection
            self.stdout = self.byte_collection
            self.stderr = self.byte_collection
            pass
    monkeypatch.setattr(helpers, 'Popen', MockPopen)

    return all_popens

在 test_docker.py 文件中以 test_ 为前缀的示例测试:

def test_docker_install():
    p = Popen(['which', 'docker'], stdout=PIPE, stderr=PIPE)
    result = p.stdout.read()
    assert 'bin/docker' in result

def test_copy_file_to_docker(all_popens):
    result = copy_file_to_docker('asdf', 'asdf')
    collected_popen = all_popens.pop()
    mock_read, mock_write, mock_close = collected_popen.byte_collection.get_all_mock_bytes()
    assert mock_read
    assert result.args == ['docker', 'cp', 'asdf', 'something_cont:asdf']

def test_docker_exec_something(all_popens):
    docker_exec_something(something_file_string)
    collected_popen = all_popens.pop()
    mock_read, mock_write, mock_close = collected_popen.byte_collection.get_all_mock_bytes()
    assert len(mock_read) == 3
    something_template_stdin = mock_write[0][1][0]
    these = [os.environ['USER'], os.environ['password_prod'], 'table_name_here', 'test_vdm',
             'col_a', 'col_b', '/tmp/test.tsv']
    assert all([x in something_template_stdin for x in these])

逐个运行测试:

py.test -k test_docker_install tests
py.test -k test_copy_file_to_docker tests
py.test -k test_docker_exec_something tests

运行 tests 文件夹中的所有测试:

py.test -k test_ tests

Section 192.6: 使用 unittest.mock.create_autospec 模拟函数

模拟函数的一种方法是使用 create_autospec 函数,它将根据对象的规格来模拟对象。对于函数,我们可以使用它来确保它们被正确调用。

在 custom_math.py 中有一个函数 multiply:

def multiply(a, b):
  return a * b

在 process_math.py 中有一个函数 multiples_of:

from custom_math import multiply

def multiples_of(integer, *args, num_multiples=0, **kwargs):
    """
    :rtype: list
    """
    multiples = []

    for x in range(1, num_multiples + 1):
        """
        在这里传递 args 和 kwargs,如果向 multiples_of 函数传递了值,只会引发 TypeError,
        否则它们将被忽略。这样我们可以测试 multiples_of 是否被正确使用。这里是为了说明
        create_autospec 的工作方式,不推荐用于生产代码。
        """
        multiple = multiply(integer, x, *args, **kwargs)
        multiples.append(multiple)

    return multiples

我们可以通过模拟 multiply 来单独测试 multiples_of。以下示例使用了 Python 标准库 unittest,但它也可以与其他测试框架一起使用,例如 pytest 或 nose:

from unittest.mock import create_autospec
import unittest

# 我们导入整个模块,以便可以模拟 multiply
import custom_math
custom_math.multiply = create_autospec(custom_math.multiply)
from process_math import multiples_of

class TestCustomMath(unittest.TestCase):
    def test_multiples_of(self):
        multiples = multiples_of(3, num_multiples=1)
        custom_math.multiply.assert_called_with(3, 1)

    def test_multiples_of_with_bad_inputs(self):
        with self.assertRaises(TypeError) as e:
            multiples_of(1, "extra arg", num_multiples=1)  # 这应该引发一个 TypeError