关于单元测试:如何从“python setup.py test”运行unittest discover?

How to run unittest discover from “python setup.py test”?

我想知道如何让python setup.py test运行等效的python -m unittest discover。我不想使用run-tests.py脚本,也不想使用任何外部测试工具(如nosepy.test)。如果解决方案只在Python2.7上工作,那就没问题了。

setup.py中,我认为我需要在config中的test_suite和/或test_loader字段中添加一些内容,但我似乎找不到正确的组合:

1
2
3
4
5
6
7
config = {
    'name': name,
    'version': version,
    'url': url,
    'test_suite': '???',
    'test_loader': '???',
}

是否可以只使用内置在python 2.7中的unittest

仅供参考,我的项目结构如下:

1
2
3
4
5
6
7
8
9
project/
  package/
    __init__.py
    module.py
  tests/
    __init__.py
    test_module.py
  run_tests.py <- I want to delete this
  setup.py

更新:这在unittest2中是可能的,但是我想找到一些只使用unittest的等效工具。

来自https://pypi.python.org/pypi/unittest2

unittest2 includes a very basic setuptools compatible test collector. Specify test_suite = 'unittest2.collector' in your setup.py. This starts test discovery with the default parameters from the directory containing setup.py, so it is perhaps most useful as an example (see unittest2/collector.py).

目前,我只是在使用一个名为run_tests.py的脚本,但我希望我可以通过转移到只使用python setup.py test的解决方案来解决这个问题。

这是我希望删除的run_tests.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import unittest

if __name__ == '__main__':

    # use the default shared TestLoader instance
    test_loader = unittest.defaultTestLoader

    # use the basic test runner that outputs to sys.stderr
    test_runner = unittest.TextTestRunner()

    # automatically discover all tests in the current dir of the form test*.py
    # NOTE: only works for python 2.7 and later
    test_suite = test_loader.discover('.')

    # run the test suite
    test_runner.run(test_suite)

如果使用PY27+或PY32+,解决方案非常简单:

1
test_suite="tests",


从使用设置工具构建和分发包(重点是挖掘):

test_suite

A string naming a unittest.TestCase subclass (or a package or module
containing one or more of them, or a method of such a subclass), or naming
a function that can be called with no arguments and returns a unittest.TestSuite.

因此,在setup.py中,您将添加一个返回测试套件的函数:

1
2
3
4
5
import unittest
def my_test_suite():
    test_loader = unittest.TestLoader()
    test_suite = test_loader.discover('tests', pattern='test_*.py')
    return test_suite

然后,您将指定命令setup,如下所示:

1
2
3
4
5
setup(
    ...
    test_suite='setup.my_test_suite',
    ...
)


你不需要配置就能让它正常工作。主要有两种方法:

捷径

将您的test_module.py重命名为module_test.py(基本上添加_test作为特定模块测试的后缀),python会自动找到它。只需确保添加到setup.py中:

1
2
3
4
5
6
7
from setuptools import setup, find_packages

setup(
    ...
    test_suite = 'tests',
    ...
)

漫长的道路

下面介绍如何使用当前目录结构:

1
2
3
4
5
6
7
8
9
project/
  package/
    __init__.py
    module.py
  tests/
    __init__.py
    test_module.py
  run_tests.py <- I want to delete this
  setup.py

tests/__init__.py下,您要导入unittest和单元测试脚本test_module,然后创建一个函数来运行测试。在tests/__init__.py中,键入如下内容:

1
2
3
4
5
6
7
import unittest
import test_module

def my_module_suite():
    loader = unittest.TestLoader()
    suite = loader.loadTestsFromModule(test_module)
    return suite

TestLoader类除loadTestsFromModule类外,还有其他功能。您可以运行dir(unittest.TestLoader)来查看其他的,但这个是最简单的使用方法。

由于您的目录结构是这样的,您可能希望test_module能够导入您的module脚本。您可能已经这样做了,但是如果没有,您可以包括父路径,以便导入package模块和module脚本。在您的test_module.py顶部,键入:

1
2
3
4
5
6
import os, sys
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

import unittest
import package.module
...

最后,在setup.py中,包括tests模块并运行您创建的命令my_module_suite

