编写测试单元的目的主要有两个,实现新功能时,单元测试能够确保新添加的代码按预期方式运行,这个进程也可手动完成,不过自动化测试明显能有效节省时间和精力
另外一重要目的是每次修改程序后,运行单元测试能保证现有代码的功能没有退化, 也就是说改动没有影响原有代码的正常运行
在最开始,单元测试就是Flasky开发的1部份,我们为数据库模型类中实现的程序功能编写了测试,模型类很容易在运行中的程序上下文以外进行测试,因此不用花费太多精力,为数据库模型中是瞎玩呢的全部功能编写测试,这最少能有效保证程序这部份在不断完善的进程中仍能按预期运行
编写测试组件很重要,但知道测试的好坏一样重要,代码覆盖工具用来统计单元测试检查了多少程序功能,并提供1个详细的报告,说明程序的哪些代码没有测试到,这个信息非常重要,由于它能指引你为最需要测试的部份编写出新测试
Python提供了1个优秀的代码覆盖工具coverage,可使用pip安装
这个工具本身是1个命令行脚本,可以在任何1个Python程序中检查代码覆盖,除此以外它还提供了更方便的脚本访问功能,使用编程方式启动覆盖检查引擎,为了能更好地把覆盖监测集成到启动脚本manage.py
中,我们可以增强之前我们自定义的test命令,添加可选选项--coverage
,这个选项的实现方式以下:
import os
COV = None
if os.environ.get('FLASK_COVERAGE'):
import coverage
COV = coverage.coverage(branch=True, include='app/*')
COV.start()
#...
@manager.command
def test(coveage=False):
'''Run the unit tests.'''
if coverage and not os.environ.get('FLASK_COVERAGE'):
import sys
os.environ['FLASK_COVERAGE'] = '1'
os.execvp(sys.executable, [sys.executable] + sys.argv)
import unittest
tests = unittest.TestLoader().discover('tests')
unittest.TextTestRunner(verbosity=2).run(tests)
if COV:
COV.stop()
COV.save()
print('Coverage Summary:')
COV.report()
basedir = os.path.abspath(os.path.dirname(__file__))
covdir = os.path.join(basedir, 'tmp/coverage')
COV.html_report(directory=covdir)
print('HTML version: file://%s/index.html' % covdir)
#...
在Flask-Script中,自定义命令很简单,若想为test
命令添加1个布尔值选项,只需在test()
函数中添加1个布尔值参数便可,Flask-Script根据参数名肯定选项名,并据此向函数中传入True
或False
不过,把代码覆盖集成到manage.py
脚本中有个小问题,test()
函数收到 --coverage
选项的值后再启动覆盖测试已晚了,那时全局作用域中的所有代码都已履行了,为了检测的准确性,设定完环境变量FLASK_COVERAGE
后,脚本会重启,再次运行时,脚本顶真个代码发现已设定了环境变量,因而立即启动覆盖检测
函数coverage.coverage()
用于启动覆盖测试引擎,branch=True
选项开启分支覆盖分析,除跟踪哪行代码已履行外,还要检查每一个条件语句的True
分支和False
分支是不是都履行了,include
选项用来限制程序包中文件的分析范围,只对这些文件中的代码进行覆盖检测,如果不指定include选项,虚拟环境中安装的全部扩大和测试代码会包括进覆盖报告中,给报告添加很多杂项
履行完所有测试后,test()
函数会在终端输出报告,同时还会生成1个使用HTML编写的精美报告并写入硬盘,HTML格式的报告非常合适直观形象地展现覆盖信息,由于它依照源码的使用情况给代码行加上了不同的色彩
# (env) PS C:\Users\Bangys\AppData\Local\GitHub\flasky> python manage.py test
test_app_exists (test_basics.BasicsTestCase) ... ok
test_app_is_testing (test_basics.BasicsTestCase) ... ok
test_home_page (test_client.FlaskClientTestCase) ... ok
test_register_and_login (test_client.FlaskClientTestCase) ... ok
test_anonymous_user (test_user_model.UserModelTestCase) ... ok
test_duplicate_email_change_token (test_user_model.UserModelTestCase) ... ok
test_expired_confirmation_token (test_user_model.UserModelTestCase) ... ok
test_follows (test_user_model.UserModelTestCase) ... ok
test_gravatar (test_user_model.UserModelTestCase) ... ok
test_invalid_confirmation_token (test_user_model.UserModelTestCase) ... ok
test_invalid_email_change_token (test_user_model.UserModelTestCase) ... ok
test_invalid_reset_token (test_user_model.UserModelTestCase) ... ok
test_no_password_getter (test_user_model.UserModelTestCase) ... ok
test_password_salts_are_random (test_user_model.UserModelTestCase) ... ok
test_password_setter (test_user_model.UserModelTestCase) ... ok
test_password_verification (test_user_model.UserModelTestCase) ... ok
test_ping (test_user_model.UserModelTestCase) ... ok
test_roles_and_permissions (test_user_model.UserModelTestCase) ... ok
test_timestamps (test_user_model.UserModelTestCase) ... ok
test_to_json (test_user_model.UserModelTestCase) ... ok
test_valid_confirmation_token (test_user_model.UserModelTestCase) ... ok
test_valid_email_change_token (test_user_model.UserModelTestCase) ... ok
test_valid_reset_token (test_user_model.UserModelTestCase) ... ok
----------------------------------------------------------------------
Ran 23 tests in 10.205s
OK
Coverage Summary:
Name Stmts Miss Branch BrPart Cover
-------------------------------------------------------------------------
app\__init__.py 33 0 0 0 100%
app\api_1_0\__init__.py 3 0 0 0 100%
app\api_1_0\authentication.py 30 19 10 0 28%
app\api_1_0\comments.py 40 30 8 0 21%
app\api_1_0\decorators.py 11 3 2 0 62%
app\api_1_0\errors.py 17 10 0 0 41%
app\api_1_0\posts.py 35 23 6 0 29%
app\api_1_0\users.py 30 24 8 0 16%
app\auth\__init__.py 3 0 0 0 100%
app\auth\forms.py 45 6 8 2 77%
app\auth\views.py 109 56 40 6 42%
app\decorators.py 14 3 2 0 69%
app\email.py 15 0 0 0 100%
app\exceptions.py 2 0 0 0 100%
app\main\__init__.py 6 0 0 0 100%
app\main\errors.py 20 15 6 0 19%
app\main\forms.py 39 7 4 0 74%
app\main\views.py 169 120 30 2 27%
app\models.py 243 59 40 5 73%
-------------------------------------------------------------------------
TOTAL 864 375 164 15 51%
HTML version: file://C:\Users\Bangys\AppData\Local\GitHub\flasky\tmp/coverage/index.html
上述报告显示,整体覆盖率为51%,情况其实不糟,但也不太好,现阶段,模型类是单元测试的关注焦点,它包括243个语句,测试覆盖了其中72%的语句,很明显,main和auth蓝本中的views.py
文件和api_1_0
蓝本中的路由的覆盖率都很低,所以我们没有为这些代码编写单元测试
有了这个报告,我们就可以很容易肯定向测试组件中添加哪些测试以提高覆盖率,但遗憾的是,并不是程序的所有组成部份都能像数据库模型那样易于测试,所以我们要学习如何去测试视图函数,表单和模板
注意,由于排版,实例报告中省略了“Missing”列的内容,这1列显示测试没有覆盖的源码行,是1个由行号范围组成的长列表
程序的某些代码严重依赖运行中的程序所创建的环境,例如不能直接调用视图函数中的代码进行测试,由于这个函数可能需要访问Flask上下文全局变量,如request
或session
,视图函数还可能等待接受POST要求中的表单数据,而且某些视图函数要求用户先登录,简言之,视图函数只能在要求上下文和运行的程序中运行
Flask内建了1个测试客户端用于解决(部份解决)这1问题,测试客户端能复现程序运行在Web服务器中的环境,让测试扮演成客户端从而发送要求
在测试客户端中运行的视图函数和正常情况下的没有太大区分,服务器收到要求,将其分配给适当的视图函数,视图函数生成响应,将其返回给测试客户端,履行视图函数后,生成的响应会传入测试,检查是不是正确
下例是1个使用测试客户端编写的单元测试框架
#tests/test_client.py
import unittest
from app import create_app, db
from app.models import User, Role
class FlaskClientTestCase(unittest.TestCase):
def setUp(self):
self.app = create_app('testing')
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
Role.insert_roles()
self.client = self.app.test_client(use_cookies=True)
def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()
def test_home_page(self):
response = self.client.get(url_for('main.index'))
self.assertTrue('Stranger' in response.get_data(as_text=True))
测试用例中的实例变量self.clinet
是Flask测试客户端对象,在这个对象上可调用方法向程序发起要求,如果创建测试客户端时启用了use_cookies
选项,这个测试客户端就可以像阅读器1样接受和发送cookie,因此能使用依赖cookie的功能记住要求之间的上下文,值得1提的是,这个选项可用来启用用户会话,让用户登录和退出
test_home_page()
测试作为1个简单的例子演示了测试客户真个作用,在这个例子中,客户端向首页发起了1个要求,在测试客户端上调用get()
方法得到的结果是1个Response
对象,内容是用视图函数得到的响应,为了检查测试是不是成功,要在响应主体中搜索是不是包括‘Stranger’这个词,响应主体可以使用response.get_data()
获得,而‘Stranger’这个词包括在向向名用户显示的欢迎消息是“Hello,Stranger”中,注意的是,默许情况下get_data()
得到的响应主体是1个字节数组,传入参数as_text=True
后得到的是1个更容易于处理的Unicode字符串
测试客户端还能使用post()方法发送包括表单数据的POST要求,不过提交表单时会有1个小麻烦,Flask-WTF生成的表单中包括1个隐藏字段,其内容是CSRF令牌,需要和表单中的数据1起提交,为了复现这个功能,测试必须要求包括表单的页面,然后解析响应返回的HTML代码并提取令牌,这样才能把令牌和表单中的数据1起发送,为了不在测试中处理CSRF令牌这1繁琐操作,最好在测试配置中禁用CSRF保护功能,实现方法以下:
# config.py
class TestingConfig(Config):
#...
WTF_CSRF_ENABLED = False
下面是1个更高级的单元测试,摹拟了新用户注册账户、登录、使用令牌确认账户和退出的进程
# test/text_client.py
class FlaskClientTestCase(unittest.TestCase):
def text_register_and_login(self):
# new account
response = self.clinet.post(url_for('auth.register'), data={
'email':'john@example.com',
'username':'john',
'password':'cat',
'passwprd2':'cat'
})
self.assertTrue(response.status_code == 302)
# use new account login
response = self.clinet.post(url_for('auth.login'), data={
'email':'john@example.com',
'password':'cat'
}m follow_redirects=True)
data = response.get_data(as_text=True)
self.assertTrue(re.search('Hello, \s+john', data))
self.assertTrue('You have not confirmed your account yet' in data)
# send confirm token
user = User.query.filter_by(email = 'john@example.com').first()
token = user.generate_confirmation_token()
response = self.client.get(url_for('auth.comfirm', token=token),
follow_redirects=True)
data = response.get_data(as_text=True)
self.assertTrue('You have comfirmed your account' in data)
#quit
response = self.client.get(url_for('auth.logout'),
follow_redirects=True)
data = response.get_data(as_text=True)
self.assertTrue('You have been logged out' in data)
这个测试先向注册路由提交1个表单,post()
方法的data
参数是个字典,包括表单中的各个字段,各字段的名字必须严格匹配定义表单时使用的名字,由于CSRF保护已在测试配置中禁用了,因此无需和表单数据1起发送
/auth/register
路由有两种响应方式,如果注册数据可用,会返回1个重定向,把用户转到登录页面,注册不可用的情况下,返回的响应会再次渲染注册表单,而且还包括适当的毛病信息,为了确认注册成功,测试会检查响应的状态码是不是为302,这个代码表示重定向
这个测试的第2部份使用刚才注册时使用的电子邮件和密码登录程序,这1工作通过向/auth/login
路由发起POST要求完成,这1次,调用post()
方法时指定了参数follow_redirects=True
,让测试客户端和阅读器1样,自动向重定向的URL发起GET要求,指定这个参数后,返回的不是302状态码,而是要求重定向的URL返回的响应
成功登录后的响应应当是1个页面,显示1个包括用户名的欢迎消息,并提示用户需要进行账户确认才能取得权限,为此,两个断言语句被用于检查响应是不是为这个页面,值得注意的是,直接搜索字符串“Hello,john!”并没有用,由于这个字符串由动态部份和静态部份组成,而且两部份之间有额外的空白,为了不测试时空白引发的问题,我们使用更加灵活的正则表达式
下1步我们要确认账户,这里也有1个小障碍,在注册进程中,通过电子邮件将确认URL发给用户,而在测试中处理电子邮件不是1件简单的事情,上面这个测试使用的解决方法是疏忽了注册时生成的令牌,直接在User
实例上调用方法重新生成1个新令牌,在测试环境中,Flask-Mail会保存邮件正文,所以还有1种可行的解决方法,即通过解析邮件正文来提取令牌
得到令牌后,测试的第3部份摹拟用户点击确认令牌URL,这1进程通过向确认URL发起GET要求并附上确认令牌来完成,这个要求的响应是重定向,转到首页,但这里再次指定了参数follow_redirects=True
,所以测试客户端会自动向重定向的页面发起要求,另外,还要检查响应中是不是包括欢迎消息和1个向用户说明确认成功的Flash消息
这个测试的最后1步是向退前途由发送GET要求,为了证实确认退出,这段测试在响应中搜索1个Flash消息
Flask客户端还可用来测试REST Web服务,下例包括了两个测试:
def get_api_headers(self, username, password):
return {
'Authorization':
'Basic ' + b64encode(
(username + ':' + password).encode('utf⑻')).decode('utf⑻'),
'Accept':'application/json',
'Content-Type':'application/json'
}
def test_no_auth(self):
response = self.client.get(url_for('api.get_posts'),
content_Type='application/json')
self.assertTrue(response.status_code == 401)
def test_posts(self):
# add a user
r = Role.query.filter_by(name="User").first()
self.assertIsNotNone(r)
u = User(email='john@example.com', password='cat', confirmed=True, role=r)
db.session.add(u)
db.session.commit()
#write a post
response = self.clinet.post(
url_for('api.new_post'),
header=self.get_auth_header('john@example.com', 'cat'),
data=json.dumps({'body': 'body of the *blog* post'}))
self.assertTrue(response.status_code == 201)
url = response.headers.get('Location')
self.assertIsNotNone(url)
#receive post
response = self.client.get(
url,
headers=self.get_auth_header('john@example.com', 'cat'))
self.assertTrue(response_status_code == 200)
json_response = json.loads(response.data.decode('utf⑻'))
self.assertTrue(json_response['url'] == url)
self.assertTrue(json_response['body'] == 'body of the *blog* post')
self.assertTrue(json_response['body_html'] ==
'<p>body of the <em>blog</em> post</p>')
测试API时使用的setUp()
和tearDown()
方法和测试普通程序所用的1样,不过API不使用cookie,所以无需配置相应支持,get_api_headers
是1个辅助方法,返回所有要求都要发送的通用首部,其中包括认证密令和MIME类型相干的首部,大多数测试都要发送这些首部
test_no_auth()
是1个简单的测试,确保Web服务会谢绝没有提供认证密令的要求,返回401毛病码,test_posts()
测试把1个用户插入数据库,然后使用基于REST的API创建1篇博客文章,然后再读取这篇文章,所有要求主体中发送的数据都要使用json.dumps()
方法进行编码,由于Flask测试客户端不会自动编码JSON格式数据,类似的,返回的响应主体也是JSON格式,处理之前必须使用json.loads()
进行编码
上一篇 sybase函数汇总
下一篇 项目和UCenter如何整合