编写测试单元的目的主要有两个,实现新功能时,单元测试能够确保新添加的代码按预期方式运行,这个过程也可手动完成,不过自动化测试显然能有效节省时间和精力
另一重要目的是每次修改程序后,运行单元测试能保证现有代码的功能没有退化, 也就是说改动没有影响原有代码的正常运行
在最开始,单元测试就是Flasky开发的一部分,我们为数据库模型类中实现的程序功能编写了测试,模型类很容易在运行中的程序上下文之外进行测试,因此不用花费太多精力,为数据库模型中是瞎玩呢的全部功能编写测试,这至少能有效保证程序这部分在不断完善的过程中仍能按预期运行
获取代码覆盖报告
编写测试组件很重要,但知道测试的好坏同样重要,代码覆盖工具用来统计单元测试检查了多少程序功能,并提供一个详细的报告,说明程序的哪些代码没有测试到,这个信息非常重要,因为它能指引你为最需要测试的部分编写出新测试
Python提供了一个优秀的代码覆盖工具coverage,可以使用pip安装
这个工具本身是一个命令行脚本,可以在任何一个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
命令添加一个布尔值选项,只需在test()
函数中添加一个布尔值参数即可,Flask-Script根据参数名确定选项名,并据此向函数中传入True
或False
不过,把代码覆盖集成到manage.py
脚本中有个小问题,test()
函数收到 --coverage
选项的值后再启动覆盖测试已经晚了,那时全局作用域中的所有代码都已经执行了,为了检测的准确性,设定完环境变量FLASK_COVERAGE
后,脚本会重启,再次运行时,脚本顶端的代码发现已经设定了环境变量,于是立即启动覆盖检测
函数coverage.coverage()
用于启动覆盖测试引擎,branch=True
选项开启分支覆盖分析,除了跟踪哪行代码已经执行外,还要检查每个条件语句的True
分支和False
分支是否都执行了,include
选项用来限制程序包中文件的分析范围,只对这些文件中的代码进行覆盖检测,如果不指定include选项,虚拟环境中安装的全部扩展和测试代码会包含进覆盖报告中,给报告添加很多杂项
执行完所有测试后,test()
函数会在终端输出报告,同时还会生成一个使用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”列的内容,这一列显示测试没有覆盖的源码行,是一个由行号范围组成的长列表
Flask测试客户端
程序的某些代码严重依赖运行中的程序所创建的环境,例如不能直接调用视图函数中的代码进行测试,因为这个函数可能需要访问Flask上下文全局变量,如request
或session
,视图函数还可能等待接受POST请求中的表单数据,而且某些视图函数要求用户先登录,简言之,视图函数只能在请求上下文和运行的程序中运行
Flask内建了一个测试客户端用于解决(部分解决)这一问题,测试客户端能复现程序运行在Web服务器中的环境,让测试扮演成客户端从而发送请求
在测试客户端中运行的视图函数和正常情况下的没有太大区别,服务器收到请求,将其分配给适当的视图函数,视图函数生成响应,将其返回给测试客户端,执行视图函数后,生成的响应会传入测试,检查是否正确
测试Web程序
下例是一个使用测试客户端编写的单元测试框架
#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
选项,这个测试客户端就能像浏览器一样接受和发送cookie,因此能使用依赖cookie的功能记住请求之间的上下文,值得一提的是,这个选项可用来启用用户会话,让用户登录和退出
test_home_page()
测试作为一个简单的例子演示了测试客户端的作用,在这个例子中,客户端向首页发起了一个请求,在测试客户端上调用get()
方法得到的结果是一个Response
对象,内容是用视图函数得到的响应,为了检查测试是否成功,要在响应主体中搜索是否包含‘Stranger’这个词,响应主体可使用response.get_data()
获取,而‘Stranger’这个词包含在向向名用户显示的欢迎消息是“Hello,Stranger”中,注意的是,默认情况下get_data()
得到的响应主体是一个字节数组,传入参数as_text=True
后得到的是一个更易于处理的Unicode字符串
测试客户端还能使用post()方法发送包含表单数据的POST请求,不过提交表单时会有一个小麻烦,Flask-WTF生成的表单中包含一个隐藏字段,其内容是CSRF令牌,需要和表单中的数据一起提交,为了复现这个功能,测试必须请求包含表单的页面,然后解析响应返回的HTML代码并提取令牌,这样才能把令牌和表单中的数据一起发送,为了避免在测试中处理CSRF令牌这一繁琐操作,最好在测试配置中禁用CSRF保护功能,实现方法如下:
# config.py
class TestingConfig(Config):
#...
WTF_CSRF_ENABLED = False
下面是一个更高级的单元测试,模拟了新用户注册账户、登录、使用令牌确认账户以及退出的过程
# 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)
这个测试先向注册路由提交一个表单,post()
方法的data
参数是个字典,包含表单中的各个字段,各字段的名字必须严格匹配定义表单时使用的名字,由于CSRF保护已经在测试配置中禁用了,因此无需和表单数据一起发送
/auth/register
路由有两种响应方式,如果注册数据可用,会返回一个重定向,把用户转到登录页面,注册不可用的情况下,返回的响应会再次渲染注册表单,而且还包含适当的错误信息,为了确认注册成功,测试会检查响应的状态码是否为302,这个代码表示重定向
这个测试的第二部分使用刚才注册时使用的电子邮件和密码登录程序,这一工作通过向/auth/login
路由发起POST请求完成,这一次,调用post()
方法时指定了参数follow_redirects=True
,让测试客户端和浏览器一样,自动向重定向的URL发起GET请求,指定这个参数后,返回的不是302状态码,而是请求重定向的URL返回的响应
成功登录后的响应应该是一个页面,显示一个包含用户名的欢迎消息,并提醒用户需要进行账户确认才能获得权限,为此,两个断言语句被用于检查响应是否为这个页面,值得注意的是,直接搜索字符串“Hello,john!”并没有用,因为这个字符串由动态部分和静态部分组成,而且两部分之间有额外的空白,为了避免测试时空白引起的问题,我们使用更为灵活的正则表达式
下一步我们要确认账户,这里也有一个小障碍,在注册过程中,通过电子邮件将确认URL发给用户,而在测试中处理电子邮件不是一件简单的事情,上面这个测试使用的解决方法是忽略了注册时生成的令牌,直接在User
实例上调用方法重新生成一个新令牌,在测试环境中,Flask-Mail会保存邮件正文,所以还有一种可行的解决方法,即通过解析邮件正文来提取令牌
得到令牌后,测试的第三部分模拟用户点击确认令牌URL,这一过程通过向确认URL发起GET请求并附上确认令牌来完成,这个请求的响应是重定向,转到首页,但这里再次指定了参数follow_redirects=True
,所以测试客户端会自动向重定向的页面发起请求,此外,还要检查响应中是否包含欢迎消息和一个向用户说明确认成功的Flash消息
这个测试的最后一步是向退出路由发送GET请求,为了证实确认退出,这段测试在响应中搜索一个Flash消息
测试Web服务
Flask客户端还可用来测试REST Web服务,下例包含了两个测试:
def get_api_headers(self, username, password):
return {
'Authorization':
'Basic ' + b64encode(
(username + ':' + password).encode('utf-8')).decode('utf-8'),
'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-8'))
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()
方法和测试普通程序所用的一样,不过API不使用cookie,所以无需配置相应支持,get_api_headers
是一个辅助方法,返回所有请求都要发送的通用首部,其中包含认证密令和MIME类型相关的首部,大多数测试都要发送这些首部
test_no_auth()
是一个简单的测试,确保Web服务会拒绝没有提供认证密令的请求,返回401错误码,test_posts()
测试把一个用户插入数据库,然后使用基于REST的API创建一篇博客文章,然后再读取这篇文章,所有请求主体中发送的数据都要使用json.dumps()
方法进行编码,因为Flask测试客户端不会自动编码JSON格式数据,类似的,返回的响应主体也是JSON格式,处理之前必须使用json.loads()
进行编码