Python单元测试

前言

因为Python是动态语言。非常动态,不写单元测试真心没法确认自己的代码是正确的。
即使有单元测试,也只证实测试过的代码是可靠的。本文就是来系统介绍下Python中的单元测试。

基础知识

  1. 最简单的方法是调用assert函数。并有nose包支持。 有个现成的包nose,安装之后,将提供nosetests命令,该命令会加载所有以test_开头的文件,然后执行其中所有以test_开头的函数。

nosetests -v

这种方法尽管简单,但却在很多小的项目中广泛使用且工作良好。除了nose,它们不需要其他工具或库,而且只依赖assert就足够了。
2. Python标准库unittest
用起来也比较简单。只需要创建继承自unittest.TestCase的类,并且写一个运行测试的方法。

import unittest

class TestKey(unittest.TestCase):
    def test_key(self):
        a = ['a','b']
        b = ['b']
        self.assertEqual(a,b) 

有两种运行方法:
1. 同上面的nosetests,但是要求文件名是test_开头。
2. python -m unittest module_name

unittest更多介绍

unittest有很多以assert开头的方法,用来特化测试。如asertDictEqual、assertEqual等。
也可以使用fail(msg)方法有意让某个测试立刻失败。

另外还有如unittest.skip装饰器和unittest.TestCase.skipTest()可以忽略一些测试。

import unittest

try:
    import mylib
except ImportError:
    mylib = None
        
class TestSkipped(unittest.TestCase):
    @unittest.skip("Do not run this")
    def test_fail(self):
        self.fail("This should not be run")

    @unittest.skipIf(mylib is None, "mylib is not available")
    def test_mylib(self):
        self.assertEqual(mylib.foobar(0, 42))

    def test_skip_at_runtime(self):
        if True:
            self.skipTest("Finally I don't want to run it")

输出结果:

python -m unittest -v test_skip
test_fail (test_skip.TestSkipped) … skipped ‘Do not run this’
test_mylib (test_skip.TestSkipped) … skipped ‘mylib is not available’
test_skip_at_runtime (test_skip.TestSkipped) … skipped “Finally I don’t want to run it”


Ran 3 tests in 0.000s

OK (skipped=3)

在许多场景中,需要在运行某个测试前后执行一组通用的操作。(这在web开发中,测试数据库很常见)。
unittest提供了两个特殊的方法setUp和tearDown,它们会在类的每个测试方法调用前后执行一次。

如下例所求。

import unittest

class TestMe(unittest.TestCase):
    def setUp(self):
        self.list = [1,2,3]

    def test_length(self):
        self.list.append(4)
        self.assertEqual(len(self.list),4)


    def test_has_one(self):
        self.assertEqual(len(self.list),3)
        self.assertIn(1,self.list)

    def tearDown(self):
        self.list = None

这样在执行每个测试前会先执行setUp方法。执行完测试后,再执行tearDown方法。

fixture

在单元测试中,fixture表示”测试前创建,测试后销毁”的(辅助性)组件。
unittest只提供了setUp和tearDown函数。不过,是有机制可以hook这两个函数的。
fixturesPython模块提供了一种简单的创建fixture类和对象的机制,如useFixture方法。

fixtures模块提供了一些内置的fixture,如fixtures.EnviromentVariable,对于在os.environ中添加或修改变量很有用,并且变量会在测试退出后重置,如下所求:

import fixtures
import os

class TestEnviron(fixtures.TestWithFixtures):

    def test_environ(self):
        fixture = self.useFixture(fixtures.EnvironmentVariable("FOOBAR","42"))
        self.assertEqual(os.environ.get("FOOBAR"),"42")

    def test_environ_no_fixture(self):
        self.assertEqual(os.environ.get("FOOBAR"),None)

fixtures.TestWithFixtures是unittest.testCase的子类。
大概看了下源码。unittest.testCase有个cleanUp的列表。这个列表里的东西会在tearDown()之后执行。
以达到执行完测试之后,把环境变量还原的目的。

模拟(mocking)

如果正在开发一个HTTP客户端,要想部署HTTP服务器并测试所有场景,令其返回所有可能值,几乎是不可能的。(至少会非常复杂)。此外,测试所有失败场景也是极其困难的。
一种更简单的方式是创建一组根据这些特定场景进行建模的mock对象(模拟对象),并利用它们作为测试环境对代码进行测试。

Python标准库中用来创建mock对象的库名为mock
从Python3.3开始,被命名为unit.mock,合并到Python标准库。因此为兼容,可以用下面的代码。

