flask_单元测试

时间:2021-06-05 04:09:00

我们现在可以试着在控制台向数据库添加一个用户:

In[2]: import model;
In[3]: from microblog import db;
In[4]: u=model.User(nickname="wll",email="wll@mail.com");
In[5]: db.session.add(u);
In[6]: db.session.commit();

接下来我们运行程序,我们将使用以前的用户登录,登录之后将他的用户名也改为wll,看程序运行后会出现什么错误:

flask_单元测试    flask_单元测试

这是我们会发现浏览器页面上只是报服务器内部错误。我们没办法具体知道程序哪里出现了错误。这是我们在开发过程中不希望看到的。这时我们可以试着让我们的应用程序以调试模式运行。调试模式是在应用程序运行的时候通过在 run 方法中传入参数 debug = True

flask_单元测试

现在我们继续运行程序:(将用户名改为与前面添加的用户相同的名字)

我们可以看到不会再报服务器内部错误,而是显示具体的错误信息:

flask_单元测试

从这条信息我们很明显可以看到是违反了唯一约束。

但是我们不希望我们的用户能够看的到程序内部错误,我们只有把debug设置为False。但是现在又有两个问题出现了:第一个是外观上的:默认的 500 错误页很丑陋。第二个小问题相当重要。我们可能不会知道什么时候用户会在我们的程序中会遇到一个失败因为现在调试被禁用。幸好有两种简单的方式解决这两个问题。

一、定制HTTP错误处理器:

Flask 为应用程序提供了一种安装自己的错误页的机制。作为例子,让我们自定义 HTTP 404 以及 500 错误页,这是最常见的两个。定义其它错误的方式是一样的。为了声明一个定制的错误处理器,需要使用装饰器 errorhandler (文件microblog.py):

@app.errorhandler(404)
def internal_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错误模板:(templates/404.html)

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

500错误模板:(templates/500.html)

{% 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 %}

二、通过电子邮件发送错误

为了解决我们第二个问题,我们将会配置两种应用程序错误报告机制。第一个就是当错误发生的时候发送电子邮件。

