一、loader简介
照惯例,引用官方文档的说明:
The
TestLoader
class is used to create test suites from classes and modules.TestLoader类是用来从类与模块中创建测试套件(即suite)。
注意:loader是可以自定义的
在了解这个模块之前,我们先简单认识一下case和suite,因为它们是loader模块的作用结果。
case:测试过程中的最小单元,每一个TestCase子类中的test方法经由loader都会生成一个独立的case。(下期学习)
suite:由case或suite所级成的集合体,它可以有多层结果,类似[[case1,[case2…]…](每一个[]代表一个suite)。事实上,由loader加载器所生成 的结果都是suite对象(就算只有1个case也是如此),而并非case的个体。(下下期学习)
二、TestLoader类
1、加载测试的几个方法
loader中用来加载测试的最基本方法是
loadTestsFromTestCase
,从用户创建的TestCase子类中加载测试。它调用getTestCaseNames获取子类中的所有测试方法名,然后使用TestCase(testMethodName)的方式为每一个测试方法创建一个用例。
def getTestCaseNames(self, testCaseClass):
""" Return a sorted sequence of method names found within testCaseClass
(源码中该方法在后面,这里为了方便放在loadTestsFromTestCase前面)
1、在loadTestsFromCase中被调用,用于过滤类中的非测试方法的函数
2、过滤规则为判定TestCase类中的方法前缀是否为loader的testMethodPrefix属性,
该属性默认为'test'
"""
def isTestMethod(attrname, testCaseClass=testCaseClass,
prefix=self.testMethodPrefix):
# 判断是否是TestCase中名称以test开头的方法
return attrname.startswith(prefix) and \
callable(getattr(testCaseClass, attrname))
testFnNames = list(filter(isTestMethod, 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
1、底层的从类中加载测试方法的函数,其他的loadTestsFrom*函数都会调用它
2、return 一个包含所有测试方法用例的suite实例
"""
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) # 获取TestCase类中的测试方法名
if not testCaseNames and hasattr(testCaseClass, 'runTest'):
# 如果TestCase类中没有test*方法,但有1个runTest方法,则视其为测试方法
testCaseNames = ['runTest']
loaded_suite = self.suiteClass(map(testCaseClass, testCaseNames))
# 每个测试方法创建一个用例,最终集合为suite,这里我们可以看到,创建case
# 使用的是TestCase(testMethodName)的方式
return loaded_suite
接下来介绍一些从loadTestsFromCase基础上构建的其他方法:loadTestsFromModule,从模块中加载测试;loadTestsFromName,从一个str类型的名称中加载测试,以及它的复数版loadTestsFromNames。
1. loadTestsFromModule(self, module, *args, pattern=None, **kws)
这里的参数module不仅可以是一个.py文件,它还可以是一个包。如果module是一个.py文件,默认情况下会加载文件中的所有TestCase子类下的testMethod;如果是一个包,它只会加载__init__.py文件中的TestCase子类下的testMethod方法。
如果模块或包的__init__.py文件中定义了
load_tests(loader, standard_tests, pattern)
方法(必须返回一个suite对象),则加载的测试为该方法返回的结果。
def loadTestsFromModule(self, module, *args, pattern=None, **kws):
...
tests = []
for name in dir(module):
# 对module中的属性进行迭代,如果module是包则module=__init__.py
obj = getattr(module, name)
if isinstance(obj, type) and issubclass(obj, case.TestCase):
# 如果是TestCase子类,则加载测试
tests.append(self.loadTestsFromTestCase(obj))
load_tests = getattr(module, 'load_tests', None)
tests = self.suiteClass(tests)
if load_tests is not None:
# 如果module中存在load_tests方法,则调用该方法对tests再加工
try:
return load_tests(self, tests, pattern)
except Exception as e:
error_case, error_message = _make_failed_load_tests(
module.__name__, e, self.suiteClass)
self.errors.append(error_message)
return error_case
return tests
关于load_test(loader, standard_tests, pattern),先介绍它的3个参数和返回结果(更多了解
官网资料翻译
):
- loader:是执行main函数时所传入的testLoader参数,默认为loader.defaultTestLoader,其实就是本章主角TestLoader类的实例。在load_tests函数中,我们一般用来调用它的loadTestsFrom*方法来自定义加载规则。
- standard_tests:默认为对load_tests所在的文件进行加载后的所有测试用例的集合,TestSuite的实例对象。
- pattern:由loadTestsFromModule方法所传递而来的参数,可以用来定义一些命名规则。
- return:它必须返回一个TestSuite的实例对象。
例如,下面代码是官网上的示例。它对自身模块所在的目录进行一次批量加载,并将加载的结果添加到原先的测试集中。
def load_tests(loader, standard_tests, pattern):
this_dir = os.path.dirname(__file__)
package_tests = loader.discover(start_dir=this_dir, pattern=pattern)
standard_tests.addTests(package_tests)
return standard_tests
2. loadTestFromName(self, name, module=None)
根据main.py中的代码中的parseArgs及createTests方法,当argv中解析到tests参数时将调用loadTestFromNames方法(对loadTestFromName方法进行迭代)。
如果指定了module,则会从指定的module(module应该是由import 或__import__方法所生成的对象)中来查找name。如果未指定,则从当前工作目录开始查找name,然后使用__import__(name)导入name,并将导入生成的结果对象保存在module中。
对于__import__(name)方法,name是要加载的对象名的模块格式(以点号分隔的格式,例如package1.package2.test1),注意,__import__方法创建的对象是name的顶层的名字,例如对__import__(‘package1.package2.test1)创建的对象实际上是相当于
import package1
,但package1此时只能访问__init__.py中的属性和package2而不能访问该模块下的其他属性(例如:如果package1包下还有一个try11.py文件,我们并不能通过package1.try11来访问到该属性),而不是最终所指的test1。因此这里对name进行迭代来获取它实际所指定的对象。__import__方法返回的对象可以是<module>,<function>,<type>。
通过for循环获得name的对象obj以及其上级对象parent后,对其进行条件判定,来执行相应的加载函数,我在代码进行了注释。
def loadTestsFromName(self, name, module=None):
parts = name.split('.')
error_case, error_message = None, None
if module is None:
parts_copy = parts[:]
while parts_copy:
try:
module_name = '.'.join(parts_copy)
module = __import__(module_name) # 创建<module>类型的对象
break
except ImportError:
# 如果import发生错误,则将name末位一级去掉后,继续循环
next_attribute = parts_copy.pop()
error_case, error_message = _make_failed_import_test(
next_attribute, self.suiteClass)
if not parts_copy:
# 如果name的最上层仍然导入错误,则抛出异常
self.errors.append(error_message)
return error_case
parts = parts[1:]
obj = module
for part in parts:
# 由于__import__()方法的特性,需要迭代来创建name实际所指最底层的对象
try:
parent, obj = obj, getattr(obj, part)
except AttributeError as e:
...
if isinstance(obj, types.ModuleType):
# obj为模块
return self.loadTestsFromModule(obj)
elif isinstance(obj, type) and issubclass(obj, case.TestCase):
# obj为TestCase子类
return self.loadTestsFromTestCase(obj)
elif (isinstance(obj, types.FunctionType) and
isinstance(parent, type) and
issubclass(parent, case.TestCase)):
# obj为TestCase子类中的方法
name = parts[-1]
inst = parent(name)
if not isinstance(getattr(inst, name), types.FunctionType):
return self.suiteClass([inst])
elif isinstance(obj, suite.TestSuite):
# obj为suite实例
return obj
if callable(obj):
# obj为返回suite或case的可调用对象
test = obj()
if isinstance(test, suite.TestSuite):
return test
elif isinstance(test, case.TestCase):
return self.suiteClass([test])
else:
raise TypeError("calling %s returned %s, not a test" %
(obj, test))
else:
raise TypeError("don't know how to make test from: %s" % obj)
def loadTestsFromNames(self, names, module=None):
""" 1、names为多元素列表时,迭代调用loadTestsFromName"""
suites = [self.loadTestsFromName(name, module) for name in names]
return self.suiteClass(suites)
3. discover(self, start_dir, pattern=’test*.py’, top_level_dir=None)
该方法检索start_dir下的所有测试模块,并加载他们创建suite对象,
这里必须保证这些模块是可以在top_level_dir导入的
。
- start_dir:批量检索的起始目录,main.py中对其进行了定义,默认为”.”,即启动目录。discover命令的-s参数。
- pattern:指检索的规则表达式,默认为”test*.py“,即名字以”test”开头的.py文件。dicover命令的-p参数。
- top_level_dir:用于指定_top_leve_dir属性,即项目的根目录。它的位置决定了是否检查start_dir的__init__.py的load_tests方法。
discover方法中首先是确定self._top_level_dir属性(项目根目录),它是测试运行的项目根目录,如果没有指定,根据start_dir的不同,它的值也会不同。start_dir是路径格式时,例p5/p6,那么_top_level_dir=star_dir是始终成力的;但如果start_dir是模块名格式时,例p5.p6,_top_level_dir是p5的上级目录。
当start_dir == _top_level_dir时,start_dir的__init__.py中所定义的load_tests方法是不会生效的。所以如果你创建了一个项目project1,并在它的__init__.py创建load_tests方法,想让load_tests方法对整个项目生效的话,你需要这样调用:
python -m unittest -t ..
。(在self._find_tests方法中实现)
discover函数中,在执行加载测试的_find_test方法前,有3个需要判断的重要属性:is_not_importable,is_namespace,set_implicit_top。
-
is_not_importable:当start_dir是否是可导入的,py文件、python包、非python包的普通目录都是可导入的。
-
is_namespace:保证start_dir是非包的普通目录时也进行检索。start_dir是start_dir是模块格式时(以“.”分隔)时,例如:python -m unittest discover p5.p6,如果p6是一个非包的普通目录,则该属性为True,其他情况下均为False。由于受self._find_test_path的影响,
当我们在一个start_dir中进行迭代查找测试对象时,如果它的子目录不是包的话,就会跳过该子目录,这会导致当我们指定的start_dir本身就是一个非包的普通目录时直接返回空内容。
另外注意:此情况只会发生成start_dir是top_dir的子目录的子录目下时才为True,因为python -m unittest discover p5会认为p5是一个路径名而非模块名。另外,如果p5是一个非包的普通目录,而p6是一个包时,程序会去尝试获取p5的__file__值,而非包的目录没有该属性,就会抛出异常(可以稍稍修改源码来取消该异常,修改如下函数)。def _get_directory_containing_module(self, module_name): # 返回模块或包的上级目录 module = sys.modules[module_name] try: full_path = os.path.abspath(module.__file__) except AttributeError: full_path = os.path.abspath(module_name)
最好还是将项目下的所有需要测试的对象放在包下,这样的万事无忧了啊。
-
set_implicit_top:没有指定最高级目录,也没有设置类属性_top_lever_dir时,该值为True。
discover功能总结:
-
start_dir是路径名格式时,必须是目录,如果是文件会报错。在未指定-t参数时,start_dir == _top_level_dir。
- start_dir是模块名格式时可以是目录或文件,如果是文件,start_dir重新赋值为文件所在的目录。此时的_top_level_dir是start_dir的最上级模块的上级目录(start_dir = p5.p6,那么_top_level_dir是p5的父目录)。
-
一般情况下,discover会对指定的start_dir目录下的所有文件及python包中进行递归查找,
遇到非包的普通目录时直接跳过
。但是当start_dir本身就是一个指向非包目录的模块名时(例如p5.p6,p6是普通目录),那么它的子目录即使是非包目录,也会进行遍历。 - 如果start_dir的子包中的__init__.py有load_tests方法,则会跳过该包的查找,而是使用该load_tests方法来加载该包中的测试(在self._find_test_path中实现)。
def discover(self, start_dir, pattern='test*.py', top_level_dir=None):
set_implicit_top = False
if top_level_dir is None and self._top_level_dir is not None:
# 在包的__init__.py文件中的load_tests方法中,我们使用自定义的loader时,如果
# 定议了loader._top_level_dir属性,则将此处的top_level_dir与其同步
top_level_dir = self._top_level_dir
elif top_level_dir is None:
# 如果未定义top_level_dir则将其同步为启动目录start_dir
set_implicit_top = True
top_level_dir = start_dir
top_level_dir = os.path.abspath(top_level_dir)
if not top_level_dir in sys.path:
# 如果项目根目录没有在sys.path中,添加它,保证所有模块都是可以从根目录导入的
# 它使得我们在设置了更上级目录为项目目录时,当前目录的所有模块仍是可import的
sys.path.insert(0, top_level_dir)
self._top_level_dir = top_level_dir
is_not_importable = False
is_namespace = False
tests = []
if os.path.isdir(os.path.abspath(start_dir)):
# 当start_dir是路径名格式并且是目录时
start_dir = os.path.abspath(start_dir)
if start_dir != top_level_dir:
# 设置了start_dir的上(几)级目录为项目目录时
# 如果start_dir中有__init__.py它才可以被导入
is_not_importable = not os.path.isfile(os.path.join(start_dir, '__init__.py'))
else:
# start_dir是以'.'分隔的模块名格式
try:
# 是否是可以导入的
__import__(start_dir)
except ImportError:
is_not_importable = True
else:
the_module = sys.modules[start_dir]
top_part = start_dir.split('.')[0]
try:
# start_dir如果是.py文件,则重新赋值为它所在的目录
# 将start_dir转换为绝对路径
start_dir = os.path.abspath(
os.path.dirname((the_module.__file__)))
except AttributeError:
try:
spec = the_module.__spec__
except AttributeError:
spec = None
if spec and spec.loader is None:
if spec.submodule_search_locations is not None:
# start_dir是一个普通目录时
is_namespace = True
for path in the_module.__path__:
...
tests.extend(self._find_tests(path,
pattern,
namespace=True))
elif the_module.__name__ in sys.builtin_module_names:
# builtin module 模块是python内置
raise TypeError('Can not use builtin modules '
'as dotted module names') from None
else:
raise TypeError(
'don\'t know how to discover from {!r}'
.format(the_module)) from None
if set_implicit_top:
...
if is_not_importable:
raise ImportError('Start directory is not importable: %r' % start_dir)
if not is_namespace:
tests = list(self._find_tests(start_dir, pattern))
return self.suiteClass(tests)
4. _find_test_path(self, full_path, pattern, namespace=False)
其实discover中调用的是_find_tests方法,在_find_tests内部再调用_find_test_path方法。在理解了后者之后,我们就能更好地明白前者中的代码原理了。
先介绍一下他的三个参数和返回值:
- full_path:这里传入的就是遍历目录时的每一个文件或目录。
- pattern:最开始传入的pattern,默认是test*.py。
- namespace:就是在discover小节中所讲的is_namespace属性。
- return:tests(loadTestsFromModule的结果或None),should_recurse(bool值)。should_recurse为True时,会对full_path进行再迭代。
当full_path是文件时,先判断他是否是规范的并且符合pattern的文件名,如果符合,则调用loadTestsFromModule方法从中加载TestCase。此时shoud_recurse为False,因为文件是无法再进行迭代的。在模块中添加raise unittest.SkipTest可以跳过该模块。
当full_path是目录时,最开始通过if判断是否跳过该目录。如果start_dir是模块名格式并指向一个非包的普通目录,所有目录都会检测,其他情况下都会跳过非包的普通目录,前者基本不用,下面我们只讨论后者的情况,即full_path是包。调用loadTestsFromModule加载full_path,如果找到load_tests则should_recurse返回False,否则返回True。
def _find_test_path(self, full_path, pattern, namespace=False):
"""
discover中调用,从文件或包中加载测试
"""
basename = os.path.basename(full_path)
if os.path.isfile(full_path):
# 目标是文件
if not VALID_MODULE_NAME.match(basename):
# 目标文件是否是符合命名规则的.py文件
return None, False
if not self._match_path(basename, full_path, pattern):
# 目标文件是否符合pattern表达式
return None, False
# 符合discover规则时,执行下面代码
name = self._get_name_from_path(full_path)
try:
module = self._get_module_from_name(name)
except case.SkipTest as e:
# 模块中有raise unittest.SkipTest时,跳过该模块
return _make_skipped_test(name, e, self.suiteClass), False
except:
error_case, error_message = \
_make_failed_import_test(name, self.suiteClass)
self.errors.append(error_message)
return error_case, False
else:
# 在start_dir下的其它目录存在同名模块,并且已经被导入过
mod_file = os.path.abspath(
getattr(module, '__file__', full_path))
realpath = _jython_aware_splitext(
os.path.realpath(mod_file))
fullpath_noext = _jython_aware_splitext(
os.path.realpath(full_path))
if realpath.lower() != fullpath_noext.lower():
module_dir = os.path.dirname(realpath)
mod_name = _jython_aware_splitext(
os.path.basename(full_path))
expected_dir = os.path.dirname(full_path)
msg = ("%r module incorrectly imported from %r. Expected "
"%r. Is this module globally installed?")
raise ImportError(
msg % (mod_name, module_dir, expected_dir))
return self.loadTestsFromModule(module, pattern=pattern), False # 从模块中加载
elif os.path.isdir(full_path):
# 目标是目录时
if (not namespace and
not os.path.isfile(os.path.join(full_path, '__init__.py'))):
# 在最开始指定的start_dir不是模块名格式的非python普通目录,在此前提下
# full_path不是python包,则discover跳过该目录
return None, False
load_tests = None
tests = None
name = self._get_name_from_path(full_path)
try:
package = self._get_module_from_name(name)
except case.SkipTest as e:
# 在包的__init__.py中raise unittest.SkipTest,跳过该包
return _make_skipped_test(name, e, self.suiteClass), False
except:
error_case, error_message = \
_make_failed_import_test(name, self.suiteClass)
self.errors.append(error_message)
return error_case, False
else:
load_tests = getattr(package, 'load_tests', None)
# Mark this package as being in load_tests (possibly ;))
self._loading_packages.add(name)
try:
tests = self.loadTestsFromModule(package, pattern=pattern)
if load_tests is not None:
# loadTestsFromModule(package) has loaded tests for us.
return tests, False
return tests, True
finally:
self._loading_packages.discard(name)
else:
return None, False
5. _find_tests(self, start_dir, pattern, namespace=False)
第一个if判断中,当start_dir != top_dir时会执行其中代码,而有两种情况下会达成这个条件:
- 在python -m unittest discover 后指定不同的-s与-t参数,注意:受self._get_name_from_path影响,-t必须是比-s更高级的目录。
- start_dir是模块名格式时。
在满足条件的情况下,会对start_dir调用self._find_test_path,然后根据返回的should_recurse值来判断是否继续迭代。
当start_dir == top_dir或if判断中should_recurse=False时,对start_dir的子目录和子文件进行循环调用self._find_test_path方法。循环的过程中,将从每个对象中加载的测试对象保存在生成器中,如果返回的should_recurse=True,那对产生这个结果的目录再次调用self._find_tests,依此不断循环直到遍历所有内容。
def _find_tests(self, start_dir, pattern, namespace=False):
name = self._get_name_from_path(start_dir)
# 将start_dir转化为相对于top_dir的相对路径的模块名格式
if name != '.' and name not in self._loading_packages:
# start_dir与top_dir不相同时,先执行一次self._find_test_path,主要目的是
# 当start_dir下有__init__.py时,检查其中的load_tests方法或TestCase类
tests, should_recurse = self._find_test_path(
start_dir, pattern, namespace)
if tests is not None:
yield tests
if not should_recurse:
# start_dir的__init__.py中有load_tests方法时,should_recurse=False
return
paths = sorted(os.listdir(start_dir))
# 遍历它的子文件和目录
for path in paths:
full_path = os.path.join(start_dir, path) # 目录下的子文件或文件夹
tests, should_recurse = self._find_test_path( #
full_path, pattern, namespace)
if tests is not None:
yield tests
if should_recurse:
# 如果full_path的__init__.py中没有load_tests,对full_path进行循环
name = self._get_name_from_path(full_path)
self._loading_packages.add(name)
try:
yield from self._find_tests(full_path, pattern, namespace)
finally:
self._loading_packages.discard(name)
三、总结
1. 执行指定对象中的TestCase
命令python -m unittest module.Case,执行指定module中的
命令python -m unittest module,执行指定module中的所有测试,如果模块内有load_tests则会按照该方法来进行测试。
命令python -m unittest package,执行package.__init__.py中的所有测试,如果__init__.py内有load_tests则会按照该方法来进行测试。
2. discover批量测试:
在工作用到最多的应该就是discover,命令是python -m unittest discover -[s] [start_dir] [-p] [pattern] [-t] [top_dir]。
- top_dir必须高于start_dir。
- pattern是shell通配符,通过它匹配文件名来判断哪些文件是测试模块,默认为test*.py。
- discover会遍历start_dir下的所有内容,一般情况下,非python包的目录会被直接跳过。
- 在discover的过程,中当一个包的__init__.py中包含一个load_tests方法时,load_tests将会代替discover来加载该包中的测试。
- start_dir == top_dir时,start_dir的__init__.py是无效的。
- 在只指定start_dir而不指定top_dir的时候,如果start_dir是路径名格式(以“/”分隔),top_dir = start_dir;如果是模块名格式(以”.”分隔),top_dir是当前工作目录。