在Python的Flask框架中实现单元测试的教程

 概要

在前面的章节里我们专注于在我们的小应用程序上一步步的添加功能上。到现在为止我们有了一个带有数据库的应用程序,可以注册用户,记录用户登陆退出日志以及查看修改配置文件。

在本节中,我们不为应用程序添加任何新功能,相反,我们要寻找一种方法来增加我们已写代码的稳定性,我们还将创建一个测试框架来帮助我们防止将来程序中出现的失败和回滚。

让我们来找bug

在上一章的结尾谈到,我故意在应用程序中引入一个bug。接下来让我描述一下它是什么样的bug,然后看看当我们的程序不按照我们意愿执行的时候,它在其中又起了什么样的影响。

应用程序的问题在于,没有保证用户昵称的唯一性。用户昵称是由应用程序自动初始化的。我们首先会考虑使用OpenID provider给出的用户的昵称,然后再考虑使用Email信息中的用户名部分作为用户的昵称。但如果出现重复的昵称,则后面的用户将无法注册成功。更糟糕的是,在修改用户配置的表单中,我们允许用户任意更改他们的昵称,但我们仍然没有对昵称冲突进行检查。

当我们分析完错误产生时应用程序的行为之后,我们将会定位这些问题。

Flask 的调试功能

那么让我们看看当bug被触发时,会出现什么现象。

让我们从创建一个崭新的数据库,在linux下,执行:
 

rm app.db
./db_create.py

在Windows下,执行:
 

del app.db
flask/Scripts/python db_create.py
我们需要两个OpenID的账号来重现这个bug。当然这两个账号最理想的状态是来自来个不同的拥有者,那样可以避免他们的cookie把情况搞的更复杂。通过如下步骤创建冲突的昵称:
  •     用第一个账号登陆
  •     进入用户信息属性编辑页面,将昵称改为“dup”
  •     登出系统
  •     用第二个账号登陆
  •     修改第二个账号的用户信息属性,将昵称改为“dup”


哎哟!sqlalchemy中抛出了一个异常,来看一下错误信息:

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

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

这个错误信息相当明确,我们试图在数据插入一个重复的昵称,数据库的昵称字段是一个卫衣键,因此这样的操作是无效的。
 

除了实际的错误,在我们手头上还有一个次要的错误。如果一个用户不注意在我们应用程序里引起了一个错误(这一个错误或者任何其他原因引起的异常),应用程序将向他/她暴漏错误信息和堆栈信息,而不是暴露给我们。对于我们开发者来说这是个很好的特性,但是很多时候我们不想让用户看到这些信息。

这么长时间以来,我们一直在debug模式下运行我们的应用程序,我们通过设置debug=True的参数来启用应用程序的debug模式。这里我们在运行脚本run.py里配置。

当我们这样开发应用是方便的,但是我们需要在生产环境上关闭debug模式。 让我们创建另一个启动脚本文件设置关闭dubug模式(filerunp.py): 
 

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

现在重新启动应用:

./runp.py

并且现在再尝试重命名第二个账号nickname成‘dup'

这次我们没有获取到一个错误信息,取而代之,我们得到了一个HTTP 500错误码,这是个内部服务器错误。虽然这不容易定位错误,但至少没有暴露我们应用程序的任何细节给陌生人。当调试关闭后出现一个异常时,Flask会产生一个500页面。

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

定制HTTP错误处理程序

Flask为应用程序提供了一个机制来安装他们自己的错误页面,作为例子,让我们定义两个最常见的HTTP 404和500错误的自定义页面。定制其他错误页面也是同样的方式。