在开始之前我们先在应用程序中配置邮件服务器以及管理员邮箱地址(文件 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 模块,因此当发生异常的时候发送邮件是十分简单(microblog.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)

在一个没有邮件服务器的开发机器上测试上述代码是相当容易的,多亏了 Python 的 SMTP 调试服务器。仅需要打开一个新的命令行窗口(Windows 用户打开命令提示符)接着运行如下内容打开一个伪造的邮箱服务器:

flask_单元测试

我们将程序中的调试模式关闭(debug=False),我们将会在命令提示符中看到具体的错误:(如下图所示:)

flask_单元测试

三、记录到文件

通过邮件接收错误是不错的,但是有时候这并不够。有些失败并不是结束于异常而且也不是主要问题,然而我们可能想要在日志中追踪它们以便做一些调试。

出于这个原因,我们还要为应用程序保持一个日志文件。

启用日志记录类似于电子邮件发送错误(文件microblog.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 以至于生成的日志的大小是有限制的。在这个例子中,我们的日志文件的大小限制在 1 兆,我们将保留最后 10 个日志文件作为备份。

logging.Formatter 类能够定制化日志信息的格式。由于这些信息记录到一个文件中,我们希望它们提供尽可能多的信息,所以我们写一个时间戳,日志记录级别和消息起源于以及日志消息和堆栈跟踪的文件和行号。

为了使得日志更有作用,我们降低了应用程序日志以及文件日志处理器的级别,这样给我们机会写入有用的信息到日志并不是必须错误发生的时候。从这以后,每次你以非调试模式启动有用程序,日志将会记录事件。

虽然我们不会在这个时候有很多记录器的需求,调试的一个处于联机状态并在使用中的网页服务器是非常困难的。消息记录到一个文件,是一个非常有用的工具,在诊断和定位问题,所以我们现在都准备好,我们需要使用此功能。

下面我们来测试下,我们还是编辑相同的错误,将用户名改为数据库已存在用户的名字,我们可以看到tmp文件夹下多了一个microblog.log的文件,文件内容如下:

2016-11-01 11:06:27,548 INFO: microblog startup [in C:\Users\wls003\PycharmProjects\microblog_study\microblog.py:30]
2016-11-01 11:06:27,595 INFO: microblog startup [in microblog.py:30]
2016-11-01 11:06:51,341 ERROR: Exception on /edit [POST] [in C:\Users\wls003\Anaconda2\lib\site-packages\flask\app.py:1423]
Traceback (most recent call last):
File "C:\Users\wls003\Anaconda2\lib\site-packages\flask\app.py", line 1817, in wsgi_app
response = self.full_dispatch_request()
File "C:\Users\wls003\Anaconda2\lib\site-packages\flask\app.py", line 1477, in full_dispatch_request
rv = self.handle_user_exception(e)
File "C:\Users\wls003\Anaconda2\lib\site-packages\flask\app.py", line 1381, in handle_user_exception
reraise(exc_type, exc_value, tb)
File "C:\Users\wls003\Anaconda2\lib\site-packages\flask\app.py", line 1475, in full_dispatch_request
rv = self.dispatch_request()
File "C:\Users\wls003\Anaconda2\lib\site-packages\flask\app.py", line 1461, in dispatch_request
return self.view_functions[rule.endpoint](**req.view_args)
File "C:\Users\wls003\Anaconda2\lib\site-packages\flask_login.py", line 758, in decorated_view
return func(*args, **kwargs)
File "microblog.py", line 122, in edit
db.session.commit()
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\orm\scoping.py", line 157, in do
return getattr(self.registry(), name)(*args, **kwargs)
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\orm\session.py", line 801, in commit
self.transaction.commit()
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\orm\session.py", line 392, in commit
self._prepare_impl()
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\orm\session.py", line 372, in _prepare_impl
self.session.flush()
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\orm\session.py", line 2019, in flush
self._flush(objects)
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\orm\session.py", line 2137, in _flush
transaction.rollback(_capture_exception=True)
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\util\langhelpers.py", line 60, in __exit__
compat.reraise(exc_type, exc_value, exc_tb)
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\orm\session.py", line 2101, in _flush
flush_context.execute()
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\orm\unitofwork.py", line 373, in execute
rec.execute(self)
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\orm\unitofwork.py", line 532, in execute
uow
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\orm\persistence.py", line 170, in save_obj
mapper, table, update)
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\orm\persistence.py", line 706, in _emit_update_statements
execute(statement, multiparams)
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\engine\base.py", line 914, in execute
return meth(self, multiparams, params)
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\sql\elements.py", line 323, in _execute_on_connection
return connection._execute_clauseelement(self, multiparams, params)
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\engine\base.py", line 1010, in _execute_clauseelement
compiled_sql, distilled_params
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\engine\base.py", line 1146, in _execute_context
context)
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\engine\base.py", line 1341, in _handle_dbapi_exception
exc_info
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\util\compat.py", line 200, in raise_from_cause
reraise(type(exception), exception, tb=exc_tb, cause=cause)
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\engine\base.py", line 1139, in _execute_context
context)
File "C:\Users\wls003\Anaconda2\lib\site-packages\sqlalchemy\engine\default.py", line 450, in do_execute
cursor.execute(statement, parameters)
IntegrityError: (sqlite3.IntegrityError) UNIQUE constraint failed: user.nickname [SQL: u'UPDATE user SET nickname=?, about_me=? WHERE user.id = ?'] [parameters: (u'wll', u'hello,my name is ninicwang!!!', 1)]

四、修复bug

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

像之前讨论的,目前存在两个地方没有处理重复。第一个就是在 after_login 函数。当一个用户成功地登录进系统这个函数就会被调用,这里我们需要创建一个新的 User 实例。这里就是受影响的代码块(文件microblog.py):

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

解决问题的方式就是让 User 类为我们选择一个唯一的名字。这就是新的 make_unique_nickname 方法所做的(文件model.py):

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

这种方法简单地增加一个计数器为请求的昵称,直到找到一个唯一的名称。例如,如果用户名 “miguel”已经存在,这个方法将会建议使用 “miguel2”,如果这个还是存在,将会建议使用 “miguel3”,依次下去直至找到唯一的用户名。需要注意的是我们把这个方法作为一个静态方法,因为这种操作并不适用于任何特定的类的实例。

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

class EditForm(Form):
nickname = StringField('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 =model.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_nicknamevalidate 方法使用它来决定昵称什么时候更改过。如果没有发生更改就接受它。如果已经发生更改的话,确保昵称在数据库是唯一的。

在视图中传入这个参数:

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

为了完成这个修改,我们必须在表单模板中使得字段错误信息会显示(文件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>

现在我们来测试下结果:

flask_单元测试

五、单元测试框架

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

传统的方式–回归测试是一个很好的主意。你编写测试检验应用程序所有不同的功能。每一个测试集中在一个关注点上验证结果是不是期望的。定期执行测试确保应用程序按预期的工作。当测试覆盖很大的时候,通过运行测试你就有自信确保修改点和新增点不会影响应用程序。

我们使用 Python 的 unittest 模块将会构建一个简单的测试框架(文件 tests.py):

 #!flask/bin/python
import os
import unittest
from config import basedir
from microblog import app,db
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_make_unique_nickname(self):
from model import User
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()

TestCase 类中含有我们的测试。setUp 和 tearDown 方法是特别的,它们分别在测试之前以及测试之后运行。(在 setUp 中做了一些配置,在 tearDown 中重置数据库内容。)

运行:python test.py