万字长文深度解析python 单元测试

文章目录

  • unittest
      • 基本概念
      • 基本用法
      • 命令行操作
      • 组织测试用例
      • 跳过测试&预期错误
      • subTest
      • unittest小结
  • doctest
      • 先谈pydoc
      • doctest与unittest
  • Mock
      • 初步理解Mock object
      • 使用Mock object
      • 定制化Mock object
          • return_value
          • side_effect
      • 配置Mock
      • patch
          • as a Decorator
          • as a Context Manager
      • patch.object
  • 写在篇后

unittest

基本概念

  Python unittest模块借鉴JUnit的思想发展而成,与其他语言的单元测试框架具有相似的风格。unittest支持测试自动化、配置共享、关机代码测试、将测试聚合到测试集合中,以及测试与报告框架相独立。关于测试,首先形式化的给出四个重要概念,它是unittest设计和应用的理论指导:

  • Test fixture表示测试执行前所需做的准备工作(测试执行所需要的固定环境),及其相关的cleanup操作,如创建临时或代理数据库等。
  • Test case是为某个特殊目标而编制的一组测试代码,它检查对特定输入集的特定响应。unittest提供了TestCase类,可用于创建测试用例。
  • Test suite是指一组test casetest suite或两者兼有,用于聚合应该一起执行的测试。
  • Test runner是协调测试执行并向用户提供测试结果的组件。test runner可以使用图形界面,文本界面,或返回特殊值来指示执行测试的结果。

基本用法

  可以通过继承unittest.TestCase类来实现一个Testcase子类,用来聚合一个或多个需要相同执行环境的测试方法。每个测试方法的定义均以test_开头。unittest.TestCase内置了众多方法用来测试输出结果与预期结果的一致性。这里展示一个测试三种字符串方法的脚本,来体会一下unittest的基本用法:

class TestStringMethods(unittest.TestCase):

    def test_upper(self):
        self.assertEqual('foo'.upper(), 'FOO')

    def test_isupper(self):
        self.assertTrue('FOO'.isupper())
        self.assertFalse('Foo'.isupper())

    def test_split(self):
        s = 'hello world'
        self.assertEqual(s.split(), ['hello', 'world'])
        # check that s.split fails when the separator is not a string
        with self.assertRaises(TypeError):
            s.split(2)

    def tearDown(self) -> None:
        print('cleanup after testing')

    def setUp(self) -> None:
      	print('preparation before testing')

        
if __name__ == '__main__':
    unittest.main(module='__main__')
# 输出结果
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK
preparation before testing
cleanup after testing
preparation before testing
cleanup after testing
preparation before testing
cleanup after testing

  在上面输出结果的第一行可以看到三个点,这里的每个点都代表一个测试用例(在测试时,每个以 test_ 开头的方法都是一个真正独立的测试用例)的结果。由于上面测试类中包含了三个测试用例,因此此处看到三个点,其中点代表测试用例通过。此处可能出现如下字符:

  • .:代表测试通过。
  • F:代表测试失败,F 代表 failure
  • E:代表测试出错,E 代表 error
  • s:代表跳过该测试,s 代表 skip

  在上面输出结果的横线下面看到了“Ran 3 tests in 0.000s”提示信息,这行提示信息说明本次测试运行了多少个测试用例。如果看到下面提示 OK,则表明所有测试用例均通过;setUptearDown方法分别在每一个test*方法执行之前和执行之后执行(注意,__init__()方法也是这样),setUp方法通常用于测试环境准备,tearDown做相应的cleanup操作。

  编写test case除了继承unittest.TestCase之外,还可以使用unittest.FunctionTestCase,紧接着上面的例子,我们可以这样写(但是官方不推荐使用这种,了解就好):

def test_upper():
    assert 'foo'.upper() == 'FOO'


def tearDown() -> None:
    print('cleanup after testing')


def setUp() -> None:
    print('preparation before testing')


new_test_case = unittest.FunctionTestCase(
    testFunc=test_upper,
    setUp=setUp,
    tearDown=tearDown,
    description='test'
)

if __name__ == '__main__':
    runner = unittest.TextTestRunner()
    runner.run(new_test_case)

  下面我简单统计了一下unittest模块中assert*内置方法。方法非常多,但是只需要记住几个常用的。其他的可以在使用到的时候再选择一个合适的。

self.assertTrue()
self.assertFalse()