try:
    from unittest import mock
except ImportError:
    import mock

mock的基本用法。

>>> from unittest import mock
>>> m = mock.Mock()
>>> m.some_method.return_value = 42
>>> m.some_method()
42
>>> def print_hello():
...     print("hello world!")
... 
>>> m.some_method.side_effect = print_hello
>>> m.some_method()
hello world!
>>> def print_hello():
...     print("hello world!")
...     return 43
... 
>>> m.some_method.side_effect = print_hello
>>> m.some_method()
hello world!
43
>>> m.some_method.call_count
3

模拟使用动作/断言模式,也就是说一旦测试运行,必须确保模拟的动作被正确地执行。如下所示:

>>> from unittest import mock
>>> m = mock.Mock()
>>> m.some_method('foo','bar')#方法调用
<Mock name='mock.some_method()' id='4508225488'>
>>> m.some_method.assert_called_once_with('foo','bar')#断言
>>> m.some_method.assert_called_once_with('foo',mock.ANY)#断言
>>> m.some_method.assert_called_once_with('foo','baz')#断言
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/local/Cellar/python3/3.5.2_3/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/mock.py", line 803, in assert_called_once_with
return self.assert_called_with(*args, **kwargs)
File "/usr/local/Cellar/python3/3.5.2_3/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/mock.py", line 792, in assert_called_with
raise AssertionError(_error_message()) from cause
AssertionError: Expected call: some_method('foo', 'baz')
Actual call: some_method('foo', 'bar')

有时可能需要来自外部模块的函数、方法或对象。mock库为此提供了一组补丁函数。
如下所示:

>>> from unittest import mock
>>> import os
>>> def fake_on_unlink(path):
...     raise IOError("Testing!")
... 
>>> with mock.patch('os.unlink',fake_on_unlink):
...     os.unlink('foobar')
... 
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
    File "<stdin>", line 2, in fake_on_unlink
    OSError: Testing!
    >>> 

另外有@mock.patch(origin_method,fake_method) 的装饰器语法,更方便使用。

关于mock,这里有篇更详细的文章Mock 在 Python 单元测试中的使用

场景测试

在进行单元测试时,对某个对象的不同版本运行一组测试是较常见的需求。你也可能想对一组不同的对象运行同一个错误处理测试去触发这个错误,又或者想对不同的驱动执行整个测试集。

考虑下面的实例。
Ceilometer中提供了一个调用存储API的抽象类。任何驱动都可以实现这个抽象类,并将自己注册成为一个驱动。Ceilometer可以按需要加载被配置的存储驱动,并且利用实现的存储API保存和提供数据。这种情况下就需要对每个实现了存储API的驱动调用一类单元测试,以确保它们按照调用者的期望执行。

实现这一点的一种自然方式是使用混入类(mixin class):一方面你将拥有一个包含单元测试的类,另一方面这个类还会包含对特定驱动用法的设置。

import unittest

class MongoDBBaseTest(unittest.TestCase):
    def setUp(self):
        self.connection = connect_to_mongodb()

class MySQLBaseTest(unittest.TestCase):
    def setUp(self):
        self.connection = connect_to_mysql()
        
class TestDatabase(unittest.TestCase):
    def test_connected(self):
        self.assertTrue(self.connection.is_connected())


class TestMongoDB(TestDatabase,MongoDBBaseTest):
    pass
    
class TestMySQL(TestDatabase,MySQLBaseTest):
    pass

然而,从长期维护的角度看,这种方法的实用性和可扩展性都不好。(每增加一个类型的数据库,就需要增加2个类)。
更好的技术是有的,可以使用testscenarios。它提供了一种简单的方式针对一组实时生成的不同场景运行类测试。使用testscenarios重写上面的例子如下:

import testscenarios
from myapp import storage

class TestPythonErrorCode(testscenarios.TestWithScenarios):
    scenarios = [
    ('MongoDB',dict(driver=storage.MongoDBStorage())),
    ('SQL',dict(driver=storage.SQLStorage())),
    ('File',dict(driver=storage.FileStorage())),
    ]
    
    def test_storage(self):
        self.assertTrue(self.driver.store({'foo':'bar'}))
        
    def test_fetch(self):
        self.assertEqual(self.driver.fetch('foo'),'bar')

