python3 unittest模块源码解析(四) — 加载器loader

  • Post author:
  • Post category:python


一、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个参数和返回结果(更多了解

官网资料翻译

):

  1. loader:是执行main函数时所传入的testLoader参数,默认为loader.defaultTestLoader,其实就是本章主角TestLoader类的实例。在load_tests函数中,我们一般用来调用它的loadTestsFrom*方法来自定义加载规则。
  2. standard_tests:默认为对load_tests所在的文件进行加载后的所有测试用例的集合,TestSuite的实例对象。
  3. pattern:由loadTestsFromModule方法所传递而来的参数,可以用来定义一些命名规则。
  4. 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导入的

  1. start_dir:批量检索的起始目录,main.py中对其进行了定义,默认为”.”,即启动目录。discover命令的-s参数。
  2. pattern:指检索的规则表达式,默认为”test*.py“,即名字以”test”开头的.py文件。dicover命令的-p参数。
  3. 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。

  1. is_not_importable:当start_dir是否是可导入的,py文件、python包、非python包的普通目录都是可导入的。

  2. 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)


    最好还是将项目下的所有需要测试的对象放在包下,这样的万事无忧了啊。

  3. set_implicit_top:没有指定最高级目录,也没有设置类属性_top_lever_dir时,该值为True。

discover功能总结:

  1. start_dir是路径名格式时,必须是目录,如果是文件会报错。在未指定-t参数时,start_dir == _top_level_dir。

  2. start_dir是模块名格式时可以是目录或文件,如果是文件,start_dir重新赋值为文件所在的目录。此时的_top_level_dir是start_dir的最上级模块的上级目录(start_dir = p5.p6,那么_top_level_dir是p5的父目录)。
  3. 一般情况下,discover会对指定的start_dir目录下的所有文件及python包中进行递归查找,

    遇到非包的普通目录时直接跳过

    。但是当start_dir本身就是一个指向非包目录的模块名时(例如p5.p6,p6是普通目录),那么它的子目录即使是非包目录,也会进行遍历。
  4. 如果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方法。在理解了后者之后,我们就能更好地明白前者中的代码原理了。

先介绍一下他的三个参数和返回值:

  1. full_path:这里传入的就是遍历目录时的每一个文件或目录。
  2. pattern:最开始传入的pattern,默认是test*.py。
  3. namespace:就是在discover小节中所讲的is_namespace属性。
  4. 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时会执行其中代码,而有两种情况下会达成这个条件:

  1. 在python -m unittest discover 后指定不同的-s与-t参数,注意:受self._get_name_from_path影响,-t必须是比-s更高级的目录。
  2. 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]。

  1. top_dir必须高于start_dir。
  2. pattern是shell通配符,通过它匹配文件名来判断哪些文件是测试模块,默认为test*.py。
  3. discover会遍历start_dir下的所有内容,一般情况下,非python包的目录会被直接跳过。
  4. 在discover的过程,中当一个包的__init__.py中包含一个load_tests方法时,load_tests将会代替discover来加载该包中的测试。
  5. start_dir == top_dir时,start_dir的__init__.py是无效的。
  6. 在只指定start_dir而不指定top_dir的时候,如果start_dir是路径名格式(以“/”分隔),top_dir = start_dir;如果是模块名格式(以”.”分隔),top_dir是当前工作目录。



版权声明:本文为qq_41437305原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。