使用一个修饰来声明一个定制的错误处理程序 (fileapp/views.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处理中的rollack语句,这个地方是不可缺少的因为这个方法会被当做一个异常调用。如果因为数据库错误导致一个异常,那么数据库的会话将变成一个无效状态,因此我们需要回滚它,以防止一个会话转向一个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发送错误日志

为了处理第二个问题我们需要配置应用的错误报告机制。

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

首先,我们需要在我们的应用配置邮件服务器和管理员列表 (fileconfig.py):
 

# mail server settings
MAIL_SERVER = 'localhost'
MAIL_PORT = 25
MAIL_USERNAME = None
MAIL_PASSWORD = None
 
# administrator list
ADMINS = ['[email protected]']

当然,你要把上面的配置改成你自己的才有意义
 

Flask 使用通用的Python logging模块, 所以设置发送错误日志邮件非常简单. (fileapp/__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

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

打印日志到文件

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

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

开启文件日志和邮件日志很相似(fileapp/__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服务时是件非常困难的事,把日志信息写入到文件中,将是我们诊断和解决问题的一个有用工具,所以现在让我们准备好使用这个功能吧。

bug修复

让我们来修复下昵称重复的bug.

前面讨论过,有两个地方目前还没有处理重复。首先是Flask-Login的after_login处理,这个方法将在用户成功登陆到系统后调用,我们需要创建一个新的User实例。这是受影响的一个代码片段,我们做了修复 (fileapp/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, role = ROLE_USER)
    db.session.add(user)
    db.session.commit()

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

class User(db.Model):
  # ...
  @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”・・・。注意我们把这个方法设定为静态方法,因为这个操作不适用于任何类的实例。

第二个导致重复昵称的地方是编辑页面视图函数,这算是用户选择昵称的一个小恶作剧,正确的方式是不允许用户输入重复名称,让用户更换为另一个名称。我们通过添加form表单验证来解决这个问题,如果用户输入一个无效的昵称,将会得到一个字段验证失败信息,添加我们的验证只需重写form的validate方法 (fileapp/forms.py):

class EditForm(Form):
  nickname = TextField('nickname', validators = [Required()])
  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 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['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 = '[email protected]')
    avatar = u.avatar(128)
    expected = 'http://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6'
    assert avatar[0:len(expected)] == expected
 
  def test_make_unique_nickname(self):
    u = User(nickname = 'john', email = '[email protected]')
    db.session.add(u)
    db.session.commit()
    nickname = User.make_unique_nickname('john')
    assert nickname != 'john'
    u = User(nickname = nickname, email = '[email protected]')
    db.session.add(u)
    db.session.commit()
    nickname2 = User.make_unique_nickname('john')
    assert nickname2 != 'john'
    assert nickname2 != nickname
 
if __name__ == '__main__':
  unittest.main()

unittest测试组件的讨论超出了本文的范围了,我们这里只需知道TestCase是我们的测试类。setUp和tearDown方法有些特殊,它们分别在每个测试方法前后执行,复杂点的设置可以包含几组测试,每个代表一个单元测试,TestCase的子类和每个组都将拥有独立的setUp和tearDown方法。


这些特殊的setUp和tearDown方法都是非常通用的,在setUp可以方便的修改配置,例如,我们想测试不同的数据库作为主数据库,在tearDown里面只需简单设置下数据库内容就可以。

测试作为方法被实现,一个测试应该运行一些已知结果的应用程序方法,也应当能够断言出结果和预期的不同。


到目前为止,在我们的测试框架里有两个测试。第一个验证来自于上一篇文章的Gravatar avatar URLs生成的是否正确,注意预期的avatar被硬编码在测试中,和User类中返回的对象作比较。

第二个测试验证是test_make_unique_nickname方法,同样也是在User类中。这个测试有点详细,它创建了一个新的用户并且写入数据库中,同时确定名字的唯一性。接下来创建第二个用户,建议使用唯一名称,你可以尝试下使用第一个用户名称。在第二部分测试预期结果是建议使用与之前不同的名称。


运行这个测试套件你只需运行tests.py脚本:
 

./tests.py

如果出现错误信息,你将会在控制台得到一个报告。
结语

今天关于调试,错误和测试的讨论到此为止,我希望这篇文章能对你有用。

老规矩,如果你有任何评论请写在下面.

微博应用程序的代码今天修改的更新,你可以在这里下载:


下载 microblog-0.7.zip.

相关推荐