1
2
3
4
5
6
7
from setuptools import setup, find_packages

setup(
    ...
    test_suite = 'tests.my_module_suite',
    ...
)

然后你就运行python setup.py test

这是一个作为参考的样品。


一种可能的解决方案是简单地对distutilssetuptoolsdistribute扩展test命令。这看起来像是一个完全的错误,比我希望的要复杂得多,但是在运行python setup.py test时,似乎可以正确地发现和运行包中的所有测试。我推迟选择这个作为我问题的答案,希望有人能提供更优雅的解决方案:)

(灵感来源于https://docs.py test.org/en/latest/goodpractices.html与安装工具python安装py test pytest runner集成)

示例setup.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
try:
    from setuptools import setup
except ImportError:
    from distutils.core import setup

def discover_and_run_tests():
    import os
    import sys
    import unittest

    # get setup.py directory
    setup_file = sys.modules['__main__'].__file__
    setup_dir = os.path.abspath(os.path.dirname(setup_file))

    # use the default shared TestLoader instance
    test_loader = unittest.defaultTestLoader

    # use the basic test runner that outputs to sys.stderr
    test_runner = unittest.TextTestRunner()

    # automatically discover all tests
    # NOTE: only works for python 2.7 and later
    test_suite = test_loader.discover(setup_dir)

    # run the test suite
    test_runner.run(test_suite)

try:
    from setuptools.command.test import test

    class DiscoverTest(test):

        def finalize_options(self):
            test.finalize_options(self)
            self.test_args = []
            self.test_suite = True

        def run_tests(self):
            discover_and_run_tests()

except ImportError:
    from distutils.core import Command

    class DiscoverTest(Command):
        user_options = []

        def initialize_options(self):
                pass

        def finalize_options(self):
            pass

        def run(self):
            discover_and_run_tests()

config = {
    'name': 'name',
    'version': 'version',
    'url': 'http://example.com',
    'cmdclass': {'test': DiscoverTest},
}

setup(**config)

python的标准库unittest模块支持发现(在python 2.7和更高版本以及python 3.2和更高版本中)。如果您可以假设这些最低版本,那么您可以将discover命令行参数添加到unittest命令中。

setup.py来说,只需稍作调整:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import setuptools.command.test
from setuptools import (find_packages, setup)

class TestCommand(setuptools.command.test.test):
   """ Setuptools test command explicitly using test discovery."""

    def _test_args(self):
        yield 'discover'
        for arg in super(TestCommand, self)._test_args():
            yield arg

setup(
    ...
    cmdclass={
        'test': TestCommand,
    },
)


另一个不太理想的解决方案受到了http://hg.python.org/unittest2/file/2b6411b9a838/unittest2/collector.py的启发。

添加一个返回已发现测试的TestSuite的模块。然后配置安装程序以调用该模块。

1
2
3
4
5
6
7
8
9
project/
  package/
    __init__.py
    module.py
  tests/
    __init__.py
    test_module.py
  discover_tests.py
  setup.py

这是discover_tests.py

1
2
3
4
5
6
7
8
import os
import sys
import unittest

def additional_tests():
    setup_file = sys.modules['__main__'].__file__
    setup_dir = os.path.abspath(os.path.dirname(setup_file))
    return unittest.defaultTestLoader.discover(setup_dir)

这是setup.py

1
2
3
4
5
6
7
8
9
10
11
12
13
try:
    from setuptools import setup
except ImportError:
    from distutils.core import setup

config = {
    'name': 'name',
    'version': 'version',
    'url': 'http://example.com',
    'test_suite': 'discover_tests',
}

setup(**config)


这不会删除run_tests.py,但会使它与安装工具一起工作。添加:

1
2
3
class Loader(unittest.TestLoader):
    def loadTestsFromNames(self, names, _=None):
        return self.discover(names[0])

然后在setup.py中:(我假设你在做类似于setup(**config)的事情)

1
2
3
4
5
config = {
    ...
    'test_loader': 'run_tests:Loader',
    'test_suite': '.', # your start_dir for discover()
}

我看到的唯一缺点是它扭曲了loadTestsFromNames的语义,但setuptools测试命令是唯一的使用者,并以指定的方式调用它。