本文试图总结编写单元测试的流程,以及自己在写单元测试时踩到的一些坑。如有遗漏,纯属必然,欢迎补充。
目录概览:
- 编写思想 x 3
- 编写方法
- 基本单元测试框架x3
- 对基于网站框架搭建的网络应用进行单元测试
编写思想
尽可能地按「单元」测试,重点是:保证待测单元内部的所有流程能按设想正确运行、返回预期结果。
假设我们有一个待测程序如下
# to_test.py
def func_01(param0101, param0102):
# 处理参数 param0101 和 param0102 的代码,如
ret = param0101 * param0102
# 假设结果保存在名为 ret 的变量中
return ret
def func_02(param0201, param0202):
# 处理参数 param0201 和 param0202 的代码,如
ret = param0201 / param0202
# 假设结果保存在名为 ret 的变量中
return ret
def call_other_funcs(param0101, param0102, param0201, param0202):
# 对传入参数 param0x0y 等进行一些处理后,存到了 param0x0y_changed 中。例如
param0x0y = param0x0y * 10
ret01 = func_01(param0101_changed, param0102_changed)
ret02 = func_02(param0201_changed, param0202_changed)
# 对得到的 ret01, ret02 进行一些处理,得到最终返回值 ret。例如
if ret01 and ret02:
ret = (ret01, ret02)
elif ret01 and (not ret02):
ret = (ret01, 0)
elif (not ret01) and ret02
ret = (0, ret02)
else:
ret = (0, 0)
return ret
要关注的点有2:
1. 每个测试仅保证 1 个单元的内部流程正确,即待测单元;这种正确性是不依赖于外部流程的正确性的
所以要假定该单元内调用的外部单元(如引入的模块、函数等)能返回预期结果——这通过所谓的 Mock 来实现,可理解为就是伪造出预期结果;至于那个外部单元能不能真的按给定输入返回预期结果,那是那个外部单元对应的单元测试应该负责的事。
以上述示例程序 to_test.py 为例,本原则所关注的点体现在:
-
对 call_other_funcs 编写单元测试时,我们仅关注上述除了 ret01 赋值和 ret02 赋值以外的代码是否被正确执行:
- 我们仅关注 param0101..param0202 在输入后会不会执行 +100 的操作乃至生成对应的 param0101_changed...param0202_changed
- 我们仅关注当 ret01 和 ret02 得到给定值后会不会执行那套 if-elif-elif-else 以生成我们想要的 (ret01, ret02)、(ret01, 0)、(0, ret02) 三者之一
-
我们并不关注 ret01、ret02 是怎么由 param0101_changed...param0202_changed 通过 func_01、func_02 生成我们想要的值,那是对 func_01、func_02的测试应该负责的部分。
- 比如当我们想测第一个 if 时,我们就令 func_01 与 func_02 均返回 0;要测第 1 个 elif 时,我们就令 func_01 返回 1, 令 func_02 返回 0;遥测第 2 个 elif 时,我们就令 func_01 返回 0, 令 func_02 返回 1;要测 else 时,我们就令 func_01 与 func_02 均返回 0
- 假设我们不伪造 func_01 和 func_02 的返回结果,而是直接让参数 param0101_changed...param0202_changed 传给 func_01 和 func_02、由它们来返回想要的结果如 (0, 0),我们实际上测试的不是上述的 call_other_funcs,而是在测下面的代码——多测了这两个函数的内部逻辑,换言之我们不仅仅在保证 1 个单元的正确性,而是在同时保证 3 个单元的正确性
def call_other_funcs_new(param0101, param0102, param0201, param0202):
# 对传入参数 param0x0y 等进行一些处理后,存到了 param0x0y_changed 中。例如
param0x0y = param0x0y * 10
ret01 = param0101_changed * param0102_changed # 我们还测了 func_01 的内部处理逻辑
ret02 = param0201_changed / param0202_changed # 我们还测了 func_02 的内部处理逻辑
# 对得到的 ret01, ret02 进行一些处理,得到最终返回值 ret。例如
if ret01 and ret02:
ret = (ret01, ret02)
elif ret01 and (not ret02):
ret = (ret01, 0)
elif (not ret01) and ret02
ret = (0, ret02)
else:
ret = (0, 0)
return ret
2. 测试要尽可能覆盖到所有语句
仍然以上述函数为例,这里并不只是说测试需要覆盖所有的 if-else 分支,而更着重于强调对上一条原则的配合,即:尽管我们要伪造一些函数的值,但我们也要保证对应的函数调用了指定的参数。
这是因为函数调用的参数可能依赖于调用函数前的代码,因此确保函数调用了指定参数,这种行为则确保了函数调用前那些(涉及到参数的)代码能够正确执行。例如上述代码中, 如果我们仅仅令 funcs_01 的返回值为某值,而没有去检查 funcs_01 到底调用的参数是不是我们预期的参数,那么实际上我们就并没有测试到像 param0101 = param0101 * 10 这样的代码。
上述 2 点是基本原则。在此之上,根据我踩的坑,还有 1 点想补充:
1. 测试要写得「傻」一点
感谢首席测试小姐姐指出:测试不仅仅是为了保证功能正确,也是一份「代码阅读指南」——即当待测单元的行为不容易理解时,用户可以通过阅读这份代码对应的单元测试来理解程序行为。
仍以上述对 to_test.py 的测试为例。(下面的 @mock.patch.object 与 mock_funcs_0x.return_value 配合,实现「伪造函数值」)
坏样例:
from unittest import mock
import to_test
class ToTestTestCase(unittest.TestCase):
# ...其他测试函数...
@mock.patch.object(to_test, 'funcs01')
@mock.patch.object(to_test, 'funcs02')
def test_call_other_funcs(self, mock_funcs_01, mock_func_02):
funcs_ret_values = [
{"funcs_01": 1, "funcs_02": 1},
{"funcs_01": 1, "funcs_02": 0},
{"funcs_01": 0, "funcs_02": 1},
{"funcs_01": 0, "funcs_02": 0}
]
for funcs_ret in funcs_ret_values:
mock_funcs_01.return_value = funcs_ret["funcs_01"]
mock_funcs_02.return_value = funcs_ret["funcs_02"]
# 剩下的测试语句……
好样例:
from unittest import mock
import to_test
class ToTestTestCase(unittest.TestCase):
# ...其他测试函数...
@mock.patch.object(to_test, 'funcs01')
@mock.patch.object(to_test, 'funcs02')
def test_call_other_funcs_if(self, mock_funcs_01, mock_func_02):
mock_funcs_01.return_value = 1
mock_funcs_02.return_value = 1
# 剩下的测试语句……
@mock.patch.object(to_test, 'funcs01')
@mock.patch.object(to_test, 'funcs02')
def test_call_other_funcs_elif_1(self, mock_funcs_01, mock_func_02):
mock_funcs_01.return_value = 1
mock_funcs_02.return_value = 0
# 剩下的测试语句……
@mock.patch.object(to_test, 'funcs01')
@mock.patch.object(to_test, 'funcs02')
def test_call_other_funcs_elif_2(self, mock_funcs_01, mock_func_02):
mock_funcs_01.return_value = 0
mock_funcs_02.return_value = 1
# 剩下的测试语句……
@mock.patch.object(to_test, 'funcs01')
@mock.patch.object(to_test, 'funcs02')
def test_call_other_funcs_else(self, mock_funcs_01, mock_func_02):
mock_funcs_01.return_value = 0
mock_funcs_02.return_value = 0
# 剩下的测试语句……
第一种写法看起来更简洁,更「模块化」,对于单个函数的测试被封装到了同一个函数中;但首先要面临的问题就是:
每次你在阅读测试函数是如何测试目标函数时,就要到上述的 list(如这里的 funcs_ret_values)中去查对应的函数到底被伪造成了什么值。
乍一看,这在需要伪造的函数值较少时看起来还不是大问题;但当需要伪造的函数数量多起来时,上面的 list of dict 就会变得冗长无比,非常不容易阅读。
更严重的是第二个问题:
设想一种情景:你需要测试函数内部的 2 条不同逻辑 A 和 B,而这些不同逻辑会返回同样的值 a。那么,由于测试是在for循环中进行的,当你发现希望返回 a 的时候没有返回 a,你就不知道到底是在测逻辑 A 时出了错,还是在测逻辑 B 时出了错。于是你可能不得不非常仔细地去检查样例,手动再模拟一遍测试的过程,而且还要手动模拟 2 次:既要考虑模拟逻辑 A,也要考虑模拟逻辑 B。这加大了debug测试程序的难度。
第二种写法虽然看上去更琐碎,但由于测试粒度比较小,上述这两个问题就都不复存在了。
编写方法
基本单元测试框架
基本测试框架如下。先阅读,再解释:
假设我们有一个类似刚才的 to_test.py 的待测函数 your_mod_name.py
# 引入单元测试模块(unittest)和伪造模块(mock)
import unittest
from unittest import mock
import your_mod_name
class YourModNameTestCase(unittest.TestCase):
def setUp(self):
"""
若每个单元测试前都要用到同一组数据,则在这里编写,如
self.var00 = val00
self.var01 = val01
不一定要有
"""
pass
def test_funcs_01(self):
"""
测试 funcs_01
所有要进行通过自动化测试框架的运行都以 test_ 开头
"""
actual_output = your_mode_name.funcs_01(5, 2)
self.assertEqual(
10,
actual_output
)
def test_funcs_02(self):
"""
测试 funcs_02
"""
actual_output = your_mode_name.funcs_02(9, 3)
self.assertEqual(
3,
actual_output
)
@mock.patch.object(your_mod_name, 'funcs_02')
@mock.patch.object(your_mod_name, 'funcs_01')
def test_call_other_funcs_branch_01(self, mock_funcs_01, mock_funcs_02):
mock_funcs_01.return_value = (5 * 10) * (2 * 10)
mock_funcs_02.return_value = (9 * 10) / (3 * 10)
actual_output = your_mode_name.funcs_01(5, 2, 9, 3)
mock_funcs_01.assert_called_with(5 * 10, 2 * 10)
mock_funcs_02.assert_called_with(9 * 10, 3 * 10)
self.assertEqual(
((5 * 10) * (2 * 10), (9 * 10) / (3 * 10)),
actual_output
)
# 对 call_other_funcs 其他分支的测试
#
# ……
#
# 对其他函数的测试
以下解释上述代码
1. 最简单的测试
对于像 funcs_01 和 funcs_02 这样的函数写测试是非常简单的:我们只要
- 引入待测模块
- 把参数传给 待测模块.待测函数,取得返回值 actual_output 即为实际输出
- 使用 assertEuqal 之类以 assert 开头的函数来断言:实际行为与预期行为一致。通常至少用 assertEqual 来断言:实际输出(actual_output)与预期输出一致;在安排「实际输出」与「预期输出」在 assertEqual 中的参数顺序时,我的做法是:第一个参数是「预期输出」,第二个参数是「实际输出」;理由是「预期输出」的形状和长度是固定的,「实际输出」的形状和长度通常会有各种变化(当测试出错或函数没有执行预期行为时),把「实际输出」安排在后面,在调试测试函数时,我们的视线关注点是固定的。
- 对于比较复杂的函数如 call_other_funcs 的测试,可能还需要使用 assert_called_with 等方法断言函数调用的参数,见 https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.assert_called_with 。当需要断言多次调用时(例如同一个函数 funcs_01 被调用了 3 次,每次传入了不同参数),可以考虑使用 assert_has_calls,见 https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.assert_has_calls
- 其他的断言方法可见 https://docs.python.org/3/library/unittest.html#assert-methods 对于 mock 对象的断言方法可见 https://docs.python.org/3/library/unittest.mock.html#the-mock-class
2. 如何伪造一个对象
2.1 通用的 mock 框架
一般来说,我们把「伪造」称为 mock,因为这就是 Python 中的伪造类的名字。
可以通过装饰器 @mock.patch.object 的写法「制造」 mock 对象,见 https://docs.python.org/3/library/unittest.mock.html#patch-object
以上述写法为例:
@mock.patch.object(your_mod_name, 'funcs_02')
@mock.patch.object(your_mod_name, 'funcs_01')
def test_call_other_funcs_branch_01(self, mock_funcs_01, mock_funcs_02):
上面这三句的意思是:
把函数 your_mod_name.funcs_01 伪造成 mock_funcs_01,把 your_mod_name.funcs_02 伪造成 mock_funcs_02。这里的 mock_funcs_01 和 mock_funcs_02 的变量名没有特别的规定,这和一般的变量命名没有两样,也可以命名为 mock_weird_name_007, mock_I_dont_know_why_101 等奇奇怪怪的名字。现在这样命名只是为了方便理解。
在「制造」出 mock 对象后,我们要给 mock 对象赋值,因为伪造传值才是我们的实际目标。以上述代码为例,该目标通过这两句来完成:
mock_funcs_01.return_value = (5 * 10) * (2 * 10)
mock_funcs_02.return_value = (9 * 10) / (3 * 10)
注意:上面这两句,即所有伪造值的语句,都要在调用函数的语句前执行,即下面这句之前执行上述两句:
actual_output = your_mode_name.funcs_01(5, 2, 9, 3)
而 return_value 也可以在装饰器中就指定,例如:
@mock.patch.object(your_mod_name, 'funcs_02', return_value=(9 * 10) / (3 * 10))
@mock.patch.object(your_mod_name, 'funcs_01', return_value=(5 * 10) * (2 * 10))
def test_call_other_funcs_branch_01(self, mock_funcs_01, mock_funcs_02):
# 此后就不用再写 mock_funcs_01.return_value = (5 * 10) * (2 * 10) 这样的句子了
除了通过装饰器来伪造,还可以通过上下文管理器(context manager)的方法来伪造 。同样是类似上述代码,可以写为:
@mock.patch.object(your_mod_name, 'funcs_02')
@mock.patch.object(your_mod_name, 'funcs_01')
def test_call_other_funcs_branch_01(self, mock_funcs_01, mock_funcs_02):
with mock.patch.object(your_mod_name, 'funcs_01', \
return_value=(5 * 10) * (2 * 10)) as mock_funcs_01,
mock.patch.object(your_mod_name, 'funcs_02', \
return_value=(9 * 10) / (3 * 10)) as mock_funcs_02:
actual_output = your_mode_name.funcs_01(5, 2, 9, 3)
mock_funcs_01.assert_called_with(5 * 10, 2 * 10)
mock_funcs_02.assert_called_with(9 * 10, 3 * 10)
self.assertEqual(
((5 * 10) * (2 * 10), (9 * 10) / (3 * 10)),
actual_output
)
2.2 特殊语句顺序
所有的伪造值语句,必须要在调用 actual_output 的赋值语句(即实际调用目标函数)之前执行。
所有的断言语句,包括 assertEqual 或 assert_called_with,必须要在调用 actual_output 的赋值语句(即实际调用目标函数)之后执行。这是因为需要断言的值、对象都要在函数执行后才会产生(需要断言的参数调用也是一种值,也要在函数执行后,才会在内存中留下「痕迹」,在执行之前,程序无法知道待测函数内部调用的其他函数到底调用了什么参数)。
要注意:调用 assert_called_with 的一定是某个 mock 对象而非 self,这是与 assertEqual 最大的区别
2.3 使用场景
更具体地说,分为 2 种情况:(1)在诸多测试用例中,每个对象的返回值之间各自独立;(2)这些返回值之间符合某种函数关系
根据这 2 种情况,对应的有 4 种伪造对象的写法。其中 2.3.1 对应第 (1) 种情况,2.3.2~2.3.4 对应第 (2) 种情况:
2.3.1 一般外部模块、网络连接、数据库连接
常见于伪造一般外部模块(自己或团队其他成员写的模块、开源库模块等)、数据库连接、网络连接的返回结果。都是类似上面对 funcs_01 的 mock 方法。给出 2 个在数据库连接和网络连接方面的 mock 示例代码:
假设使用数据库连接的原始代码为:
# your_mod_name.py
import db_conn
# some other function code ...
def funcs_with_db_connection(params):
# some code ...
answers = db_conn.query(sql) # sql 是指定的 SQL 语句字符串
# some code to process answers
# 假定 ret 是返回变量
return ret
则对应的 mock 代码为:
# 数据库连接
import your_mod_name
@mock.patch.object(your_mod_name.db_conn, 'query', return_value=['000001', '000002'])
def test_funcs_with_db_connection(self, mock_db_conn):
# params 是参数
# 在有了上面
actual_output = your_mod_name.funcs_with_db_connection(params)
网络连接则以 requests.get 为例:
# your_mod_name.py
import requests
# some other function code ...
def funcs_with_requests(params):
# some code ...
answers = requests.get('your_url_to_site') # 从 your_url_to_site 获取信息
# some code to process answers
# 假定 ret 是返回变量
return ret
这时候返回的对象可能有多个属性如 status_coe 和 text,而且都要用上。那么此时对应的 mock 代码可写成:
@mock.patch.object(your_mod_name.requests, 'get')
def test_funcs_with_requests(self, mock_requests_get)
mock_response = mock.Mock()
mock_response.status_code = status
mock_response.text = {'key01': 'val01', 'key02': 'val02'}
mock_requests.return_value = mock_response
2.3.2 伪造成一个指定函数:转发输入
相当于把原始函数的输入值「转发」到指定函数上。例如原始代码为:
# your_mod_name.py
def format_answers(raw_string):
# 处理 raw_string 非常复杂的处理逻辑
# 假设处理完后保存到 good_string 中
return good_string
def funcs_with_format_answers(params):
# 某些代码生成了原始答案字符串 raw_string
answers = format_answers(raw_string)
# some code ... 返回 ret
return ret
比如在测试函数中,我多次调用了该函数,但我不想对每次 mock 都指定一个值,那么可以这么做:
import your_mod_name
def mock_format_answers(input_string):
return input_string
@mock.patch.object(your_mod_name, 'format_answers', mock_format_answers):
def test_funcs_with_format_answers(self):
# some code for testing
注意到当我们指定了转发目标后,实际上指定了「制造」的 mock 对象为我们设计好的函数,这样就不需要在函数头中再写 mock_format_answers 了(如果写,反而会报错)。
2.3.3 从一个伪造类生成一个伪造对象
设想一个情况:你在原始函数中调用了某个类 ClassA 生成了实例 instance_A,并在原始函数中使用了该类的多个方法。那么一次次 mock 这个函数的一个个方法,可能看着或写着繁琐。在这种情况下,我们就可以通过将对应的类转发到我们设计好的伪造类上,并在伪造类下定义需要 mock 的方法,从而 mock 一个类就相当于 mock 了和该类相关的所有方法。
一个简单的例子是:某个函数内部调用了 time 这个类的 time() 和 sleep() 方法,例如:
# your_mod_name.py
import time
def funcs_with_time(params):
t_start = time.time()
# some code ...
t_cost = time.time() - t_start
ret = []
while t_cost <= 10:
# do something
time.sleep(0.5)
t_cost = time.time() - t_start
if t_cost > 5:
ret.append('good')
return ret
在我们编写测试的时候,如果不将 time 这个类 mock 掉,那么程序的行为就不可预测:程序运行时是一个随机行为,我们如果不能「控制时间」,就不能保证测试函数在测试时能走到目标函数中的指定分支。
我们只要在测试函数中这么写即可:
import your_mod_name
class MockTime(object):
"""
用于 time 的 Mock 类
"""
def __init__(self):
self.time_count = -0.5 # 配合 time() 方法使 ts_start = 0
def time(self):
"""
每次对象被调用时会运行这里的代码。
"""
if self.time_count == -0.5:
self.time_count = 0.0
return self.time_count
def sleep(self, gap):
"""
0.5 的增量保证能在 cost 超过 10 之前触发:销毁 Token,返回 URL
"""
self.time_count += 0.5
return
mock_time_helper = MockTime()
class YourModNameTestCase(unittest.TestCase):
# some code for testing other functions ...
@mock.patch.object(your_mod_name, 'time', mock_time_helper)
def test_funcs_with_time(self):
# 这样就可以将不可控的「程序运行时」变为可控的「计数器」
# some code for testing ...
2.3.4 如何伪造内置函数(built-in functions)的返回结果,如open(path_to_file).readlines()
这其实是 2.3.3 这个情况的一个特例,但也是一个容易让人抓狂的点。比如有时候我们需要测试的函数内有一个 open 函数,在打开文件后还调用了 readlines() 方法。那么我们如何 mock 掉 open?或者 open 返回的对象类名是啥,我能不能去 mock 那个类对应的 readlines() 方法?其实这个问题的关键在于:内置函数的类是什么?
答案是:builtins https://docs.python.org/3/library/builtins.html
那么之后就能够像 2.3.3 一样去处理了。
例如原始代码是
# your_mod_name.py
def funcs_with_open(params):
# some code ...
tokens = [line.strip('\n') for line in open('path_to_file').readlines()]
return tokens
对应的测试函数中可以这么写
import unittest
import builtins
import your_mod_name
class MockOpen(object):
"""
内置函数 open 的 mock 类
"""
def __init__(self, data):
assert isinstance(data, list), '请输入一个列表: {}'.format((data))
self.data = data
def __call__(self, blabla):
return self
def readlines(self):
return self.data
class YourModNameTestCase(unittest.TestCase):
# some code to test other functions
def test_funcs_with_open(self):
with mock.patch('builtins.open', MockOpen(['test00\n', 'test01\n']))
# some code to get params
actual_output = your_mod_name.funcs_with_open(params)
self.assertEqual(
['test00', 'test01'],
actual_output
)
对基于网站框架搭建的网络应用进行单元测试
和编写网络应用一样:编写网络应用(即涉及到网络通信的程序)的单元测试,与编写一般程序的单元测试基本一致,最大的差别就在于:网络应用和网络应用的单元测试一般需要额外关注:
- 路由:要通过哪个URI进行数据操作,例如要从哪里去GET数据、把数据POST到哪里(一般必须处理)
- 状态码:返回状态的设置和捕捉(不一定要捕捉或设置)
这里就不做太多展开,在 2 个框架下各给出 1 个例子并做简要解释,更多情况请参阅对应的文档,或等我日后填坑(然后可能就不知不觉弃坑了?)
[Flask]
更多有关 Flask 的测试方法,见
https://pythonhosted.org/Flask-Testing/
http://flask.pocoo.org/docs/0.12/testing/
原程序
@app.route('/userapi/get_phone_number', methods=['POST'])
def get_phone_number():
# some code to get phone number
对应测试程序中如何调用该程序:
# request_data 是已经处理过的要 POST 的 JSON
rv = self.client.post('/userapi/get_phone_number', data=request_data)
注意到这里的 '/userapi/get_phone_number' 就是调用路由
[Tornado]
更多有关 Tornado 的测试方法,见 http://www.tornadoweb.org/en/stable/testing.html
原项目中由这个程序指定了程序路由:
# server.py
class Application(tornado.web.Application):
def __init__(self):
handlers = [
# some other handlers ...
(r"/qaapi/qa", RESTfulAPIHandler) # 已有 RESTfulAPIHandler.py 是对应应用
]
tornado.web.Application.__init__(self, handlers)
# some other codes ...
要测试时:
uri = '/qaapi/qa?userid={}&token={}'.format(userid, token)
data = get_data()
response = self.fetch(uri, method="POST", body=data)
self.assertEqual(400, response.code)
self.assertEqual('expected_output', response.buffer.getvalue())