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

发表于 5年以前  | 总阅读数:878 次

概要

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

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

让我们来找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 = ['you@example.com']

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

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 = 'john@example.com')
        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 = '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()

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.

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237229次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8063次阅读
 目录