Python接口测试实战7 - unittest单元测试框架

unittest是Python内置的单元测试框架,是一个足够精简也足够完善的单元测试框架,在其中有以下四种概念:

  • test fixture
  • test case
  • test suite
  • test runner

我们以一段测试代码样例来展开介绍这四个概念:

import unittest

class TestGetIssue(unittest.TestCase):
    def setUp(self):
        self.client = RedmineClient("https://redmine.xuh.me", "490592ea4e46348df29828edd597acbfdc5ebb4a")
    
    def test_get_issue(self):
        resp = self.client.get_issue("1")
        self.assertEqual(resp["issue"]["id"], 1)
    
    def test_bad_issue_id(self):
        resp = self.client.get_issue("-1")
        self.assertIsNone(resp["issue"])

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

Test Fixture

test fixture中文可以叫做测试夹具,如果对它理解困难的话,可以看下其定于:

A test fixture represents the preparation needed to perform one or more tests, and any associate cleanup actions. This may involve, for example, creating temporary or proxy databases, directories, or starting a server process.

上面代码中的setUp即可以理解为test fixture,它会在用例执行之前先行被调用,往往作用测试数据的准备、测试对象的实例化等。

unittest支持模块级别的test fixture:

  • setUpModule
  • tearDownModule

还支持test suite级别的test fixture:

  • setUpClass
  • tearDownClass

当然还支持用例级别的test fixture:

  • setUp
  • tearDown

这些fixture的执行顺序如下:

- setUpModule
	- setUpClass <suite-1>
		- setUp
		- test-1-1
		- tearDown
		- setUp
		- test-1-2
		- tearDown
		...
	- tearDownClass <suite-1>
	...
- tearDownModule

Test Case

即测试用例,也就是在上文代码样例中的test_get_isssue以及test_bad_issue_id两个function,默认情况下,在一个TestCase下以test开头的函数会视作测试用例:https://docs.python.org/3/library/unittest.html#unittest.TestLoader.testMethodPrefix

在test case中,当需要检查实际值是否与期望值一致时,可以使用断言,如:

  • self.assertEqual
  • self.assert

我相信测试人员对测试用例是什么应该是完全能够理解的,所以我这边也就不再赘述,除了一点:test case的执行顺序与你在TestCase下编写顺序并无关联,另外请勿强约束不同用例的执行顺序

未避免与类TestCase产生混淆,这个概念下文称之为Test Function

Test Suite

一般称之为测试套件,即所有用例的集合,是test runner的运行对象

Test Runner

test runner用于执行test suite以及输出结果,unittest内置了TextTestRunner

这个组件有可能是unittest最薄弱的一个环节,比如很多人喜欢使用HtmlTestRunner生成html格式的测试报告

TestCase 与 Test Function之间的关系

理解完这些基础概念后,我们在看看使用这个框架时一些要注意的问题: class TestCasedef test_xxx之间的关系

往往很多人会这么理解两者:TestRunner会实例化一个TestCase类,然后按某个顺序执行TestCase类下实现的test_xx这类函数,这样来不同的test function就可以使用约定的TestCase实例变量来传递上下文了,实现不同用例之间的关联,那事实是这样吗?

我们只能从源码中寻找答案。(Python 3.7+)

用例加载器是如何找到test function的:unittest/loader.py

    def getTestCaseNames(self, testCaseClass):
        """Return a sorted sequence of method names found within testCaseClass
        """
        def shouldIncludeMethod(attrname):
            if not attrname.startswith(self.testMethodPrefix):
                return False
            testFunc = getattr(testCaseClass, attrname)
            if not callable(testFunc):
                return False
            fullName = f'%s.%s.%s' % (
                testCaseClass.__module__, testCaseClass.__qualname__, attrname
            )
            return self.testNamePatterns is None or \
                any(fnmatchcase(fullName, pattern) for pattern in self.testNamePatterns)
        testFnNames = list(filter(shouldIncludeMethod, dir(testCaseClass)))
        if self.sortTestMethodsUsing:
            testFnNames.sort(key=functools.cmp_to_key(self.sortTestMethodsUsing))
        return testFnNames
 

    def loadTestsFromTestCase(self, testCaseClass):
        """Return a suite of all test cases contained in testCaseClass"""
        if issubclass(testCaseClass, suite.TestSuite):
            raise TypeError("Test cases should not be derived from "
                            "TestSuite. Maybe you meant to derive from "
                            "TestCase?")
        testCaseNames = self.getTestCaseNames(testCaseClass)
        if not testCaseNames and hasattr(testCaseClass, 'runTest'):
            testCaseNames = ['runTest']
        loaded_suite = self.suiteClass(map(testCaseClass, testCaseNames))
        return loaded_suite

再看unittest/suite.py

class BaseTestSuite(object):
    """A simple test suite that doesn't provide class or module shared fixtures.
    """
    _cleanup = True

    def __init__(self, tests=()):
        self._tests = []
        self._removed_tests = 0
        self.addTests(tests)

    def __iter__(self):
        return iter(self._tests)

class TestSuite(BaseTestSuite):
    def run(self, result, debug=False):
        topLevel = False
        if getattr(result, '_testRunEntered', False) is False:
            result._testRunEntered = topLevel = True

        for index, test in enumerate(self):
            # 省略过程代码
            if not debug:
                test(result)
            else:
                test.debug()

最后看下 unittest/case.py

class TestCase(object):
    def __init__(self, methodName='runTest'):
        """Create an instance of the class that will use the named test
           method when executed. Raises a ValueError if the instance does
           not have a method with the specified name.
        """
        self._testMethodName = methodName

    def __call__(self, *args, **kwds):
        return self.run(*args, **kwds)

    def run(self, result=None):

        testMethod = getattr(self, self._testMethodName)

            with outcome.testPartExecutor(self):
                self.setUp()
            if outcome.success:
                outcome.expecting_failure = expecting_failure
                with outcome.testPartExecutor(self, isTest=True):
                    testMethod()
                outcome.expecting_failure = False
                with outcome.testPartExecutor(self):
                    self.tearDown()

            self.doCleanups()

通过走读源码,test function的加载到执行逻辑其实就很清楚了:

  1. TestLoader查询不同TestCase下的以test开头的function,视作test function,添加到TestSuite中
  2. TestSuite运行时,会遍历这些这些tests,并以TestCase("test_xxx")的方式实例化这些TestCase类
  3. 执行TestCase.run函数,找到test_xxx函数,并执行

所以回答上面提出的问题:虽然不同的Test Function定义在一个TestCase内,但是运行时会实例化出不同的TestCase,因此这些Test Function之间不可能通过实例变量传递上下文

其实这样来,也对我们的用例设计提出了一个要求:每个用例(Test Function)必须闭合、与其他用例解耦

第三方测试框架

除了Python内置的unittest框架外,目前还有很多第三方测试框架,如

  • nose
  • nose2
  • pytest

这些框架都兼容unittest框架的特性,因此我建议是:尽量使用unittest组织测试用例,可以在执行层面引入三方框架,增强能力,比如

  • 输出html测试报告
  • 并行执行
  • 更好的test fixutre

正因为我一直秉持这样的原则,所以我们项目从unittest -> nose -> pytest转换时很顺畅,没有遇到过太多的问题。

而如果一上来直接使用nose或者pytest,并且深度使用其特性,比如pytest.fixture,那如果需要切换到其他框架时,将会遇到非常大的改造成本。

另外我还需要重申下:unittest提供的test fixture足够精简、好用,如果觉得不够用,请先提高你的Python编程能力