Flask学习之七 单元测试

英文博客地址:http://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-vii-unit-testing

中文翻译地址:http://www.pythondoc.com/flask-mega-tutorial/testing.html

开源中国社区:http://www.oschina.net/translate/the-flask-mega-tutorial-part-vii-unit-testing

 

一、之前的bug

我们有一个数据库功能的应用程序,它能够注册用户,允许用户登录以及登出,查看以及编辑他们的用户信息。

Bug 就是没有有效地让我们用户的昵称保持唯一性。应用程序自动选择用户的初始昵称。如果 OpenID 提供商提供一个用户的昵称的话我们会使用这个昵称。如果没有提供话,应用程序会选择邮箱的用户名部分作为昵称。如果两个用户有着同样的昵称的话,第二个用户是不能够被注册的。更加糟糕的是,在编辑用户信息的时候,我们允许用户修改昵称,但是没有去限制昵称名称冲突。

 

二、Flask调试

注:要调试这部分的BUG需要两个OpenID帐号

先让我们创建一个新的数据库。在 Linux:

rm app.db
./db_create.py

我们需要两个OpenID的账号来重现这个bug。当然这两个账号最理想的状态是来自来个不同的拥有者,那样可以避免他们的cookie把情况搞的更复杂。通过如下步骤创建冲突的昵称:

  • 用第一个账号登陆
  • 进入用户信息属性编辑页面,将昵称改为“dup”
  • 登出系统
  • 用第二个账号登陆
  • 修改第二个账号的用户信息属性,将昵称改为“dup”

 sqlalchemy中抛出了一个异常,错误信息:

sqlalchemy.exc.IntegrityError
IntegrityError: (IntegrityError) column nickname is not unique u'UPDATE user SET nickname=?, about_me=? WHERE user.id = ?' (u'dup', u'', 2)

 错误的后面是这个错误的堆栈信息,事实上,这是一个相当不错的错误提示,你可以转向任何框架检查代码或者在浏览器里执行正确的表达式。

错误是相当地明显的,我们试着在数据库中插入重复的昵称。数据库模型对 nickname 字段有着 unique 限制,因此这不是一个合法的操作。

除了上面的错误,我们还有另外一个错误。如果一个用户不小心在应用程序里引起了一个错误(这一个错误或者任何其他原因引起的异常),应用程序将向他/她显示错误信息和堆栈信息,而不是返回给我们。对于我们开发者来说这是个很好的特性,但是很多时候我们不想让用户看到这些信息。

这段时间内我们的应用程序以调试模式运行着。调试模式是在应用程序运行的时候通过在 run 方法中传入参数 debug = True

当我们在开发的应用程序的时候这个功能很方便,但是我们必须在生产环境上确保这个功能被禁用。让我们创建另外一个调试模式禁用的启动脚本(文件 runp.py):

#!flask/bin/python
from app import app
app.run(debug=False)

现在重新启动应用,并且现在再尝试重命名第二个账号nickname成‘dup’,现在会得到一个 HTTP 错误 500,这是服务器内部错误。当调试关闭后出现一个未处理异常时,Flask会产生一个500页面。

虽然这样好些了,但现在仍存在两个问题。首先美化问题:默认的500页面很丑陋。第二个问题更重要些,当用户操作失败时,我们无法获取到错误信息了,因为错误在后台默默的处理了。幸运的是有个简单方式来处理这两个问题。

 

三、定制 HTTP 错误处理器

Flask 为应用程序提供了一种安装自己的错误页的机制。作为例子,让我们自定义 HTTP 404 以及 500 错误页,这是最常见的两个。定义其它错误的方式是一样的。

为了声明一个定制的错误处理器,需要使用装饰器 errorhandler (文件 app/views.py):

@app.errorhandler(404)
def not_found_error(error):
    return render_template('404.html'), 404

@app.errorhandler(500)
def internal_error(error):
    db.session.rollback()
    return render_template('500.html'), 500

注意一下错误 500 处理器中的 rollback 声明,这是很有必要的因为这个函数是被作为异常的结果被调用。如果异常是被一个数据库错误触发,数据库的会话会处于一个不正常的状态,因此我们必须把会话回滚到正常工作状态在渲染 500 错误页模板之前。

404 错误的模板:

<!-- extend base layout -->
{% extends "base.html" %}

{% block content %}
  <h1>File Not Found</h1>
  <p><a href="{{ url_for('index') }}">Back</a></p>
{% endblock %}

500错误模板:

<!-- extend base layout -->
{% extends "base.html" %}

