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 TestCase 与def 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的加载到执行逻辑其实就很清楚了:
- TestLoader查询不同TestCase下的以test开头的function,视作test function,添加到TestSuite中
TestSuite
运行时,会遍历这些这些tests
,并以TestCase("test_xxx")
的方式实例化这些TestCase类- 执行
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编程能力