“未经测试的东西被打破了”
这句话的起源是未知的,虽然它并不完全正确,但它也离真相不远。未经测试的应用程序使得现有代码难以改进,未经测试的应用程序的开发人员往往变得非常偏执。如果应用程序具有自动化测试,您可以安全地进行更改,并立即知道是否有任何中断。
Flask提供了一种方法来测试您的应用程序,方法是暴露Werkzeug测试Client 并为您处理上下文本地。然后,您可以将其与您喜欢的测试解决方案一起使用。
在本分享中,我们将使用pytest 包作为测试的基础框架。您可以使用pip安装它,如下所示:
应用程序 首先,我们需要一个应用程序来测试;我们将使用教程 中的应用程序。如果您还没有该应用程序,请从示例中 获取源代码。
测试骨架搭建 我们首先在应用程序根目录下添加一个tests目录(如果有的话可以不用创建)。然后创建一个Python文件来存储我们的测试(test_baby.py
)。当我们格式化文件名如test_*.py
时,它将被pytest自动发现。
接下来,我们创建一个名为 client() 的pytest fixture ,它配置应用程序以进行测试并初始化一个新的数据库,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#! _*_ coding: utf-8 _*_
#
#
import os
import tempfile
import pytest
from baby import create_app
from baby.db import init_db , get_db
with open ( os . path . join ( os . path . dirname ( __file__ ), 'data.sql' ), 'rb' ) as f :
_data_sql = f . read () . decode ( 'utf8' )
@pytest.fixture
def client ():
db_fd , db_path = tempfile . mkstemp ()
app = create_app ({
'DATABASE' : db_path ,
'TESTING' : True
})
client = app . test_client ()
with app . app_context ():
init_db ()
get_db () . executescript ( _data_sql )
yield client
os . close ( db_fd )
os . unlink ( app . config [ 'DATABASE' ])
Copy 每个单独的测试将调用此client fixture。它为我们提供了一个简单的应用程序接口,我们可以在其中触发对应用程序的测试请求。client还将为我们跟踪cookie。
在设置过程中,TESTING配置标志被激活。这样做是在请求处理期间禁用错误捕获,以便在对应用程序执行测试请求时获得更好的错误报告。
因为SQLite3是基于文件系统的,所以我们可以轻松地使用tempfile 模块来创建临时数据库并对其进行初始化。mkstemp() 函数为我们做了两件事:它返回一个低级文件句柄和一个随机文件名,后者我们用作数据库名。我们必须保持db_fd,以便我们可以使用 os.close() 函数来关闭文件。
要在测试后删除数据库,fixture会关闭文件并将其从文件系统中删除。
如果我们现在运行测试组件,我们应该看到以下输出:
1
2
3
4
5
6
7
8
$ pytest tests/test_baby.py
======================================== test session starts ======================================
platform darwin -- Python 3.6.5, pytest-4.4.0, py-1.8.0, pluggy-0.9.0
rootdir: /Users/durban/python/baby, inifile: setup.cfg
collected 0 items
==================================== no tests ran in 0.05 seconds =================================
Copy 即使它没有运行任何实际测试,我们已经知道我们的flaskr应用程序在语法上是有效的,否则导入将因异常而结束。
测试数据库 现在是时候开始测试应用程序的功能了。如果我们访问应用程序的根目录(/),让我们检查应用程序是否显示“Baby”。为此,我们向test_baby.py添加了一个新的测试函数,如下所示:
1
2
3
4
def test_empty_db ( client ):
rv = client . get ( '/' )
assert b 'Baby' in rv . data
Copy 请注意,我们的测试函数以单词test开头;这允许pytest自动将函数识别为要运行的测试。
通过使用client.get
,我们可以使用给定路径向应用程序发送HTTP GET
请求。返回值将是response_class 对象。我们现在可以使用data 属性来检查应用程序的返回值(作为字符串)。在这种情况下,我们确保“No entries here so far”是输出的一部分。
再次运行它,您应该看到一个通过测试:
1
2
3
4
5
6
7
8
9
10
11
$ pytest -v tests/test_baby.py
======================= test session starts ========================
platform darwin -- Python 3.6.5, pytest-4.4.0, py-1.8.0, pluggy-0.9.0 -- /Users/durban/python/baby/.env3/bin/python3
cachedir: .pytest_cache
rootdir: /Users/durban/python/baby, inifile: setup.cfg
collected 1 item
tests/test_baby.py::test_empty_db PASSED [ 100%]
===================== 1 passed in 0.10 seconds =====================
Copy 测试登录和注销 我们的应用程序的大部分功能仅适用于管理用户,因此我们需要一种方法来将我们的测试客户端记录到应用程序中。为此,我们使用所需的表单数据(用户名和密码)向登录和注销页面发出一些请求。并且由于登录和注销页面重定向,我们告诉客户端follow_redirects
将以下类添加到test_baby.py文件中:
1
2
3
4
5
6
7
8
9
10
11
12
13
class AuthActions ( object ):
def __init__ ( self , client ):
self . _client = client
def login ( self , username = 'test' , password = 'test' ):
return self . _client . post (
'/auth/login' ,
data = { 'username' : username , 'password' : password }
)
def logout ( self ):
return self . _client . get ( '/auth/logout' )
Copy 现在,我们可以轻松地测试登录和注销是否正常工作,以及它是否因无效凭据而失败。添加这个新的测试功能:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def test_login ( client , auth ):
auth . login ( 'test' , 'test' )
with client :
client . get ( '/' )
assert session [ 'user_id' ] == 1
rv = auth . login ( 'testx' , 'test' )
print ( rv . data )
assert b 'Incorrect username' in rv . data
rv = auth . login ( 'test' , 'testx' )
print ( rv . data )
assert b 'Incorrect password' in rv . data
def test_logout ( client , auth ):
auth . login ( 'test' , 'test' )
with client :
rv = auth . logout ()
assert 'user_id' not in session
Copy 再次运行测试得到如下结果
1
2
3
4
5
6
7
8
9
10
11
12
13
$ pytest -v tests/test_baby.py
=========================== test session starts ============================
platform darwin -- Python 3.6.5, pytest-4.4.0, py-1.8.0, pluggy-0.9.0 -- /Users/durban/python/baby/.env3/bin/python3
cachedir: .pytest_cache
rootdir: /Users/durban/python/baby, inifile: setup.cfg
collected 3 items
tests/test_baby.py::test_empty_db PASSED [ 33%]
tests/test_baby.py::test_login PASSED [ 66%]
tests/test_baby.py::test_logout PASSED [ 100%]
========================= 3 passed in 0.71 seconds =========================
Copy 测试添加帖子 我们还应该测试添加帖子是否有效。添加一个新的测试函数,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
def test_add_post ( client , auth ):
auth . login ()
with client :
rv = client . post ( '/create' , data = dict (
title = '<Hello>' ,
body = '<strong>Html</strong> is here'
), follow_redirects = True )
assert b '<strong>Html</strong> is here' in rv . data
assert b '<Hello>' in rv . data
Copy 在这里,我们检查文本中是否允许HTML,而不是标题中的HTML,这是预期的行为。
运行它现在应该给我们四个通过测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ pytest -v tests/test_baby.py
=================== test session starts ====================
platform darwin -- Python 3.6.5, pytest-4.4.0, py-1.8.0, pluggy-0.9.0 -- /Users/durban/python/baby/.env3/bin/python3
cachedir: .pytest_cache
rootdir: /Users/durban/python/baby, inifile: setup.cfg
collected 4 items
tests/test_baby.py::test_empty_db PASSED [ 25%]
tests/test_baby.py::test_login PASSED [ 50%]
tests/test_baby.py::test_logout PASSED [ 75%]
tests/test_baby.py::test_add_post PASSED [ 100%]
================= 4 passed in 0.71 seconds =================
Copy 其他测试技巧 除了使用如上所示的测试client之外,还有test_request_context() 方法,该方法可以与with
语句结合使用以临时激活请求上下文。通过这种方式,您可以访问request ,g 和session 对象,例如视图函数。以下是演示此方法的完整示例:
1
2
3
4
5
6
7
8
import flask
app = flask . Flask ( __name__ )
with app . test_request_context ( '/?name=Peter' ):
assert flask . request . path == '/'
assert flask . request . args [ 'name' ] == 'Peter'
Copy 可以以相同的方式使用上下文绑定的所有其他对象。
如果要使用不同的配置测试应用程序并且似乎没有好的方法,请考虑切换到应用程序工厂(请参阅应用程序工厂 )。
但请注意,如果使用测试请求上下文,则不会自动调用before_request() 和after_request() 函数。但是,当测试请求上下文离开with
块时,确实执行了teardown_request() 函数。如果你确实想要调用before_request() 函数,你需要自己调用preprocess_request() :
1
2
3
4
5
6
app = flask . Flask ( __name__ )
with app . test_request_context ( '/?name=Peter' ):
app . preprocess_request ()
...
Copy 根据应用程序的设计方式,这可能是打开数据库连接或类似连接所必需的。
如果要调用after_request() 函数,则需要调用preprocess_request() ,但是要求您将响应对象传递给它:
1
2
3
4
5
6
7
app = flask . Flask ( __name__ )
with app . test_request_context ( '/?name=Peter' ):
resp = Response ( '...' )
resp = app . process_response ( resp )
...
Copy 这通常不太有用,因为此时您可以直接开始使用测试客户端。
伪造资源和上下文 一种非常常见的模式是在应用程序上下文或flask.g对象上存储用户授权信息和数据库连接。一般的模式是在第一次使用时将对象放在那里,然后在拆卸时将其移除。想象一下这个代码来获取当前用户:
1
2
3
4
5
6
7
def get_user ():
user = getattr ( g , 'user' , None )
if user is None :
user = fetch_current_user_from_database ()
g . user = user
return user
Copy 对于测试,从外部覆盖此用户而不必更改某些代码将是很好的。这可以通过挂钩flask.appcontext_pushed 信号来完成:
1
2
3
4
5
6
7
8
9
10
from contextlib import contextmanager
from flask import appcontext_pushed , g
@contextmanager
def user_set ( app , user ):
def handler ( sender , ** kwargs ):
g . user = user
with appcontext_pushed . connected_to ( handler , app ):
yield
Copy 然后想下面这样使用它
1
2
3
4
5
6
7
8
9
10
11
12
from flask import json , jsonify
@app.route ( '/users/me' )
def users_me ():
return jsonify ( username = g . user . username )
with user_set ( app , my_user ):
with app . test_client () as c :
resp = c . get ( '/users/me' )
data = json . loads ( resp . data )
self . assert_equal ( data [ 'username' ], my_user . username )
Copy 保持上下文环境 有时触发常规请求保持上下文环境会有所帮助,但仍会将上下文保持一段时间,以便进行额外的内省。使用Flask 0.4,可以使用带有with
块的test_client() :
1
2
3
4
5
6
app = flask . Flask ( __name__ )
with app . test_client () as c :
rv = c . get ( '/?tequila=42' )
assert request . args [ 'tequila' ] == '42'
Copy 如果您只使用不带with块的test_client() ,则断言将失败并显示错误,因为请求不再可用(因为您尝试在实际请求之外使用它)。
访问和修改会话 有时,从测试客户端访问或修改会话非常有用。通常有两种方法。如果您只是想确保某个会话将某些键设置为某些值,您可以保持上下文并访问flask.session :
1
2
3
4
with app . test_client () as c :
rv = c . get ( '/' )
assert flask . session [ 'foo' ] == 42
Copy 但是,这无法在发出请求之前修改会话或访问会话。从Flask 0.8开始,我们提供了一个所谓的“会话事务”,它模拟在测试客户端上下文中打开会话的相应调用并对其进行修改。在事务结束时存储会话。这与所使用的会话后端无关:
1
2
3
4
5
6
with app . test_client () as c :
with c . session_transaction () as sess :
sess [ 'a_key' ] = 'a value'
# once this is reached the session was stored
Copy 请注意,在这种情况下,您必须使用sess对象而不是flask.session代理。然而,对象本身将提供相同的接口。
测试JSON API Flask非常支持JSON,是构建JSON API的流行选择。使用JSON数据发出请求并检查响应中的JSON数据非常方便:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from flask import request , jsonify
@app.route ( '/api/auth' )
def auth ():
json_data = request . get_json ()
email = json_data [ 'email' ]
password = json_data [ 'password' ]
return jsonify ( token = generate_token ( email , password ))
with app . test_client () as c :
rv = c . post ( '/api/auth' , json = {
'username' : 'flask' , 'password' : 'secret'
})
json_data = rv . get_json ()
assert verify_token ( email , json_data [ 'token' ])
Copy 在测试客户端方法中传递json
参数将请求数据设置为JSON序列化对象,并将内容类型设置为application/json
。您可以使用get_json
从请求或响应中获取JSON数据。
测试CLI命令 Click附带了用于测试CLI命令的实用程序。CliRunner独立运行命令并捕获Result 对象中的输出。
Flask提供了test_cli_runner() 来创建FlaskCliRunner ,它自动将Flask应用程序传递给CLI。使用其invoke() 方法以与从命令行调用命令相同的方式调用命令。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import click
@app.cli.command ( 'hello' )
@click.option ( '--name' , default = 'World' )
def hello_command ( name )
click . echo ( f 'Hello, { name } !' )
def test_hello ():
runner = app . test_cli_runner ()
# invoke the command directly
result = runner . invoke ( hello_command , [ '--name' , 'Flask' ])
assert 'Hello, Flask' in result . output
# or by name
result = runner . invoke ( args = [ 'hello' ])
assert 'World' in result . output
Copy 在上面的示例中,按名称调用命令很有用,因为它验证命令是否已正确注册到应用程序。
如果要测试命令如何解析参数而不运行命令,请使用其make_context()方法。这对于测试复杂的验证规则和自定义类型很有用。
1
2
3
4
5
6
7
8
9
10
11
12
13
def upper ( ctx , param , value ):
if value is not None :
return value . upper ()
@app.cli.command ( 'hello' )
@click.option ( '--name' , default = 'World' , callback = upper )
def hello_command ( name )
click . echo ( f 'Hello, { name } !' )
def test_hello_params ():
context = hello_command . make_context ( 'hello' , [ '--name' , 'flask' ])
assert context . params [ 'name' ] == 'FLASK'
Copy