{% block content %}
  <h1>An unexpected error has occurred</h1>
  <p>The administrator has been notified. Sorry for the inconvenience!</p>
  <p><a href="{{ url_for('index') }}">Back</a></p>
{% endblock %}

上面两个模板中我们继续使用我们 base.html 布局,这是为了让错误页面和应用程序的外观是统一的。

 

四、通过email发送错误日志

为了处理第二个问题(当用户操作失败时,无法获取到错误信息)我们需要配置应用的错误报告机制。

第一个是每当有错误发生时把错误日志通过邮件发送给我们。

在开始之前我们先在应用程序中配置邮件服务器以及管理员邮箱地址(文件 config.py):

# mail server settings
MAIL_SERVER = 'localhost'
MAIL_PORT = 25
MAIL_USERNAME = None
MAIL_PASSWORD = None

# administrator list
ADMINS = ['you@example.com']

Flask 使用 Python logging 模块,因此当发生异常的时候发送邮件是十分简单(文件 app/__init__.py):

from config import basedir, ADMINS, MAIL_SERVER, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD

if not app.debug:
    import logging
    from logging.handlers import SMTPHandler
    credentials = None
    if MAIL_USERNAME or MAIL_PASSWORD:
        credentials = (MAIL_USERNAME, MAIL_PASSWORD)
    mail_handler = SMTPHandler((MAIL_SERVER, MAIL_PORT), 'no-reply@' + MAIL_SERVER, ADMINS, 'microblog failure', credentials)
    mail_handler.setLevel(logging.ERROR)
    app.logger.addHandler(mail_handler)

Note that we are only enabling the emails when we run without debugging.

注意,我们要的非dubug模式下开启邮件功能.

在没有邮件服务器的pc上测试邮件功能也很容易,幸好Python有SMTP的测试排错的服务器(SMTP debugging server)。打开一个控制台窗口,并且运行下面的命令:

python -m smtpd -n -c DebuggingServer localhost:25

 当程序运行的时候,应用接收和发送邮件会在控制台窗口中显示出来。

PS. 我到这部分的时候不知到为什么收发邮件这里没有成功。

 

五、打印日志到文件

通过邮件接收错误日志非常不错,但是,这是不够的。有些导致失败的条件不会触发异常并且不是主要的问题,所以我们需要将日志保存到log文件中,在某些情况下,需要日志来进行排错。 

出于这个原因,我们的应用需要一个日志文件。

启用日志记录类似于电子邮件发送错误(文件 app/__init__.py):

if not app.debug:
    import logging
    from logging.handlers import RotatingFileHandler
    file_handler = RotatingFileHandler('tmp/microblog.log', 'a', 1 * 1024 * 1024, 10)
    file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'))
    app.logger.setLevel(logging.INFO)
    file_handler.setLevel(logging.INFO)
    app.logger.addHandler(file_handler)
    app.logger.info('microblog startup')

日志文件将会在 tmp 目录,名称为 microblog.log。我们使用的RotatingFileHandler方法中有个限制日志数量的参数。在这种情况下,我们限制了一个日志文件大小为1M,并且把最后的十个文件作为备份。

logging.Formatter类提供了日志信息的定义格式,由于这些信息将写到一个文件中,我们想获取到尽可能多的信息,因此除了日志信息和堆栈信息,我们还写了个时间戳,日志级别和文件名、信息的行号。

为了使日志更有用,我们降低了应用程序日志和文件日志处理程序的日志级别,因为这样我们将有机会在没有错误情况下把有用信息写入到日志中。作为一个例子,我们启动时将日志的级别设置为信息级别。从现在开始,每次你启动应用程序将记录你的调试信息。

当我们没有使用日志时,调试一个在线和使用中的web服务时是件非常困难的事,把日志信息写入到文件中,将是我们诊断和解决问题的一个有用工具。

当你运行runp.py然后打开一个页面:

 

六、修复 bug

让我们解决 nickname 重复的问题。

有两个地方目前还没有处理重复。

首先是Flask-Login的after_login处理,这个方法将在用户成功登陆到系统后调用,我们需要创建一个新的User实例。

这里是修复后的受影响的代码块(文件 app/views.py):

    if user is None:
        nickname = resp.nickname
        if nickname is None or nickname == "":
            nickname = resp.email.split('@')[0]
        nickname = User.make_unique_nickname(nickname)
        user = User(nickname = nickname, email = resp.email)
        db.session.add(user)
        db.session.commit()

