python 0x0101_指南与踩坑:Python 单元测试

本文试图总结编写单元测试的流程,以及自己在写单元测试时踩到的一些坑。如有遗漏,纯属必然,欢迎补充。

目录概览:

编写思想 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 中的参数顺序时,我的做法是:第一个参数是「预期输出」,第二个参数是「实际输出」;理由是「预期输出」的形状和长度是固定的,「实际输出」的形状和长度通常会有各种变化(当测试出错或函数没有执行预期行为时),把「实际输出」安排在后面,在调试测试函数时,我们的视线关注点是固定的。

2. 如何伪造一个对象

2.1 通用的 mock 框架

一般来说,我们把「伪造」称为 mock,因为这就是 Python 中的伪造类的名字。

以上述写法为例:

@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() 方法?其实这个问题的关键在于:内置函数的类是什么?

那么之后就能够像 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 的测试方法,见

原程序

@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]

原项目中由这个程序指定了程序路由:

# 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())

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值