前言

这几天在看 jaeger-client-python 的测试部分,主要使用 unittest 和 pytest 构造测试,使用 mock 辅助测试。以下内容主要包含 jaeger-client-python 涉及的一些使用方式,unittest 是 Python 自带的单元测试框架,pytest 兼容 unittest 和 nose 测试集,并有丰富的插件支持。

unittest

构造测试

继承 unittest.TestCase 就创建了测试样例,被测方法以 test 开头命名。使用 assert*() 方法能够聚合所有测试结果并产生报告,unittest.main() 提供了测试脚本的命令行接口。

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

class TestStringMethods(unittest.TestCase):

def test_upper(self):
self.assertEqual('foo'.upper(), 'FOO')

def test_isupper(self):
self.assertTrue('FOO'.isupper())
self.assertFalse('Foo'.isupper())

def test_split(self):
s = 'hello world'
self.assertEqual(s.split(), ['hello', 'world'])
# check that s.split fails when the separator is not a string
with self.assertRaises(TypeError):
s.split(2)

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

前置方法与后置方法

测试的前置操作可以实现在测试前置方法 setUp() 中,在运行测试时,测试框架会自动为每个单独测试调用前置方法。tearDown() 方法能够在测试方法运行后进行清理工作。

一个测试代码运行的环境被称为 test fixture 。一个新的 TestCase 实例作为一个测试脚手架,用于运行各个独立的测试方法。在运行每个测试时,setUp()tearDown()__init__() 会被调用一次。

setUpClass()tearDownClass() 在测试类中只执行一次,而不是每个测试方法前后都执行。

setupModule()tearDownModule() 在单个测试模块中只执行一次。

命令行界面

unittest 模块可以通过命令行运行模块、类和独立测试方法的测试。

1
2
3
4
5
6
# 测试模块
python -m unittest test_api.py
# 测试类
python -m unittest test_api.APITest
# 测试方法
python -m unittest test_api.APITest.test_active_span

test_api.py 是 jaeger-python-client 中的测试。

组织测试

test suite 机制支持将测试用的 TestCase 实现集合起来,自定义测试套件可以参考以下方法组织测试

1
2
3
4
5
6
7
8
9
def suite():
suite = unittest.TestSuite()
suite.addTest(WidgetTestCase('test_default_widget_size'))
suite.addTest(WidgetTestCase('test_widget_resize'))
return suite

if __name__ == '__main__':
runner = unittest.TextTestRunner()
runner.run(suite())

FunctionTestCase 类可用于打包已有的测试函数,并支持设置前置与后置函数,假定如下测试函数:

1
2
3
4
def testSomething():
something = makeSomething()
assert something.name is not None
# ...

可以通过 FunctionTestCase 类创建等价的测试用例,其中前置与后置函数是可选的:

1
2
3
testcase = unittest.FunctionTestCase(testSomething,
setUp=makeSomethingDB,
tearDown=deleteSomethingDB)

跳过测试

unittest 提供了几个装饰器用于跳过测试:

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
class MyTestCase(unittest.TestCase):
# 跳过测试,参数为测试被跳过的原因
@unittest.skip("demonstrating skipping")
def test_nothing(self):
self.fail("shouldn't happen")

# 条件为真时,跳过测试
@unittest.skipIf(mylib.__version__ < (1, 3),
"not supported in this library version")
def test_format(self):
# Tests that work for only a certain version of the library.
pass

# 除非条件为真,否则跳过测试
@unittest.skipUnless(sys.platform.startswith("win"), "requires Windows")
def test_windows_support(self):
# windows specific testing code
pass

# 通过引发异常以跳过测试
def test_maybe_skipped(self):
if not external_resource_available():
self.skipTest("external resource not available")
# test code that depends on the external resource
pass

子测试

在一个测试中,传入不同的参数测试同一个方法,subTest 子测试可以满足这个需求,而且单个子测试的失败不影响后续子测试的执行。

1
2
3
4
5
6
7
8
9
10
11
import unittest

class NumbersTest(unittest.TestCase):

def test_even(self):
"""
Test that numbers between 0 and 5 are all even.
"""
for i in range(0, 6):
with self.subTest(i=i):
self.assertEqual(i % 2, 0)

pytest

在测试函数中使用 assert 断言进行条件判断,然后使用 pytest 执行测试。

1
2
def test_passing():
assert (1, 2, 3) == (1, 2, 3)

. 表示 PASSEDF 表示 FAILED,使用 -v 显示测试的详细信息

1
2
def test_failing():
assert (1, 2, 3) == (3, 2, 1)

捕获异常

使用 pytest.raises() 进行异常捕获:

1
2
3
4
5
6
7
import pytest

def test_raises():
with pytest.raises(TypeError) as e:
connect('localhost', '6379')
exec_msg = e.value.args[0]
assert exec_msg == 'port type must be int'

执行测试