如上所示,为构建一个场景列表,我们需要的只是一个元组列表,其将场景名称作为第一个参数,并将针对此场景的属性字典作为第二个参数。
针对每个场景,都会运行一遍测试用例。

测试序列与并行

subunit是用来为测试结果提供流协议的一个Python模块。它支持很多有意思的功能,如聚合测试结果或者对测试的运行进行记录或者归档等。

使用subunit运行测试非常简单:
python -m subunit.run test_scenarios
这条命令的输出是二进制数据,好在subunit还支持一组将二进制流转换为其他易读格式的工具。
python -m subunit.run test_scenarios | subunit2pyunit
其他值得一提的工具还有subunit2csv、subunit2gtk和subunit2junitxml。
subunit还可以通过传入discover参数支持自动发现哪个测试要运行。
python -m subunit.run discover | subuint2pyunit
也可以通过传入参数–list只列出测试但不运行。要查看这一结果,可以使用subunit-ls
python -m subunit.run discover --list | subunit-ls --exists

备注:unittest本身也提供了自动发现测试用例的方法。

tests = unittest.TestLoader().discover('tests') #tests参数是指tests目录。
unittest.TextTestRunner(verbosity=2).run(tests)

在大型应用程序中,测试用例的数据可能会多到难以应付,因此让程序处理测试结果序列是非常有用的。testrepository包目的就是解决这一问题,它提供了testr程序,可以用来处理要运行的测试数据库。

$testr init
$ touch .testr.conf
$ python -m subunit.run test_scenarios | testr load

一旦subunit的测试流被运行并加载到testrepository,接下来就很容易使用testr命令了。
通过.testr.conf文件可以实现自动化测试。

[DEFAULT]
test_command=python -m subunit.run discover . $LISTOPT $IDOPTION
test_id_option=--load-list $IDFILE
test_list_option=--list

现在只需要运行testr run就可以将测试加载到testrepository中并执行。
另外可以通过增加–parallel实现并发测试。

测试覆盖

测试覆盖是完善单元测试的工具。它通过代码分析工具和跟踪钩子来判断代码的哪些部分被执行了。在单元测试期间使用时,它可以用来展示代码的哪些部分被测试所覆盖而哪些没有。

安装Python的coverage模块后,就可以通过shell使用coverage程序。
单独使用coverage非常简单且有用,它可以提出程序的哪些部分从来没有被运行时,以及哪些可能是”僵尸代码”。
此外,在单元测试中使用的好处也显而易见,可以知道代码的哪些部分没有被测试过。前面谈到的测试工具都可以和coverage集成。

  1. nosetests和coverage nosetests --cover-package=ceilometer --with-coverage tests/test_pipeline.py
  2. 使用coverage和testrepository python setup.py testr --coverage

使用虚拟环境和tox

tox的目标是自动化和标准化Python中运行测试的方式。基于这一目标,它提供了在一个干净的虚拟环境中运行整个测试集的所有功能,并安装被测试的应用程序以检查其安装是否正常。

使用tox之前,需要提供一个配置文件。这个文件名为tox.ini,需要放在被测试项目的根目录,与setup.py同级。
$ touch tox.ini
现在就可以成功运行tox:

$ tox
...

通过编辑tox.ini可以改变默认行为

[testenv]
deps=nose
     -rrequirements.txt
commands=nosetests

如上配置可运行nosetests命令,并且会自动安装依赖的nose包以及requirements.txt文件中的包。

可以配置多个环境,然后通过tox -e参数指定。

[testenv]
deps=nose
     -rrequirements.txt
commands=nosetests

[testenv:py21]
basepython=python2.1

以上配置就可以通过tox -e py21来测试python2.1版本下的表现。

测试策略

无论你的代码托管在哪里,都应该尽可能实现软件测试的自动化,进而保证项目不断向前推进而不是引入更多Bug而倒退。

更多测试话题

除了nose还有pytest。

在测试出问题的时候,可以通过pdb单步调试来发现问题在哪。pdg可能还是pycharm这类ide工具方便点。

另外推荐看下Flask Web开发 基于Python的Web应用开发实战
这里面讲了好多测试用例的东西,例子丰富。
而且也涵盖了测试覆盖率,测试报告,自动测试,测试客户端,使用Selenium进行端到端的测试。

在FlaskWeb开发中。
1. 针对模型类写了单元测试。
2. 测试客户端,针对网页内容做了测试。
3. 测试API服务。
4. 利用selenium完成测试端到端的服务。(可以测试交互功能,会依赖于js)

Tags:
7 Comments