测试Flask应用

“没有经过测试的东西都是不完整的”**

这句话的出处不详,虽然不完全正确,但也离事实不远。未经测试的应用程序会使提高现有代码变得困难,未经测试的应用程序的开发人员往往变得相当偏执。如果应用程序有自动测试,你就可以安全地进行更改,并立即知道是否有任何错误。

Flask 提供了一种用于测试应用的方法,那就是将Werkzeug 的 Client 暴露出来,并且为你操作这些内容的本地的上下文变量,然后就可以将自己最喜欢的测试解决方案应用于其上了。

在本文档中,我们将使用Python自带的 pytest 包作为测试的基本框架。你可以用 ``pip``安装它 ,像这样:

$ pip install pytest

应用

首先,我们需要一个用来测试应用程序;我们将使用来自 tutorial`的应用 . 如果您还没有这个应用程序,可以下载 :gh:`the examples <examples/tutorial> .

测试框架

我们首先在应用程序根目录下添加一个测试目录。然后创建一个python文件来存储我们的测试( test_flaskr.py )当我们将文件名格式化为 test_*.py ,它会被pytest自动发现。

接下来,我们创建一个 pytest fixture called client() 配置应用程序以进行测试并初始化新数据库:

import os
import tempfile

import pytest

from flaskr import flaskr


@pytest.fixture
def client():
    db_fd, flaskr.app.config['DATABASE'] = tempfile.mkstemp()
    flaskr.app.config['TESTING'] = True
    client = flaskr.app.test_client()

    with flaskr.app.app_context():
        flaskr.init_db()

    yield client

    os.close(db_fd)
    os.unlink(flaskr.app.config['DATABASE'])

这个客户端固件会被每个独立的测试调用。它提供了一个简单的应用接口,用于向应用发送请求,还可以为我们追踪 cookie 。

在设置过程中, TESTING 配置标志是已激活的。这样做的目的是在请求处理期间禁用错误捕获,以便在对应用程序执行测试请求时获得更好的错误报告。

因为sqlite3是基于文件系统的,所以我们可以轻松地使用`tempfile` 模块来创建临时数据库并初始化它。函数`~tempfile.mkstemp` 实际上完成了两件事:它返回一个低级文件指针和一个随机文件名,后者是我们用作数据库名称的文件名。我们只需要保留 db_fd,就可以使用 os.close() 函数关闭文件。

要在测试后删除数据库,fixture将关闭文件并将其从文件系统中删除。

如果现在运行这套测试,我们将看到以下输出:

$ pytest

================ test session starts ================
rootdir: ./flask/examples/flaskr, inifile: setup.cfg
collected 0 items

=========== no tests ran in 0.07 seconds ============

尽管它没有运行任何实际测试,但我们已经可以知道 flaskr 应用程序在语法上是有效的,否则在导入时会引发异常并中断运行。

第一次测试

现在是时候开始测试应用程序的功能了。当我们访问应用的根 URL ( / )时应该显示 “ No entries here so far ”。在 test_flaskr.py 文件中新增一个测试函数来测试这个功能:

def test_empty_db(client):
    """Start with a blank database."""

    rv = client.get('/')
    assert b'No entries here so far' in rv.data

注意,我们的测试函数都是以单词开头 test。这样 pytest 就会自动识别这些是用于测试的函数并运行它们。