默认情况下,pytest 会递归查找当前目录下所有以 test 开始或结尾的 Python 脚本,并执行文件内的所有以 test 开始或结束的函数和方法。直接执行测试脚本会同时执行所有测试函数。

1
pytest test_no_mark.py

这里的 .F 表示第一个函数执行成功,第二个函数执行失败

如果想要指定测试函数,可以使用以下几种方法:

  1. 显式指定函数名,通过 :: 标记。缺点是一次只能执行一个测试函数。
1
pytest test_no_mark.py::test_func1 -v
  1. 模糊匹配,使用 -k 选项标识。可以批量操作,但需要被测函数包含相同的模式。
1
pytest -k func1 test_no_mark.py -v
  1. 使用 pytest.mark 在函数上进行标记

首先,需要对标记进行注册,在配置文件 pytest.ini 中注册标记

1
2
3
4
[pytest]
markers =
finished
unfinished

然后在代码中使用标记

1
2
3
4
5
6
7
8
9
import pytest

@pytest.mark.finished
def test_func1():
assert 1 == 1

@pytest.mark.unfinished
def test_func2():
assert 1 != 1

测试时使用 -m 选择标记的函数

1
pytest -m finished test_with_mark.py -v

pytest 内置了一些标记:

  • usefixtures - 在测试函数或类上使用 fixture
  • filterwarnings - 过滤测试函数的某些警告
  • skip - 总是跳过测试函数
  • skipif - 如果满足某个条件,则跳过测试函数
  • xfail - 如果满足某个条件,则产生“预期失败”结果
  • parametrize - 对同一测试函数执行多个调用

固定装置/固件(fixture)

使用 @pytest.fixture 将函数定义为固定装置(固件),最简单的使用方式是作为测试函数的参数 。使用固定装置的好处是可以重复使用、装置可以请求其他装置等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import pytest

class Fruit:
def __init__(self, name):
self.name = name

def __eq__(self, other):
return self.name == other.name

@pytest.fixture
def my_fruit():
return Fruit("apple")

@pytest.fixture
def fruit_basket(my_fruit):
return [Fruit("banana"), my_fruit]

def test_my_fruit_in_basket(my_fruit, fruit_basket):
assert my_fruit in fruit_basket

固定装置可以设置范围(scope),可能的值包括:

  • function :默认范围,每个测试函数都会执行一次 fixture。
  • class :每个测试类执行一次 fixture,所有方法都可以使用。
  • module :每个模块执行一次 fixture,模块内函数和方法都可以使用。
  • package :每个包执行一次 fixture,包内的模块、函数、方法都可以使用。
  • session :一次测试只执行一次 fixture,所有被找到的函数和方法都可用。

pytest 一次只缓存 fixture 的一个实例,这意味着当使用参数化 fixture 时,pytest 可以在给定范围内多次调用 fixture 。

清理测试可以使用 pytest 提供的拆卸系统,允许自定义每个固定装置清理所需的具体步骤,通过以下两种方式使用:

  1. yield 语句

pytest 使用 yield 关键词将固件分为两部分,yield 之前的代码属于预处理,会在测试前执行;yield 之后的代码属于后处理,将在测试完成后执行。

使用 yield 语句执行拆卸,以前的写法是 @pytest.yield_fixture ,现在可以直接在 @pytest.fixture 装饰的函数中使用 yield 语句。

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

@pytest.fixture
def db():
print('Connection successful')
yield
print('Connection closed')

def search_user(user_id):
d = {
'001': 'xiaoming'
}
return d[user_id]

def test_search(db):
assert search_user('001') == 'xiaoming'
  1. 添加 finalizer

接受 request 对象,通过 addfinalizer 方法添加拆卸函数,必须在完成必要的拆卸工作后再进行添加,否则可能引发异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import pytest

@pytest.fixture
def db():
print('Connection successful')

def search_user(user_id):
d = {
'001': 'xiaoming'
}
return d[user_id]

def test_search(db, request):
assert search_user('001') == 'xiaoming'

def close_db():
print('Connection closed')

request.addfinalizer(close_db)

参数化测试

使用 @pytest.mark.parametrize 装饰器实现参数化测试功能。这里定义了三个不同的 (test_input,expected) ,因此 test_eval 将运行三次。

1
2
3
4
5
import pytest

@pytest.mark.parametrize("test_input,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)])
def test_eval(test_input, expected):
assert eval(test_input) == expected

其他

@pytest.mark.gen_testpytest-tornado 提供,该标记允许编写协程风格的测试与 tornado.gen 模块一起使用,也支持 async/await(同步/阻塞)的语法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import pytest
import tornado.web

class MainHandler(tornado.web.RequestHandler):
def get(self):
self.write("Hello, world")

application = tornado.web.Application([
(r"/", MainHandler),
])

@pytest.fixture
def app():
return application

@pytest.mark.gen_test
def test_hello_world(http_client, base_url):
response = yield http_client.fetch(base_url)
assert response.code == 200

参阅