测试Flask应用

Something that is untested is broken.

The origin of this quote is unknown and while it is not entirely correct, it is also not far from the truth. Untested applications make it hard to improve existing code and developers of untested applications tend to become pretty paranoid. If an application has automated tests, you can safely make changes and instantly know if anything breaks.

Flask提供了一种方法用于测试您的应用,那就是将Werkzeug测试Client暴露出来,并且为你处理上下文局部变量。You can then use that with your favourite testing solution. 在这片文档中,我们将会使用Python自带的unittest包。

应用

First, we need an application to test; we will use the application from the Tutorial. 如果你还没有这个应用,请从示例中获取源码。

测试框架

为了测试这个应用,我们添加第二个模块(flaskr_tests.py), 并在其中创建一个单元测试框架:

import os
import flaskr
import unittest
import tempfile

class FlaskrTestCase(unittest.TestCase):

    def setUp(self):
        self.db_fd, flaskr.app.config['DATABASE'] = tempfile.mkstemp()
        flaskr.app.config['TESTING'] = True
        self.app = flaskr.app.test_client()
        with flaskr.app.app_context():
            flaskr.init_db()

    def tearDown(self):
        os.close(self.db_fd)
        os.unlink(flaskr.app.config['DATABASE'])

if __name__ == '__main__':
    unittest.main()

setUp()方法中的代码创建一个新的测试客户端并且初始化一个新的数据库。This function is called before each individual test function is run. 要在测试之后删除这个数据库,我们在tearDown()方法当中关闭这个文件,并将它从文件系统中删除。同时,在初始化的时候TESTING配置标志被激活。它所做的是禁用处理请求时的错误捕捉,这样你在进行对应用发出请求的测试时获得更好的错误报告。

这个测试客户端将会给我们一个通向应用的简单接口。我们可以触发对向应用发送请求的测试,并且此客户端也会帮我们记录 Cookie 的 动态。

因为SQLite3是基于文件系统的,我们可以很容易的使用临时文件模块来创建一个临时的数据库并初始化它。函数mkstemp()实际上完成了两件事情:它返回了一个底层的文件指针以及一个随机的文件名,后者我们用作数据库的名字。我们只需要将db_fd变量保存起来,这样就可以使用os.close()方法来关闭这个文件。

If we now run the test suite, we should see the following output:

$ python flaskr_tests.py

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

虽然现在还未进行任何实际的测试,我们已经可以知道我们的flaskr应用在语法是合法的,否则import将会由于异常而失败。

第一个测试

Now it’s time to start testing the functionality of the application. 让我们检查如果我们访问根路径(/),它将显示“No entries here so far”。To do this, we add a new test method to our class, like this:

class FlaskrTestCase(unittest.TestCase):

    def setUp(self):
        self.db_fd, flaskr.app.config['DATABASE'] = tempfile.mkstemp()
        self.app = flaskr.app.test_client()
        flaskr.init_db()

    def tearDown(self):
        os.close(self.db_fd)
        os.unlink(flaskr.app.config['DATABASE'])

    def test_empty_db(self):
        rv = self.app.get('/')
        assert b'No entries here so far' in rv.data

注意到我们的测试函数以test开头,这允许unittest模块自动识别出哪些方法是一个测试方法,并且运行它。

通过使用self.app.get,我们可以发送一个 HTTP GET请求给应用的某个给定路径。The return value will be a response_class object. 我们现在可以使用data属性来检查程序的返回值(以字符串类型)。In this case, we ensure that 'No entries here so far' is part of the output.

Run it again and you should see one passing test:

$ python flaskr_tests.py
.
----------------------------------------------------------------------
Ran 1 test in 0.034s

OK

登入和登出

我们应用的大部分功能只允许具有管理员资格的用户访问,所以我们需要一种方法来帮助我们的测试客户端登入和登出。为此,我们向登入和登出页面发送一些请求,这些请求都携带了表单数据(用户名和密码)。因为登入和登出页面都会重定向,我们将客户端设置为follow_redirects

Add the following two methods to your FlaskrTestCase class:

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

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

现在我们可以轻松的测试登陆和登出是正常工作还是因认证失败而出错。添加这个新的测试到类中:

def test_login_logout(self):
    rv = self.login('admin', 'default')
    assert b'You were logged in' in rv.data
    rv = self.logout()
    assert b'You were logged out' in rv.data
    rv = self.login('adminx', 'default')
    assert b'Invalid username' in rv.data
    rv = self.login('admin', 'defaultx')
    assert b'Invalid password' in rv.data

测试添加消息

我们同时应该测试消息的添加功能是否正常。添加一个新的测试方法,如下:

def test_messages(self):
    self.login('admin', 'default')
    rv = self.app.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

Here we check that HTML is allowed in the text but not in the title, which is the intended behavior.

Running that should now give us three passing tests:

$ python flaskr_tests.py
...
----------------------------------------------------------------------
Ran 3 tests in 0.332s

OK

关于请求的头信息和状态值等更复杂的测试,请参考MiniTwit Example,在这个例子的源代码里包含一套更长的测试。

其他测试技巧

除了如上文演示的使用测试客户端完成测试的方法,还有一个test_request_context()方法可以配合with语句用于激活一个临时的请求上下文。With this you can access the request, g and session objects like in view functions. Here is a full example that demonstrates this approach:

import flask

app = flask.Flask(__name__)

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

All the other objects that are context bound can be used in the same way.

If you want to test your application with different configurations and there does not seem to be a good way to do that, consider switching to application factories (see Application Factories).

Note however that if you are using a test request context, the before_request() and after_request() functions are not called automatically. 然而,teardown_request()函数在测试请求的上下文离开with块的时候会执行。如果你希望before_request()函数仍然执行,你需要自己调用preprocess_request()方法:

app = flask.Flask(__name__)

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

This can be necessary to open database connections or something similar depending on how your application was designed.

If you want to call the after_request() functions you need to call into process_response() which however requires that you pass it a response object:

app = flask.Flask(__name__)

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

This in general is less useful because at that point you can directly start using the test client.

伪造资源和上下文

New in version 0.10.

一个常见的做法是保存用户认证信息和数据库连接在应用上下文或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

对于测试,这样易于从外部覆盖这个用户,而不用修改代码。This can be accomplished with hooking the flask.appcontext_pushed signal:

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

And then to use it:

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)

保存上下文

New in version 0.4.

Sometimes it is helpful to trigger a regular request but still keep the context around for a little longer so that additional introspection can happen. 在Flask 0.4 中,通过test_client()函数和with块的使用可以实现:

app = flask.Flask(__name__)

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

如果你仅仅使用test_client()方法,而不使用with代码块, assert会失败,因为request不再可访问(因为你试图在非真正请求中时候访问它)。

访问和修改 Sessions

New in version 0.8.

Sometimes it can be very helpful to access or modify the sessions from the test client. Generally there are two ways for this. If you just want to ensure that a session has certain keys set to certain values you can just keep the context around and access flask.session:

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

但是这样做并不能使你修改Session或在请求发出之前访问Session。Starting with Flask 0.8 we provide a so called “session transaction” which simulates the appropriate calls to open a session in the context of the test client and to modify it. 在事务结束时,Session将被保存。它与使用的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 代理。而这个对象本身提供了同样的接口。