程序员人生 网站导航

测试

栏目:php教程时间:2016-09-30 10:12:32

编写测试单元的目的主要有两个,实现新功能时,单元测试能够确保新添加的代码按预期方式运行,这个进程也可手动完成,不过自动化测试明显能有效节省时间和精力

另外一重要目的是每次修改程序后,运行单元测试能保证现有代码的功能没有退化, 也就是说改动没有影响原有代码的正常运行

在最开始,单元测试就是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根据参数名肯定选项名,并据此向函数中传入TrueFalse

不过,把代码覆盖集成到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测试客户端

程序的某些代码严重依赖运行中的程序所创建的环境,例如不能直接调用视图函数中的代码进行测试,由于这个函数可能需要访问Flask上下文全局变量,如requestsession,视图函数还可能等待接受POST要求中的表单数据,而且某些视图函数要求用户先登录,简言之,视图函数只能在要求上下文和运行的程序中运行

Flask内建了1个测试客户端用于解决(部份解决)这1问题,测试客户端能复现程序运行在Web服务器中的环境,让测试扮演成客户端从而发送要求

在测试客户端中运行的视图函数和正常情况下的没有太大区分,服务器收到要求,将其分配给适当的视图函数,视图函数生成响应,将其返回给测试客户端,履行视图函数后,生成的响应会传入测试,检查是不是正确

测试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消息

测试Web服务

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()进行编码

------分隔线----------------------------
------分隔线----------------------------

最新技术推荐