self.assertEqual()
self.assertNotEqual()
self.assertEquals()
self.assertNotEquals()
self.assertGreater()
self.assertGreaterEqual()
self.assertLess()
self.assertLessEqual()

self.assertAlmostEqual()
self.assertNotAlmostEqual()
self.assertAlmostEquals()
self.assertNotAlmostEquals()
self.assertListEqual()
self.assertDictEqual()
self.assertSequenceEqual()
self.assertSetEqual()
self.assertTupleEqual()
self.assertCountEqual()
self.assertLogs()
self.assertMultiLineEqual()


self.assertIn()
self.assertNotIn()
self.assertIs()
self.assertIsNot()
self.assertIsInstance()
self.assertNotIsInstance()
self.assertIsNone()
self.assertIsNotNone()

self.assertRaises()
self.assertRaisesRegex()
self.assertRaisesRegexp()
self.assertRegex()
self.assertNotRegex()
self.assertRegexpMatches()
self.assertWarnsRegex()
self.assertWarns()

self.assertLogs()

命令行操作

  上面的例子是通过unittest.main()来执行测试,我们也可以通过命令行的方式来执行测试。例如,上面的例子我的工程结构如下图所示:
万字长文深度解析python 单元测试_第1张图片

  则可通过下面几种方式运行这些test case:

python -m unittest
python -m unittest discover  # 上面是此句的简写
python -m unittest discover -s tests  # 同上
python -m unittest tests.test_str
python -m unittest tests.test_str.TestStringMethods
python -m unittest tests.test_str.TestStringMethods.test_upper