我们解决这个问题的方法是让User类选择一个唯一的名字给我们,这就是新的 make_unique_nickname 方法所做的(文件 app/models.py):

    class User(db.Model):
    # ...
    @staticmethod
    def make_unique_nickname(nickname):
        if User.query.filter_by(nickname=nickname).first() is None:
            return nickname
        version = 2
        while True:
            new_nickname = nickname + str(version)
            if User.query.filter_by(nickname=new_nickname).first() is None:
                break
            version += 1
        return new_nickname
    # ...

这个方法简单的添加一个计数器来生成一个唯一的昵称名。例如,如果用户名“miguel”存在,这个方法将建议你使用“miguel2”,但是如果它也存在就会生成“miguel3”···。注意我们把这个方法设定为静态方法,因为这个操作不适用于任何类的实例。

第二个存在重复昵称问题的地方就是编辑用户信息的视图函数。这个稍微有些难处理,因为这是用户自己选择的昵称。正确的做法就是不接受一个重复的昵称,让用户重新输入一个。我们通过添加form表单验证来解决这个问题,如果用户输入一个无效的昵称,将会得到一个字段验证失败信息,用户将会返回到编辑用户信息页。为了添加验证,我们只需覆盖表单的 validate 方法(文件 app/forms.py):

from app.models import User

class EditForm(Form):
    nickname = TextField('nickname', validators=[DataRequired()])
    about_me = TextAreaField('about_me', validators=[Length(min=0, max=140)])

    def __init__(self, original_nickname, *args, **kwargs):
        Form.__init__(self, *args, **kwargs)
        self.original_nickname = original_nickname

    def validate(self):
        if not Form.validate(self):
            return False
        if self.nickname.data == self.original_nickname:
            return True
        user = User.query.filter_by(nickname=self.nickname.data).first()
        if user != None:
            self.nickname.errors.append('This nickname is already in use. Please choose another one.')
            return False
        return True

表单的构造函数增加了一个新的参数original_nickname,验证方法validate使用这个参数来判断昵称是否修改了,如果没有修改就直接返回它,如果已经修改了,方法会确认下新的昵称在数据库是否已经存在。

接下来我们在视图函数中添加新的构造器参数:

@app.route('/edit', methods=['GET', 'POST'])
@login_required
def edit():
    form = EditForm(g.user.nickname)
    # ...

我们还必须在表单模板中使得字段错误信息会显示(文件 app/templates/edit.html):

        <td>Your nickname:</td>
        <td>
            {{ form.nickname(size=24) }}
            {% for error in form.errors.nickname %}
            <br><span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </td>

现在这个bug已经修复了,阻止了重复数据的出现···除非这些验证方法不能正常工作了。在两个或者多个线程/进程并行存取数据库时,这仍然存在一个潜在的问题,但这些都是以后我们文章讨论的主题。

在这里你可以尝试选择一个重复的名称来看看表单如何处理这些错误的。

 

七、单元测试框架

随着应用程序的规模变得越大就越难保证代码的修改不会影响到现有的功能。

传统的方法防止回归是一个很好的方式,你通过编写单元测试来测试应用程序所有不同功能,每一个测试集中于一个点来验证结果是否和预期的一致。测试程序通过 定期的执行来确认应用程序是否在正常工作。当测试覆盖率变大时,你就可以自信的修改和添加新功能,只需通过测试程序来验证下是否影响到了应用程序现有功 能。

 

现在我们使用python的unittest测试组件来创建个简单的测试框架 (tests.py):

#!flask/bin/python
import os
import unittest

from config import basedir
from app import app, db
from app.models import User

class TestCase(unittest.TestCase):
    def setUp(self):
        app.config['TESTING'] = True
        app.config['WTF_CSRF_ENABLED'] = False
        app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'test.db')
        self.app = app.test_client()
        db.create_all()

    def tearDown(self):
        db.session.remove()
        db.drop_all()

    def test_avatar(self):
        u = User(nickname='john', email='john@example.com')
        avatar = u.avatar(128)
        expected = 'http://gravatar.duoshuo.com/avatar/d4c74594d841139328695756648b6bd6'
        assert avatar[0:len(expected)] == expected

    def test_make_unique_nickname(self):
        u = User(nickname='john', email='john@example.com')
        db.session.add(u)
        db.session.commit()
        nickname = User.make_unique_nickname('john')
        assert nickname != 'john'
        u = User(nickname=nickname, email='susan@example.com')
        db.session.add(u)
        db.session.commit()
        nickname2 = User.make_unique_nickname('john')
        assert nickname2 != 'john'
        assert nickname2 != nickname

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

 

 

 

 

posted @ 2015-02-04 10:17  AminHuang  Views(418)  Comments(0Edit  收藏  举报