Skip to main content

Flask 1.0 进阶 - 测试Flask应用程序

Flask 1.0 进阶 - 测试Flask应用程序

Flask 1.0 进阶 - 测试Flask应用程序

"未经测试的东西被打破了"

这句话的起源是未知的,虽然它并不完全正确,但它也离真相不远。未经测试的应用程序使得现有代码难以改进,未经测试的应用程序的开发人员往往变得非常偏执。如果应用程序具有自动化测试,您可以安全地进行更改,并立即知道是否有任何中断。

Flask提供了一种方法来测试您的应用程序,方法是暴露Werkzeug测试Client并为您处理上下文本地。然后,您可以将其与您喜欢的测试解决方案一起使用。

在本分享中,我们将使用pytest包作为测试的基础框架。您可以使用pip安装它,如下所示:

pip install pytest

应用程序

首先,我们需要一个应用程序来测试;我们将使用教程中的应用程序。如果您还没有该应用程序,请从示例中获取源代码。

测试骨架搭建

我们首先在应用程序根目录下添加一个tests目录(如果有的话可以不用创建)。然后创建一个Python文件来存储我们的测试(test_baby.py)。当我们格式化文件名如test_*.py时,它将被pytest自动发现。

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

#! _*_ 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'])

每个单独的测试将调用此client fixture。它为我们提供了一个简单的应用程序接口,我们可以在其中触发对应用程序的测试请求。client还将为我们跟踪cookie。

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

因为SQLite3是基于文件系统的,所以我们可以轻松地使用tempfile模块来创建临时数据库并对其进行初始化。mkstemp()函数为我们做了两件事:它返回一个低级文件句柄和一个随机文件名,后者我们用作数据库名。我们必须保持db_fd,以便我们可以使用 os.close() 函数来关闭文件。

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

如果我们现在运行测试组件,我们应该看到以下输出:


$ 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 =================================

即使它没有运行任何实际测试,我们已经知道我们的flaskr应用程序在语法上是有效的,否则导入将因异常而结束。

测试数据库

现在是时候开始测试应用程序的功能了。如果我们访问应用程序的根目录(/),让我们检查应用程序是否显示“Baby”。为此,我们向test_baby.py添加了一个新的测试函数,如下所示:


def test_empty_db(client):
    rv = client.get('/')
    assert b'Baby' in rv.data

请注意,我们的测试函数以单词test开头;这允许pytest自动将函数识别为要运行的测试。

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

再次运行它,您应该看到一个通过测试:


$ 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 =====================

测试登录和注销

我们的应用程序的大部分功能仅适用于管理用户,因此我们需要一种方法来将我们的测试客户端记录到应用程序中。为此,我们使用所需的表单数据(用户名和密码)向登录和注销页面发出一些请求。并且由于登录和注销页面重定向,我们告诉客户端follow_redirects

将以下类添加到test_baby.py文件中:

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')

现在,我们可以轻松地测试登录和注销是否正常工作,以及它是否因无效凭据而失败。添加这个新的测试功能:


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

再次运行测试得到如下结果


$ 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 =========================

测试添加帖子

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

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'&lt;strong&gt;Html&lt;/strong&gt; is here' in rv.data

        assert b'&lt;Hello&gt;' in rv.data

在这里,我们检查文本中是否允许HTML,而不是标题中的HTML,这是预期的行为。

运行它现在应该给我们四个通过测试:


$ 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 =================

其他测试技巧

除了使用如上所示的测试client之外,还有test_request_context()方法,该方法可以与with语句结合使用以临时激活请求上下文。通过这种方式,您可以访问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()函数,则需要调用preprocess_request(),但是要求您将响应对象传递给它:

app = flask.Flask(__name__)

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

这通常不太有用,因为此时您可以直接开始使用测试客户端。

伪造资源和上下文

一种非常常见的模式是在应用程序上下文或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)

保持上下文环境

有时触发常规请求保持上下文环境会有所帮助,但仍会将上下文保持一段时间,以便进行额外的内省。使用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(),则断言将失败并显示错误,因为请求不再可用(因为您尝试在实际请求之外使用它)。

访问和修改会话

有时,从测试客户端访问或修改会话非常有用。通常有两种方法。如果您只是想确保某个会话将某些键设置为某些值,您可以保持上下文并访问flask.session

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

但是,这无法在发出请求之前修改会话或访问会话。从Flask 0.8开始,我们提供了一个所谓的“会话事务”,它模拟在测试客户端上下文中打开会话的相应调用并对其进行修改。在事务结束时存储会话。这与所使用的会话后端无关:

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代理。然而,对象本身将提供相同的接口。

测试JSON API

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={
        'username': 'flask', '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'
版权声明

版权声明

张大鹏 创作并维护的 Walkerfree 博客采用 创作共用保留署名-非商业-禁止演绎4.0国际许可证。本文首发于 Walkerfree 博客(http://www.walkerfree.com/),版权所有,侵权必究。本文永久链接:http://www.walkerfree.com/article/164