通过使用 client.get 我们可以发送一个HTTP GET 请求给应用的某个给定路径。返回值将是`response_class` 对象。我们现在可以使用“data` 属性来检查应用程序的返回值(字符串)。在这种情况下,我们检查输出是否包含 'No entries here so far'

再次运行它,会看到一个通过的测试:

$ pytest -v

================ test session starts ================
rootdir: ./flask/examples/flaskr, inifile: setup.cfg
collected 1 items

tests/test_flaskr.py::test_empty_db PASSED

============= 1 passed in 0.10 seconds ==============

登录和注销

我们应用程序的大部分功能只对管理用户可用,因此我们需要一种方法来帮助我们的测试客户端登录和注销。为此,我们使用所需的表单数据(用户名和密码)向登录和注销页面发出一些请求。因为登录和注销页面都会重定向,我们将客户端设置 follow_redirects

将以下两个函数添加到你的 test_flaskr.py 文件中::

def login(client, username, password):
    return client.post('/login', data=dict(
        username=username,
        password=password
    ), follow_redirects=True)


def logout(client):
    return client.get('/logout', follow_redirects=True)

现在,我们可以很容易地测试登录和注销是否有效,以及它是否因无效验证而失败。添加此新测试函数:

def test_login_logout(client):
    """Make sure login and logout works."""

    rv = login(client, flaskr.app.config['USERNAME'], flaskr.app.config['PASSWORD'])
    assert b'You were logged in' in rv.data

    rv = logout(client)
    assert b'You were logged out' in rv.data

    rv = login(client, flaskr.app.config['USERNAME'] + 'x', flaskr.app.config['PASSWORD'])
    assert b'Invalid username' in rv.data

    rv = login(client, flaskr.app.config['USERNAME'], flaskr.app.config['PASSWORD'] + 'x')
    assert b'Invalid password' in rv.data

测试消息的添加

我们还应该测试添加消息是否有效。添加一个新的测试函数,如下所示:

def test_messages(client):
    """Test that messages work."""

    login(client, flaskr.app.config['USERNAME'], flaskr.app.config['PASSWORD'])
    rv = client.post('/add', data=dict(
        title='<Hello>',
        text='<strong>HTML</strong> allowed here'
    ), follow_redirects=True)
    assert b'No entries here so far' not in rv.data
    assert b'&lt;Hello&gt;' in rv.data
    assert b'<strong>HTML</strong> allowed here' in rv.data

这里我们测试计划的行为是否能够正常工作,即在正文中可以出现HTML标签,而在标题中不允许。

运行它,现在应该会给我们三次通过的测试:

$ pytest -v

================ test session starts ================
rootdir: ./flask/examples/flaskr, inifile: setup.cfg
collected 3 items

tests/test_flaskr.py::test_empty_db PASSED
tests/test_flaskr.py::test_login_logout PASSED
tests/test_flaskr.py::test_messages PASSED

============= 3 passed in 0.23 seconds ==============

其他测试技巧

除了使用上述测试客户端外,还可以联合 with 语句使用 test_request_context() 方法来临时激活一个请求环境。通过这个,您可以访问 requestgsession 对象,如视图函数。下面是一个完整的例子,演示了这种方法:

import flask

app = flask.Flask(__name__)

with app.test_request_context('/?name=Peter'):
    assert flask.request.path == '/'
    assert flask.request.args['name'] == 'Peter'

所有其他和上下文绑定的对象都可以以相同的方式访问。

如果你想用不同的配置测试你的应用程序,但似乎没有一个好的方法来进行测试,请考虑使用应用的工厂函数(请参见 应用工厂

但是请注意,尽管你使用的是测试用的请求环境,函数.before_request和after_request函数都不会自动运行的。但是当调试请求环境离开 with 块时会执行 teardown_request() 函数。如果需要 before_request() 函数和正常情况下一样被调用,那么需要自己调用 preprocess_request()

app = flask.Flask(__name__)

with app.test_request_context('/?name=Peter'):
    app.preprocess_request()
    ...

这对于打开数据库连接或类似的操作是必要的,这取决于你的应用程序的设计方式。

如果想调用 after_request() 函数,那么必须调用 process_response(),并把响应对象传递给它:

app = flask.Flask(__name__)

with app.test_request_context('/?name=Peter'):
    resp = Response('...')
    resp = app.process_response(resp)
    ...

这个例子中的情况基本没有用处,因为此时你可以直接开始使用测试客户端。

伪造资源和环境

0.10 新版功能.

Changelog

一种非常常见的模式是将用户授权信息和数据库连接存储在应用程序上下文或`g` 对象中。一般模式是在第一次使用对象时,把对象放在应用上下文或flask.g.上面,而在请求销毁时移除对象。试想一下例如下面的获取当前用户的代码:

def get_user():
    user = getattr(g, 'user', None)
    if user is None:
        user = fetch_current_user_from_database()
        g.user = user
    return user

对于测试来说,最好从外部重写这个用户,而不必更改一些代码。这可以通过连接 flask.appcontext_pushed 信号完成这个任务:

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

然后使用它:

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)

保存环境

0.4 新版功能.

Changelog

有时候这种情形是有用的:触发一个常规请求,但是保持环境以便于做一点额外的事 情。在 Flask 0.4 之后可以在 with 语句中使用 test_client() 来实现:

app = flask.Flask(__name__)

with app.test_client() as c:
    rv = c.get('/?tequila=42')
    assert request.args['tequila'] == '42'

如果你在没有 with 的情况下使用 test_client() ,那么 assert 会出错失败。因为无法在请求之外访问 request 。

访问和修改会话

0.8 新版功能.

Changelog

有时在测试客户机访问或修改会话会非常有帮助。通常有两种方法。如果你仅仅希望确保一个Session拥有某个特定的键,那么只需保留上下文并访问 flask.session ::

with app.test_client() as c:
    rv = c.get('/')
    assert flask.session['foo'] == 42

但是这个方法无法修改会话或在请求发出前访问会话。从flask 0.8开始,我们提供了一个所谓的“Session事务”的东西用于模拟适当的调用,从而在测试客户机上下文中打开会话和修改会话。在事务结束时,会话被存储。这与使用的会话后端无关:

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

请注意,在这种情况下,你必须使用 sess 对象来代替 flask.session 代理。但是,Sess对象本身将提供相同的接口。

测试JSON API

1.0 新版功能.

Changelog

Flask非常支持JSON,是构建JSON API的流行选择。使用JSON数据进行请求和在响应中检查JSON数据非常方便:

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={
        'email': 'flask@example.com', 'password': 'secret'
    })
    json_data = rv.get_json()
    assert verify_token(email, json_data['token'])

在测试客户端方法中传递 json 参数,设置请求数据为 JSON 序列化对象,并设 置内容类型为 application/json 。可以使用 get_json 从请求或者响应中 获取 JSON 数据。

测试CLI命令

Click 来自于 测试工具 ,可用于测试 CLI 命令。一个 CliRunner 独立运行命令并通过 Result 对象捕获输出。

Flask 提供 test_cli_runner() 来创建一个 FlaskCliRunner,以自动传递 Flask 应用给 CLI 。用 它的 invoke() 方法调用命令,与在命令行中调用一样:

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

在上面的示例中,按名称调用命令非常有用,因为它会验证该命令是否已正确注册到应用程序中。

如果要测试命令在不运行命令的情况下解析参数,请使用.make_context` 方法。这对于测试复杂的验证规则和自定义类型很有用。:

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'