6.4 场景测试

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

最后一种情况在OpenStack Ceilometer1(https://launchpad.net/ceilometer)项目中被大量使用。Ceilometer中提供了一个调用存储API的抽象类。任何驱动都可以实现这个抽象类,并将自己注册成为一个驱动。Ceilometer可以按需要加载被配置的存储驱动,并且利用实现的存储API保存和提取数据。这种情况下就需要对每个实现了存储API的驱动调用一类单元测试,以确保它们按照调用者的期望执行。

广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元

实现这一点的一种自然方式是使用混入类(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

然而,从长期维护的角度看,这种方法的实用性和可扩展性都不好。

更好的技术是有的,可以使用testscenarios包(https://pypi.python.org/pypi/ testscenarios)。它提供了一种简单的方式针对一组实时生成的不同场景运行类测试。这里使用testscenarios重写了示例6.9的部分代码来说明6.3节中介绍过的模拟,具体见示例6.10。

示例6.10 testscenarios的基本用法

import mock
import requests
import testscenarios

class WhereIsPythonError(Exception):
    pass

def is_python_still_a_programming_language():
    r = requests.get("http://python.org")
    if r.status_code == 200:
        return 'Python is a programming language' in r.content
    raise WhereIsPythonError("Something bad happened")

def get_fake_get(status_code, content):
    m = mock.Mock()
    m.status_code = status_code
    m.content = content
    def fake_get(url):
        return m
    return fake_get

class TestPythonErrorCode(testscenarios.TestWithScenarios):
    scenarios = [
        ('Not found', dict(status=404)),
        ('Client error', dict(status=400)),
        ('Server error', dict(status=500)),
    ]

    def test_python_status_code_handling(self):
        with mock.patch('requests.get',
                        get_fake_get(
                            self.status,
                            'Python is a programming language for sure')):
            self.assertRaises(WhereIsPythonError,
                              is_python_still_a_programming_language)

尽管看上去只定义了一个测试,但是testscenarios会运行这个测试三次,因为这里定义了三个场景。

% python -m unittest -v test_scenario
test_python_status_code_handling (test_scenario.TestPythonErrorCode) ... ok
test_python_status_code_handling (test_scenario.TestPythonErrorCode) ... ok
test_python_status_code_handling (test_scenario.TestPythonErrorCode) ... ok

---------------------------------------------------------
Ran 3 tests in 0.001s
OK

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

很容易联想到另一种使用方式:可以实例化一个特定的驱动并针对它运行这个类的所有测试,而不是为每个测试存储一个单独的值作为属性。具体如示例6.11所示。

示例6.11 使用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')

 注意

  这里之所以不需要使用前面示例中使用的基类unittest.TestCase,是因为test-scenarios.TestWithScenarios继承自unittest.TestCase

1作者是OpenStack中监控项目Ceilometer的前项目技术主管(Project Technical Lead)。—译者注