python -m unittest tests/test_str.py
python -m unittest -v tests/test_str.py  # -v 表示verbose

  除了-v,unittest还有其他几个有用的命令行参数:

  • -q : quiet模式
  • -f : 遇到第一个测试失败时停止
  • -b: 测试过程中缓存标准输出流、标准错误流
  • -c: 捕捉control-c并等待当前正在执行的测试完时再中断并输出目前为止跑完的测试的报告
  • -k: 仅运行与给定子字符串匹配的测试,如, -k foo匹配foo_tests.SomeTest.test_something,bar_tests.SomeTest.test_foo, 但是不匹配bar_tests.FooTest.test_something
  • —locals:在tracebacks中显示local variables

  下面几个参数和unittest discovery有关:

  • -s`: 开始自动搜寻测试的目录(默认是当前文件夹), 该目录必须是 importable
  • -p: 匹配测试文件的pattern(默认为test*.py
  • -t: 项目的顶级目录,默认是开始搜寻的目录
python -m unittest discover -s tests -p "*_test.py"
python -m unittest discover test"*_test.py"

New in version 3.2: The command-line options -b, -c and -f were added.

New in version 3.5: The command-line option --locals.

New in version 3.7: The command-line option -k

组织测试用例

运行各种测试的顺序是通过根据字符串的内置顺序对测试方法名称进行排序来确定的

   对一个功能的验证往往是需要很多多测试用例,可以把测试用例集合在一起执行,这就产生了测试套件TestSuite 的概念,它是用来组装单个测试用例,规定用例的执行的顺序,而且TestSuite也可以嵌套TestSuite。使用Test Suite组织测试代码一般有以下两种使用方式:

  • suite.addTest()

    通过suite.addTest逐步添加单个测试用例, 类似的方法还有suite.addTests通过序列添加一个多个测试用例。添加完毕之后,可以使用suite.countTestCases()计算该suite对象的测试用例数量。

import unittest


class Widget():
    def __init__(self, name):
        self.name = name
        self._size = (50, 50)

    def size(self):
        return self._size

    def resize(self, tlp):
        self._size = tlp


class WidgetTestCase(unittest.TestCase):
    def setUp(self):
        self.widget = Widget('The widget')

    def test_default_widget_size(self):
        self.assertEqual(self.widget.size(), (50, 50),
                         'incorrect default size')

    def test_widget_resize(self):
        self.widget.resize((100, 150))
        self.assertEqual(self.widget.size(), (100, 150),
                         'wrong size after resize')


def suite():
    suite = unittest.TestSuite()
    suite.addTest(WidgetTestCase('test_default_widget_size'))
    suite.addTest(WidgetTestCase('test_widget_resize'))
    return suite


if __name__ == '__main__':
    # runner = unittest.TextTestRunner()
    # runner.run(suite())
    unittest.main()

  • unittest.TestLoader().discover()

    可以通过TestLoader().discover()方法指定测试用例的目录(目录必须包含__init__.py文件),根据文件名称匹配测试用例。discover()是一个自动搜索并组装测试用例的方法,TestLoader类还提供loadTestsFromTestCaseloadTestsFromModuleloadTestsFromNameloadTestsFromNames等方法加载Test Case。

    import unittest
    
    suites = unittest.TestLoader().discover('./tests', pattern='test_*.py', top_level_dir=None)
    runner = unittest.TextTestRunner()
    runner.run(suites)
    

跳过测试&预期错误

  unittest模块支持跳过一个测试用例中的某个测试方法甚至整个测试用例。使用unittest.skip()装饰器及其变体实现跳过测试,或者直接引发SkipTest异常;此外,还支持将测试标记为预期错误(expected failure),意思是该测试方法输出结果与预期结果会不一致,但是不应该被认为是测试失败。

import sys
import unittest


class MyTestCase(unittest.TestCase):

    @unittest.skip("demonstrating skipping")
    def test_nothing(self):
        self.fail("shouldn't happen")

    @unittest.skipIf(sys.version_info[0]==3,
                     "not supported in this python2 ")
    def test_format(self):
        # Tests that work for only a certain version of the library.
        pass

    @unittest.skipUnless(sys.platform.startswith("win"), "requires Windows")
    def test_windows_support(self):
        # windows specific testing code
        pass

    def test_maybe_skipped(self):
        if True:
            self.skipTest("external resource not available")
        pass

class ExpectedFailureTestCase(unittest.TestCase):
    @unittest.expectedFailure
    def test_fail(self):
        self.assertEqual(1, 0, "broken")

被跳过的测试方法的 setUp()tearDown() 不会被运行。被跳过的类的 setUpClass()tearDownClass()不会被运行。被跳过的模块的 setUpModule()tearDownModule() 不会被运行。

subTest

  在python 3.4中新增了subTest()特性,可以将测试方法里循环中的每一个迭代视为一个"隐形的"测试方法,示例如下,setTest()

class NumbersTest(unittest.TestCase):

    def test_even(self):
        """
        Test that numbers between 0 and 5 are all even.
        """
        for i in range(0, 6):
            with self.subTest(i=i):
                self.assertEqual(i % 2, 0)

unittest小结

  下面总结一下unittest模块运行单元测试的方式以及原理。unittest模块的TestLoader类有一个discover(self, start_dir, pattern='test*.py', top_level_dir=None)方法可以递归查找指定目录(start_dir)及其子目录下的全部测试模块。如果一个测试模块的名称符合pattern,将检查该模块是否包含 load_tests(loader, standard_tests, pattern) 函数,如果 load_tests() 函数存在,则由该函数负责加载本模块中的测试用例,并返回一个TestSuite对象;如果不存在,就会执行loadTestsFromModule(),查找该文件中派生自TestCase 的类包含的 test 开头的方法。

import unittest


from tests import test_01, test_02  # 两个包含TestCase派生类的测试模块

# ---------------方式1----------------------
unittest.main(module=test_02)

# ---------------方式2----------------------
runner = unittest.TextTestRunner()
# suite = unittest.TestLoader().loadTestsFromModule(module=test_01)
# suite = unittest.TestLoader().loadTestsFromTestCase(testCaseClass=test_01.WidgetTestCase)
# suite = unittest.TestLoader().loadTestsFromName('test_default_widget_size', module=test_01.WidgetTestCase)
# suite = unittest.TestLoader().loadTestsFromName('WidgetTestCase.test_default_widget_size', module=test_01)
# suite = unittest.TestLoader().loadTestsFromNames(['test_default_widget_size', 'test_widget_resize'],
#                                                  module=test_01.WidgetTestCase)

# suite = unittest.TestSuite()
# suite.addTest(test_01.WidgetTestCase('test_default_widget_size'))

suite = unittest.TestLoader().discover('./tests', pattern='test_*.py', top_level_dir=None)
test_result: unittest.TextTestResult = runner.run(suite)

print(isinstance(test_result, (unittest.TextTestResult, unittest.TestResult)))

doctest

先谈pydoc

 要说doctest不妨先了解一下python标准库pydoc模块,通过pydoc模块可以非常方便地查看、生成帮助文档。其文档的组织方式总是按如下顺序来显示一个模块中的全部内容:

  • 模块的文档说明:就是*.py 文件顶部的注释信息,这部分信息会被提取成模块的文档说明
  • CLASSES 部分:这部分会列出该模块所包含的全部类
  • FUNCTIONS 部分:这部分会列出该模块所包含的全部函数
  • DATA 部分:这部分会列出该模块所包含的全部成员变量
  • FILE 部分:这部分会显示该模块对应的源文件

举个例子,我写了一个名为tiny_example.py的模块如下:

"""
this module is for test pydoc
"""


def say_hi(name):
    """
    say hello to some one
    :param name: some one's name
    :return:
    """
    print(f'hello {name}')


class User:
    """
    define a User class, including name and age
    """
    NATIONAL = 'China'

    def __init__(self, name, age):
        """
        init method, get an instance of User
        :param name:
        :param age:
        """
        self.name = name
        self.age = age

    def eat(self, food):
        """
        eat method of User instance
        :param food: some food
        :return:
        """
        print('%is eating %s' % (self.name, food))

  则可以通过python -m pydoc tiny_example.py在命令行中查看模块文档:

万字长文深度解析python 单元测试_第2张图片

  当然也可以为模块生成html文件,在浏览器中查看文档:

  python -m pydoc tiny_example.py  # 为当前模块生成文档
  python -m pydoc directory_name  # 为该文件夹下面的模块生成文档
  python3 -m pydoc -p 端口号  # 启动本地服务器来查看文档信息
  python3 -m pydoc -b  # 同上
  python3 -m pydoc -w sys  # 生成html

  现在回到doctest,就是在写代码注释的段落中加入测试代码,在下面的示例中一共为该函数提供了 2 个测试用例,>>>之后的内容表示测试用例,接下来的一行则代表该测试用例的输出结果。写完之后启动测试也只需要简单的使用doctest.testmod()即可。

def say_hi(name):
    """
    say hello to some one
    e.g.

    >>> say_hi("jeffery")
    hello jeffery
    >>> say_hi("barry")
    hi barry

    :param name: some one's name
    :return:
    """

    print(f'hello {name}')


if __name__ == '__main__':
    import doctest
    doctest.testmod(verbose=True)

  运行上面的代码,测试用例say_hi("barry")将会报错:

**********************************************************************
File "/Users/jeffery/workspace/projects/Exporing/pydoctest/tiny_example.py", line 13, in __main__.say_hi
Failed example:
    say_hi("barry")
Expected:
    hi barry
Got:
    hello barry
**********************************************************************
1 items had failures:
   1 of   2 in __main__.say_hi
***Test Failed*** 1 failures.

 每个失败的测试用例结果都包含如下 4 部分:

  1. 第一部分:显示在哪个源文件的哪一行;
  2. 第二部分:Failed example,显示是哪个测试用例出错了;
  3. 第三部分:Expected,显示程序期望的输出结果。也就是在“>>>命令”的下一行给出的运行结果,它就是期望结果;
  4. 第四部分:Got,显示程序实际运行产生的输出结果。只有当实际运行产生的输出结果与期望结果一致时,才表明该测试用例通过;

doctest与unittest

  doctest中的测试用例可以通过DocTestSuite来提取模块中docstring的测试用例,并组建成TestSuite对象返回。依旧以上面的say_hi()函数为例,假设该函数所在模块为tests.test_doctest, 则:

import unittest
import doctest
from tests import test_doctest


runner = unittest.TextTestRunner()
suite = unittest.TestLoader().discover('./tests', pattern='test_*.py', top_level_dir=None)
s = doctest.DocTestSuite(module=test_doctest)  # 提取doctest用例,返回TestSuite对象
suite.addTests(s)  # 加到其他用例suite中(也可以不加,单独run)
test_result: unittest.TextTestResult = runner.run(suite)

  另外,doctest模块还提供了DocFileSuite从文本文件提取测试用例,更多用法请参考官方文档

Mock

  Mock是Python中一个用于支持单元测试的库,它的主要功能是使用mock对象替代掉指定的Python对象,以达到模拟对象的行为。在Python 3.3及之后被整合在unittest.mock模块中,更早的版本可以通过pip install mock进行安装,接下来,我们就一步步探索一下Mock的使用方式。

初步理解Mock object

unittest.mock实现了一个Mock类,其使用非常灵活,首先实例化一个Mock对象:

>>> from unittest.mock import Mock
>>> mock = Mock()
>>> mock
<Mock id='4561344720'>

​ 现在,你可以通过将其作为参数传递给函数或重新定义另一个对象来实现对象的替换:

# Pass mock as an argument to do_something()
do_something(mock)

# Patch the json library
json = mock

​ 按道理,当你使用mock替换代码中的对象时,它必须看起来像它正在替换的真实对象吧?否则,这岂不是一通胡乱操作?例如,如果你准备 mocking json库然后调用dumps(),那么你的mock对象也必须包含dumps()方法。为了实现这个功能,mock对象在你调用某一个方法属性时,创建这些方法和属性(称为Lazy Attributes and Methods)。

>>> mock.some_attribute
<Mock name='mock.some_attribute' id='4394778696'>
>>> mock.do_something()
<Mock name='mock.do_something()' id='4394778920'>

​ 也正是因为mock对象可以动态创建任意属性,因此适合替换任何对象。使用前面的示例,如果你mocking json库并调用dumps(),则mock对象将创建该方法,以便其接口可以匹配库原来的接口:

>>> json = Mock()
>>> json.dumps()
<Mock name='mock.dumps()' id='4392249776'>			

​ 请注意, 这个mock对象的dumps()方法有两个关键特点:

  1. 不同于原来的dumps()方法,这个模拟方法不需要参数(实际上,它会接受您传递给它的任何参数)

  2. dumps()方法的返回值也是一个Mock类实例。Mock以递归方式定义其他Mock类实例的特性允许你在复杂情况下游刃有余。

    >>> json = Mock()
    >>> json.loads('{"k": "v"}').get('k')
    <Mock name='mock.loads().get()' id='4379599424'>
    

使用Mock object

​ Mock类实例存储有关你如何使用它们的数据(*此处划重点*),如,你可以看到一个方法是否被调用、调用了几次、怎么调用的等等:

>>> from unittest.mock import Mock

>>> # Create a mock object
... json = Mock()

>>> json.loads('{"key": "value"}')
<Mock name='mock.loads()' id='4550144184'>

>>> # You know that you called loads() so you can
>>> # make assertions to test that expectation
... json.loads.assert_called()
>>> json.loads.assert_called_once()
>>> json.loads.assert_called_with('{"key": "value"}')
>>> json.loads.assert_called_once_with('{"key": "value"}')

>>> json.loads('{"key": "value"}')
<Mock name='mock.loads()' id='4550144184'>

>>> # If an assertion fails, the mock will raise an AssertionError
... json.loads.assert_called_once()
Traceback (most recent call last):
  File "", line 1, in <module>
  File "/usr/local/Cellar/python/3.6.5/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/mock.py", line 795, in assert_called_once
    raise AssertionError(msg)
AssertionError: Expected 'loads' to have been called once. Called 2 times.

>>> json.loads.assert_called_once_with('{"key": "value"}')
Traceback (most recent call last):
  File "", line 1, in <module>
  File "/usr/local/Cellar/python/3.6.5/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/mock.py", line 824, in assert_called_once_with
    raise AssertionError(msg)
AssertionError: Expected 'loads' to be called once. Called 2 times.

>>> json.loads.assert_not_called()
Traceback (most recent call last):
  File "", line 1, in <module>
  File "/usr/local/Cellar/python/3.6.5/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/mock.py", line 777, in assert_not_called
    raise AssertionError(msg)
AssertionError: Expected 'loads' to not have been called. Called 2 times.

​ 在上例中,assert_called()确保你调用了该方法,如果没有调用就会报错;而assert_called_once()检查你是否只调用了一次该方法。这两个断言函数都有相应的变体来检查传递给该方法的参数:

  • .assert_called_with(*args, **kwargs)

  • .assert_called_once_with(*args, **kwargs)

    ​ 要通过这些断言测试,必须使用传递给实际方法的相同参数调用mocked方法(*此处划重点*)

>>> json = Mock()
>>> json.loads(s='{"key": "value"}')
>>> json.loads.assert_called_with('{"key": "value"}')
Traceback (most recent call last):
  File "", line 1, in <module>
  File "/usr/local/Cellar/python/3.6.5/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/mock.py", line 814, in assert_called_with
    raise AssertionError(_error_message()) from cause
AssertionError: Expected call: loads('{"key": "value"}')
Actual call: loads(s='{"key": "value"}')
>>> json.loads.assert_called_with(s='{"key": "value"}')

​ 另外,你还可以查看mock对象的特殊属性来测试你的应用程序如何使用被替换对象:

>>> from unittest.mock import Mock

>>> # Create a mock object
... json = Mock()
>>> json.loads('{"key": "value"}')
<Mock name='mock.loads()' id='4391026640'>

>>> # Number of times you called loads():
... json.loads.call_count
1
>>> # The last loads() call:
... json.loads.call_args
call('{"key": "value"}')
>>> # List of loads() calls:
... json.loads.call_args_list
[call('{"key": "value"}')]
>>> # List of calls to json's methods (recursively):
... json.method_calls
[call.loads('{"key": "value"}')]

​ 你可以使用这些属性来编写测试代码,以确保程序是否按照你的预期来运行。这里先总结一下Mock对象的各类方法

  1. 构造方法

    __init__(name=None, return_value=DEFAULT, side_effect=None, spec=None)spec设置的是mock对象的属性,可以是property或者方法,也可以是其他的列表字符串或者其他的python类。

    spec can be either a list of strings or an existing object (a class or instance) that acts as the specification for the mock object. If you pass in an object then a list of strings is formed by calling dir on the object (excluding unsupported magic attributes and methods). Accessing any attribute not in this list will raise an AttributeError.

  2. assert方法

    assert_called()  # 断言该mock对象被调用了
    assert_called_once()  # 断言该mock对象被调用了一次
    assert_called_once_with()  # 断言该mock对象以什么参数被调用了一次
    assert_called_with()  # 断言该mock对象以什么参数被调用过
    assert_not_called()  # 断言该mock对象没有调用过
    assert_any_call()  # 用于检查测试的mock对象在测试例程中是否调用了方法
    assert_has_calls()  # 检查是否按照正确的顺序和正确的参数进行调用的
    
  3. 统计方法

    called() # 跟踪mock对象所做的任意调用的访问器,返回bool值
    call_count()  # 调用次数
    call_args()  # 最近一次调用参数
    call_args_list()  # 所有调用的参数list
    mock_calls()  # 
    method_calls()  # 试一个mock对象都调用了哪些方法,结果是一个list
    
  4. 实用方法

    attach_mock()  # 将一个mock对象添加到另一个mock对象中
    configure_mock()  # 配置Mock对象,包括name, side_effect,return_value等
    mock_add_spec()  # 给mock对象添加新的属性
    reset_mock()  # 将mock对象恢复到测试之前的状态
    
  5. 属性

    name  # mock对象的唯一标识
    return_value  # 返回值
    side_effect  # 当其不是DEFAULT时,覆盖return_value
    

定制化Mock object

return_value

​ 使用mock模块的重要原因之一就是为了能够在测试期间控制代码的行为,最简单的一种方式就是指定函数的返回值(return_value)。让我们用一个例子来看看它是如何工作的。

​ 首先,创建一个名为my_calendar.py的文件并编写一个is_weekday()函数,用于判断今天是否是工作日。最后,编写一个测试,确保函数的正确性:

from datetime import datetime

def is_weekday():
    today = datetime.today()
    # Python's datetime library treats Monday as 0 and Sunday as 6
    return (0 <= today.weekday() < 5)

# Test if today is a weekday
assert is_weekday()

​ 由于这里测试的是今天是否为工作日,因此结果取决于你进行测试的那一天,这就意外着你今天测试成功了,说不定过两天到了周末,就测试失败了。为了使测试结果的稳定性,我们可以使用Mock来实现该测试:

import datetime
from unittest.mock import Mock

# Save a couple of test days
tuesday = datetime.datetime(year=2019, month=1, day=1)
saturday = datetime.datetime(year=2019, month=1, day=5)

# Mock datetime to control today's date
datetime = Mock()

def is_weekday():
    today = datetime.datetime.today()
    # Python's datetime library treats Monday as 0 and Sunday as 6
    return (0 <= today.weekday() < 5)

# Mock .today() to return Tuesday
datetime.datetime.today.return_value = tuesday
# Test Tuesday is a weekday
assert is_weekday()
# Mock .today() to return Saturday
datetime.datetime.today.return_value = saturday
# Test Saturday is not a weekday
assert not is_weekday()

​ 在上面示例中,.today()是变成了一个模拟,并指定了它的return_value。这样,当你调用.today()时,它会返回你指定的日期时间,实现测试的稳定性。

side_effect

​ 在更复杂的场景中,也许仅仅控制return_value并不足以实现相关的业务逻辑。比如有时候,你期望当一个测试函数被多次调用时,你想让函数返回不同的值甚至引发异常,则可以使用.side_effect来做到这一点。我们再写一个函数来解释这一特性:

import requests

def get_holidays():
    r = requests.get('http://localhost/api/holidays')
    if r.status_code == 200:
        return r.json()
    return None

get_holidays()向localhost服务器发出请求,试图获得holiday信息。如果服务器响应成功,get_holidays()将返回一个字典。否则,该方法将返回None。可以通过设置requests.get.side_effect来测试get_holidays()如何响应连接超时:

import unittest
from requests.exceptions import Timeout
from unittest.mock import Mock

# Mock requests to control its behavior
requests = Mock()

def get_holidays():
    r = requests.get('http://localhost/api/holidays')
    if r.status_code == 200:
        return r.json()
    return None

class TestCalendar(unittest.TestCase):
    def test_get_holidays_timeout(self):
        # Test a connection timeout
        requests.get.side_effect = Timeout
        with self.assertRaises(Timeout):
            get_holidays()

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

​ 如果你想要让结果更加动态,可以将.side_effect设置为一个函数,该函数与被mocking的函数共享参数(下面例子中,传入request.get()的参数,也会传入log_request()):

import requests
import unittest
from unittest.mock import Mock

# Mock requests to control its behavior
requests = Mock()

def get_holidays():
    r = requests.get('http://localhost/api/holidays')
    if r.status_code == 200:
        return r.json()
    return None

class TestCalendar(unittest.TestCase):
    def log_request(self, url):
        # Log a fake request for test output purposes
        print(f'Making a request to {url}.')
        print('Request received!')

        # Create a new Mock to imitate a Response
        response_mock = Mock()
        response_mock.status_code = 200
        response_mock.json.return_value = {
            '12/25': 'Christmas',
            '7/4': 'Independence Day',
        }
        return response_mock

    def test_get_holidays_logging(self):
        # Test a successful, logged request
        requests.get.side_effect = self.log_request
        assert get_holidays()['12/25'] == 'Christmas'


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

.side_effect也可以赋值为一个可迭代对象,其中必须包含返回值,异常或两者的皆有。每次调用mocked方法时,iterable都会返回下一个值。例如,您可以在Timeout返回成功响应后测试重试:

mport unittest
from requests.exceptions import Timeout
from unittest.mock import Mock

# Mock requests to control its behavior
requests = Mock()

def get_holidays():
    r = requests.get('http://localhost/api/holidays')
    if r.status_code == 200:
        return r.json()
    return None

class TestCalendar(unittest.TestCase):
    def test_get_holidays_retry(self):
        # Create a new Mock to imitate a Response
        response_mock = Mock()
        response_mock.status_code = 200
        response_mock.json.return_value = {
            '12/25': 'Christmas',
            '7/4': 'Independence Day',
        }
        # Set the side effect of .get()
        requests.get.side_effect = [Timeout, response_mock]
        # Test that the first request raises a Timeout
        with self.assertRaises(Timeout):
            get_holidays()
        # Now retry, expecting a successful response
        assert get_holidays()['12/25'] == 'Christmas'
        # Finally, assert .get() was called twice
        assert requests.get.call_count == 2

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

这里划重点,总结一下side_effect可以是一个函数、一个Exception类、或是一个包含二者的可迭代对象。当作为函数时,该函数将会传入和mocked方法一样的参数;当作为Exception,则不会返回,而是直接抛出异常。

配置Mock

​ 设置Mock上.return_value.side_effect可以使用上面例子所采用的方法。但是,那并不是最灵活的方式,所以,本节主要探讨一下Mock各种属性的设置方式。

​ 首先,你可以在初始化Mock实例时通过指定某些属性来配置Mock:

>>> mock = Mock(side_effect=Exception)
>>> mock()
Traceback (most recent call last):
  File "", line 1, in <module>
  File "/usr/local/Cellar/python/3.6.5/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/mock.py", line 939, in __call__
    return _mock_self._mock_call(*args, **kwargs)
  File "/usr/local/Cellar/python/3.6.5/Frameworks/Python.framework/Versions/3.6/lib/python3.6/unittest/mock.py", line 995, in _mock_call
    raise effect
Exception

>>> mock = Mock(name='Real Python Mock')
>>> mock
<Mock name='Real Python Mock' id='4434041432'>

>>> mock = Mock(return_value=True)
>>> mock()
True

​ 虽然可以在Mock实例上设置.side_effect.return_value,但是.name等其他属性只能通过.__ init __().configure_mock()设置。如果你尝试在实例上设置Mock实例的.name属性,你将会得到意想不到的结果:

>>> mock = Mock(name='Real Python Mock')
>>> mock.name
<Mock name='Real Python Mock.name' id='4434041544'>

>>> mock = Mock()
>>> mock.name = 'Real Python Mock'
>>> mock.name
'Real Python Mock'

​ 你可以使用.configure_mock()配置现有的Mock实例:

>>> mock = Mock()
>>> mock.configure_mock(return_value=True)
>>> mock()
True

​ 可以将一个字典数据传到.configure_mock()Mock .__ init __(),实现对Mock实例的属性配置:

# Verbose, old Mock
response_mock = Mock()
response_mock.json.return_value = {
    '12/25': 'Christmas',
    '7/4': 'Independence Day',
}

# Shiny, new .configure_mock()
holidays = {'12/25': 'Christmas', '7/4': 'Independence Day'}
response_mock = Mock(**{'json.return_value': holidays})

patch

​ 在了解了mock对象之后,我们来看两个方便测试的函数:patchpatch.object。这两个函数都会返回一个mock内部的类实例,这个类是class _patch。返回的这个类实例既可以作为函数的装饰器,也可以作为类的装饰器,也可以作为上下文管理器。使用patch或者patch.object的目的是为了控制mock的范围,意思就是在一个函数范围内,或者一个类的范围内,或者with语句的范围内mock掉一个对象

as a Decorator

​ 如果要在整个测试函数中mocking某个对象,可以使用patch()作为函数装饰器。为了探究它的工作原理,将逻辑代码和测试代码放入单独的文件来重新组织my_calendar.py文件:

import requests
from datetime import datetime


def is_weekday():
    today = datetime.today()
    # Python's datetime library treats Monday as 0 and Sunday as 6
    return 0 <= today.weekday() < 5


def get_holidays():
    r = requests.get('http://localhost/api/holidays')
    if r.status_code == 200:
        return r.json()
    return None

​ 这些函数现在位于单独的文件中,与测试代码完全分离。接下来,在test.py文件中编写测试代码:

import unittest
from my_calendar import get_holidays
from requests.exceptions import Timeout
from unittest.mock import patch


class TestCalendar(unittest.TestCase):
    @patch('my_calendar.requests')
    def test_get_holidays_timeout(self, mock):
            mock.get.side_effect = Timeout
            with self.assertRaises(Timeout):
                get_holidays()
                mock.get.assert_called_once()


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

​ 上面测试代码中,首先在测试函数范围内创建了Mock类实例 mock,该mock对象在测试函数范围内替换了my_calendar.py中的requests

Technical Detail: patch() returns an instance of MagicMock, which is a Mocksubclass. MagicMock is useful because it implements most magic methods for you, such as .__len__(), .__str__(), and .__iter__(), with reasonable defaults.

as a Context Manager

​ 有时候,你会想将patch()作为上下文管理器来使用,比如:

  • 你只想模拟替换测试范围的一部分对象;

  • 您已经使用了太多的装饰器或参数,这会降低测试代码的可读性;

    举个例子(更多例子可以参考cookbook):

import unittest
from my_calendar import get_holidays
from requests.exceptions import Timeout
from unittest.mock import patch

class TestCalendar(unittest.TestCase):
    def test_get_holidays_timeout(self):
        with patch('my_calendar.requests') as mock_requests:
            mock_requests.get.side_effect = Timeout
            with self.assertRaises(Timeout):
                get_holidays()
                mock_requests.get.assert_called_once()

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

patch.object

​ 到目前为止,我们替换的是整个完整的对象,但有时也许只想模拟替换一个对象的一部分。这时候可以使用path.object()来实现:

import unittest
from my_calendar import requests, get_holidays
from unittest.mock import patch

class TestCalendar(unittest.TestCase):
    @patch.object(target=requests, attribute='get', side_effect=requests.exceptions.Timeout)
    def test_get_holidays_timeout(self, mock_requests):
            with self.assertRaises(requests.exceptions.Timeout):
                get_holidays()

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

除了对象和属性,你还可以使用patch.dict()模拟替换dict

写在篇后

  本文主要介绍了unittest的基本用法,包括基本特性、命令行操作、测试流程组织、Mock模块的灵活运用等。俗话说,写不写测试的代码,就是耍流氓,希望看了这篇之后,你可以不做流氓!

你可能感兴趣的:(Python)