第15章:测试-Flask Web 开发(第2版)

编写单元测试有两个很好的理由。在实现新功能时,使用单元测试来确认新代码是否按预期方式工作。通过手动测试可以获得相同的结果,但显然自动化测试可以节省时间和精力,因为它们可以轻松实现复用。

第二个更重要的原因是,每次修改应用程序时,都可以执行所有基于它构建的单元测试,以确保现有代码中不存在退档; 换句话说,新的更改不会影响旧代码的工作方式。

单元测试从开始就一直是 Flasky 项目的一部分,测试旨在执行在数据库模型类中实现的应用程序的特定功能。这些类很容易在正在运行的应用程序的上下文之外进行测试,因此,考虑到它不需要花费多少精力,对数据库模型中存在的所有功能执行单元测试是确保应用程序的该部分功能健壮性的最佳方法,请保持这种方式。

本章讨论如何改进和扩展单元测试到应用程序的其他领域。

获取代码覆盖率报告

拥有一套测试套件非常重要,但知道它有多好或多坏同样重要。代码覆盖工具可以计算单元测试执行多少应用程序,并可以提供详细报告,指出应用程序代码的哪些部分未被测试。这些信息非常重要,因为它可以用来指导将最新测试写入最需要的领域。

Python 有一个很好的代码覆盖工具,被称为 coverage。可以通过 pip 进行安装:

(venv) $ pip install coverage

这个工具可以作为一个命令行脚本,启动任何启用了代码覆盖的 Python 应用程序,同时,它还提供了很多实用脚本,以便通过编程地方式来启动 coverage 引擎。为了将覆盖率指标更好地集成到第7章中添加的 Flask 测试命令中,可以添加 --coverage 选项。

# 示例 15-1. flasky.py: 覆盖率指标
import os
import sys
import click

COV = None
if os.environ.get('FLASK_COVERAGE'):
    import coverage
    COV = coverage.coverage(branch=True, include='app/*')
    COV.start()

# ...

@app.cli.command()
@click.option('--coverage/--no-coverage', default=False,
              help='Run tests under code coverage.')
def test(coverage):
    """Run the unit tests."""
    if coverage and not os.environ.get('FLASK_COVERAGE'):
        os.environ['FLASK_COVERAGE'] = '1'
        os.execvp(sys.executable, [sys.executable] + sys.argv)
    import unittest
    tests = unittest.TestLoader().discover('tests')
    unittest.TextTestRunner(verbosity=2).run(tests)
    if COV:
        COV.stop()
        COV.save()
        print('Coverage Summary:')
        COV.report()
        basedir = os.path.abspath(os.path.dirname(__file__))
        covdir = os.path.join(basedir, 'tmp/coverage')
        COV.html_report(directory=covdir)
        print('HTML version: file://%s/index.html' % covdir)
        COV.erase()

通过给 flask test 命令传入 --coverage 选项,即可开启代码覆盖率支持。为了给自定义的 test 命令加上布尔选项,使用了 click.option 装饰器。Click 最终会将该选项,以布尔值参数传给函数。

但是 flasky.py 整合的代码覆盖率有一个小问题。在 test() 函数中收到 --coverage 选项时,启用覆盖率指标已经太迟了; 到那时,全局范围内的所有代码都已经执行完毕。因此,为了获得准确的指标,在设置 FLASK_COVERAGE 环境变量后,脚本会递归地重新启动。在第二次运行中,脚本的顶部会发现环境变量已设置,即使在所有应用程序导入之前,也从一开始就打开 coverage

coverage.coverage() 函数用于启动 coverage 引擎。branch = True 选项启用分支覆盖分析,除了跟踪执行哪行代码外,还会检查每个条件是否都针对 TrueFalse 不同情况分别进行了执行。include 选项用于将覆盖率分析限制为应用程序包内部的文件为唯一需要测量的代码。如果没有 inclue 选项,虚拟环境中安装的所有扩展和测试代码本身都将包含在覆盖率报告中,这会给报告增加很多干扰。

在所有测试执行完毕后,test() 函数将报告写入控制台,并将更美观的 HTML 报告写入磁盘。HTML 版本显示所有用颜色标注的源代码,这些颜色表明测试覆盖的行和不包含的行。

如果你从 Github 克隆了示例代码,可以使用 git checkout 15a 签出本示例。签出后请执行 pip install -r requirements/dev.txt

Flask 测试客户端

应用程序代码的某些部分在很大程度上依赖于正在运行的应用程序创建的环境。例如,你不能简单地调用视图函数中的代码来测试它,因为函数可能需要访问 Flask 上下文变量(如 requestsession),还可能需要从 POST 请求中获取表单数据,并且可能还需要用户已经登录。简而言之,视图函数只能在一个请求上下文和正在运行的应用程序上下文中运行。

Flask 配备了一个测试客户端来尝试解决这个问题,至少在某种程度上。测试客户端复制应用程序在 Web 服务器内运行时存在的环境,允许测试充当客户端并发送请求。

在测试客户端下执行时,视图函数看不到任何重大差异; 接收到请求并将其路由到相应的视图函数,从中生成响应并将其返回。视图函数执行后,它的响应被传递给测试,测试可以检查响应的正确性。

测试 Web 应用

例15-2显示了一个使用测试客户端的单元测试框架。

# 示例 15-2. tests/test_client.py: 使用 Flask 测试客户端的单元测试框架
import unittest
from app import create_app, db
from app.models import User, Role

class FlaskClientTestCase(unittest.TestCase):
    def setUp(self):
        self.app = create_app('testing')
        self.app_context = self.app.app_context()
        self.app_context.push()
        db.create_all()
        Role.insert_roles()
        self.client = self.app.test_client(use_cookies=True)

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

    def test_home_page(self):
        response = self.client.get('/')
        self.assertEqual(response.status_code, 200)
        self.assertTrue('陌生人' in response.get_data(as_text=True))

和之前的 tests/test_basic.py 相比,这个模块增加了一个 self.client 实例变量,它是 Flask 测试客户端对象。这个对象提供了向应用程序发出请求的方法。当这个测试客户端通过 user_cookies 选项创建后,它将可以像浏览器那样接收并发送 cookie,所以那些依赖 cookie 调用请求和应用程序上下文的功能也可以使用。特别是,这种方法可以使用存储在 cookie 中的用户会话(Session)。

test_home_page() 测试,简单的演示了测试客户端可以用来做什么。在这个例子中,向应用程序请求了首页。测试客户端的 get() 方法的返回值是调用视图函数后,返回的一个 Flask 响应对象。为了检查测试是否成功,对响应的状态码进行了检测,然后使用 response.get_data() 来获取到响应主体,并在其中搜索“陌生人”,它是首页中,对匿名用户 “你好,陌生人!” 进行问候的消息内容的一部分。注意,get_data() 默认以字节数组返回响应主体,通过传入 as_text=True 来将其转成字符串。

测试客户端还能通过 post() 方法来发起 POST 请求,但是提交表单有点复杂。在第4章讨论过,所有由 Flask-WTF 生成的表单都有一个包含 CSRF 令牌的隐藏表单域,在提交表单时,需要将这个令牌同时进行提交。这个测试需要在提交这个令牌之前,先请求一次表单页面,从中解析出这个令牌,然后再将这个令牌和其它数据一起进行提交。为了避免在测试中处理 CSRF 令牌的麻烦,最好在测试配置中禁用 CSRF 保护。如示例15-3所示。

#Example 15-3. config.py: 在测试配置中禁用 CSRF 保护
class TestingConfig(Config):
    #...
    WTF_CSRF_ENABLED = False

示例 15-4 展示了一个更高级的单元测试,它模拟了一个新用户的注册、登录、通过确认令牌来确认账号最后注销登录的过程。

# 示例 15-4. tests/test_client.py: 使用 Flask 测试客户模板一个新用户的流程
class FlaskClientTestCase(unittest.TestCase):
    # ...
    def test_register_and_login(self):
        # 注册一个新账号
        response = self.client.post('/auth/register', data={
            'email': '[email protected]',
            'username': 'john',
            'password': 'cat',
            'password2': 'cat'
        })
        self.assertEqual(response.status_code, 302)

        # 使用这个新账号进行登录
        response = self.client.post('/auth/login', data={
            'email': '[email protected]',
            'password': 'cat'
        }, follow_redirects=True)
        self.assertEqual(response.status_code, 200)
        self.assertTrue(re.search('Hello,\s+john!',
                                  response.get_data(as_text=True)))
        self.assertTrue(
            'You have not confirmed your account yet' in response.get_data(
                as_text=True))

        # 发送确认令牌
        user = User.query.filter_by(email='[email protected]').first()
        token = user.generate_confirmation_token()
        response = self.client.get('/auth/confirm/{}'.format(token),
                                   follow_redirects=True)
        user.confirm(token)
        self.assertEqual(response.status_code, 200)
        self.assertTrue(
            'You have confirmed your account' in response.get_data(
                as_text=True))

        # 注销登录
        response = self.client.get('/auth/logout', follow_redirects=True)
        self.assertEqual(response.status_code, 200)
        self.assertTrue('You have been logged out' in response.get_data(
            as_text=True))

如果你从 Github 克隆了示例代码,可以使用 git checkout 15b 签出本示例。

测试 Web Service

Flask 测试客户端也可以用来测试 RESTful Web Service。示例 15-5 显示了有两个测试的单元测试类。

# 示例 15-5. tests/test_api.py: 使用 Flask 测试客户端对 RESTful API 进行测试
class APITestCase(unittest.TestCase):
    # ...
    def get_api_headers(self, username, password):
        return {
            'Authorization':
                'Basic ' + b64encode(
                    (username + ':' + password).encode('utf-8')).decode('utf-8'),
            'Accept': 'application/json',
            'Content-Type': 'application/json'
        }
    def test_no_auth(self):
        response = self.client.get(url_for('api.get_posts'),
                                   content_type='application/json')
        self.assertEqual(response.status_code, 401)

    def test_posts(self):
        # 添加用户
        r = Role.query.filter_by(name='User').first()
        self.assertIsNotNone(r)
        u = User(email='[email protected]', password='cat', confirmed=True,
                 role=r)
        db.session.add(u)
        db.session.commit()

       # 发表博文
        response = self.client.post(
            '/api/v1/posts/',
            headers=self.get_api_headers('[email protected]', 'cat'),
            data=json.dumps({'body': 'body of the *blog* post'}))
        self.assertEqual(response.status_code, 201)
        url = response.headers.get('Location')
        self.assertIsNotNone(url)

        # 获取新博文
        response = self.client.get(
            url,
            headers=self.get_api_headers('[email protected]', 'cat'))
        self.assertEqual(response.status_code, 200)
        json_response = json.loads(response.get_data(as_text=True))
        self.assertEqual('http://localhost' + json_response['url'], url)
        self.assertEqual(json_response['body'], 'body of the *blog* post')
        self.assertEqual(json_response['body_html'],
                        '<p>body of the <em>blog</em> post</p>')

API 测试不需要启用 cookie 支持。get_api_headers() 是一个辅助方法,用来返回通用的 HTTP 首部信息,它将身份认证凭据和 MIME 类型都包含进去了。这些是 API 请求需要的信息。

如果你从 Github 克隆了示例代码,可以使用 git checkout 15c 签出本示例。

使用 Selenium 进行端到端测试

Flask 测试客户端无法对一个运行中的应用程序环境进行完整的模拟。例如,依于浏览器的 JavaScript 代码在测试客户端就无法运行,因为包含在响应中的 JavaScript 代码将被(当作字符串或字节数组)直接返回给测试而不会作为代码来执行。当测试需要完整的环境时,除了使用浏览器连接到真实的服务器之外别无他法。幸运的是,大多数 Web 浏览器都可以实现自动化操作。Selenium 是一款 Web 浏览器自动化工具,支持三大操作系统(Mac OS、Linux 和 Windows)中的主流 Web 浏览器(Chrome、Firefox 等)。

Selenium 的 Python 接口可以通过 pip 安装:

(venv) $ pip install selenium

除了浏览器本身之外,Selenium 还需要安装与该浏览器对应的驱动。所有主流浏览器都有驱动程序,因此应用程序可以建立一个复杂的框架来使用多个浏览器分别进行测试。在本应用中,只使用 Google Chrome 浏览器来进行自动化测试。如果你使用 macOS 电脑,可以使用 brew 来安装 Chrome 浏览器的驱动:

(venv) $ brew install chromedriver

Linux 和 Windows 没有 brew,可以直接在 ChromeDrive 的官网下载其安装程序进行安装。

使用 Selenium 进行测试,需要应用程序处于运行状态,以便处理 HTTP 请求。本节将展示当测试在主线程上运行时,在后台线程中使用开发服务器启动应用程序的方法。在测试的控制下,Selenium 启动 Web 浏览器并使其连接到应用程序以执行所需的操作。

这种方法的一个问题是,在所有测试完成之后,需要停止 Flask 服务器,理想情况下,需要使用更优雅的方式,以便后台任务(如代码覆盖引擎)可以完成清理工作。Werkzeug Web 服务器有一个关闭选项,但由于服务器在自己的线程中被隔离运行,让服务器关闭的唯一方法是发送一个常规的 HTTP 请求。

示例15-6展示了一个关闭服务器的路由的实现

#Example 15-6. app/main/views.py: 关闭服务器的路由
@main.route('/shutdown')
def server_shutdown():
    if not current_app.testing:
        abort(404)
    shutdown = request.environ.get('werkzeug.server.shutdown')
    if not shutdown:
        abort(500)
    shutdown()
    return 'Shutting down...'

示例15-7显示了配置为使用 Selenium 运行测试的测试用例的设计。

# 示例 15-7. tests/test_selenium.py: 使用 Selenium 测试的框架
from selenium import webdriver

class SeleniumTestCase(unittest.TestCase):
    client = None

    @classmethod
    def setUpClass(cls):
        # 启动 Chrome
        options = webdriver.ChromeOptions()
        options.add_argument('headless')
        try:
            cls.client = webdriver.Chrome(chrome_options=options)
        except:
            pass

        # 如果浏览器无法启动,路过测试
        if cls.client:
            # 创建应用程序对象
            cls.app = create_app('testing')
            cls.app_context = cls.app.app_context()
            cls.app_context.push()

            # 抑制日志记录以保持单元测试整洁输出
            import logging
            logger = logging.getLogger('werkzeug')
            logger.setLevel("ERROR")

            # 创建数据库,并生成一些模拟数据
            db.create_all()
            Role.insert_roles()
            fake.users(10)
            fake.posts(10)

            # 添加一个管理员
            admin_role = Role.query.filter_by(permissions=0xff).first()
            admin = User(email='[email protected]',
                         username='john', password='cat',
                         role=admin_role, confirmed=True)
            db.session.add(admin)
            db.session.commit()

            # 在一个线程中启动 Flask 服务
            cls.server_thread = threading.Thread(
                target=cls.app.run, kwargs={'debug': 'false',
                                            'use_reloader': False,
                                            'use_debugger': False})
            cls.server_thread.start()

    @classmethod
    def tearDownClass(cls):
        if cls.client:
            # 停止 Flask 服务和浏览器
            cls.client.get('http://localhost:5000/shutdown')
            cls.client.quit()
            cls.server_thread.join()

            # 销毁数据库
            db.drop_all()
            db.session.remove()

            # 移除应用程序上下文
            cls.app_context.pop()

    def setUp(self):
        if not self.client:
            self.skipTest('没有Web浏览器')

    def tearDown(self):
        pass

之前介绍过,我们可以在应用程序的主入口脚本中,通过调用 app.run() 来启动 Flask 的开发服务器,或者通过 Flask-Script 等第三方扩展来实现。现在,基于 Click 实现的 flask run 命令已经取代了 app.run() 的方式,但 app.run() 方法依然被支持,这个例子中,展示了如何在复杂的测试里使用该方法。

setUp() 方法在每个测试运行前,通过 Selenium 来启动浏览器。如果浏览器无法启动,则跳过测试。示例 15-8 展示了一个构建在 Selenium 上的测试。

# 示例 15-8. tests/test_selenium.py: Selenium 单元测试范例
class SeleniumTestCase(unittest.TestCase):
    # ...

    def test_admin_home_page(self):
        # 导航到首页
        self.client.get('http://localhost:5000/')
        self.assertTrue(re.search('Hello,\s+Stranger!',
                                  self.client.page_source))

        # 导航到登录页面
        self.client.find_element_by_link_text('Log In').click()
        self.assertIn('<h1>Login</h1>', self.client.page_source)

        # 登录
        self.client.find_element_by_name('email').\
            send_keys('[email protected]')
        self.client.find_element_by_name('password').send_keys('cat')
        self.client.find_element_by_name('submit').click()
        self.assertTrue(re.search('Hello,\s+john!', self.client.page_source))

        # 导航到用户资料页面
        self.client.find_element_by_link_text('Profile').click()
        self.assertIn('<h1>john</h1>', self.client.page_source)

【wrdll 注】《Python Web开发:测试驱动方法》一书更深入地介绍了 Selenium 进行自动化测试的知识。同时,也是学习 TDD(测试驱动开发) 和 Django 的好教程。另外,Selenium 在爬虫领域应用的也非常广泛。

示例 15-8 中的 test_admin_home_page 单元测试将启动一个无界面的 Chrome 实例,并在其中执行所有操作。如果你想在一个常规的 Chrome 中看到这些操作,只需把 setUpClass() 方法里的 options.add_argument('headless') 行注释掉,这样 Selenium 将创建一个常规的 Chrome 窗口。

如果你从 Github 克隆了示例代码,可以使用 git checkout 15d 签出本示例。签出后,请运行 pip install -r requirements/dev.txt

这值得么?

现在,您可能会问自己,使用 Flask 测试客户端或 Selenium 进行测试值得吗?毕竟它们是这样的麻烦。这是一个好问题,它的答案却没那么简单。

无论你喜不喜欢,你的应用程序都会被测试。如果你自己不进行测试,那么你的用户将被迫成为测试者; 他们会发现错误,然后你必须顶着压力解决它们。简单而有针对性的测试,如运行数据库模型和应用程序的其他部分,可以在应用程序的上下文之外执行。它们以低成本来确保核心组件的正常运行,所以应该始终进行这些测试。

Flask 测试客户端和 Selenium 等端到端测试是可选的。鉴于它们的复杂性,可以只对那些无法孤立测试的功能来进行。应该对应用程序的代码进行设计,以便可以将业务逻辑推送到独立于应用程序上下文的应用程序模块中,从而可以更轻松地进行测试。存在于视图函数中的代码应该很简单,只是作为一个 HTTP 请求的接受者,然后在封装了应用程序逻辑的其他类或函数中调用相应的动作。

所以是的,测试是绝对值得的。 但设计高效的测试策略并编写可充分利用它的代码非常重要。

本文并非《Flask Web Development(2nd edition)》的全文翻译。而只是在阅读该书时,对自认为重点的内容进行记录以及思考。由于水平有限,本文所述内容难免出现不足或错误。请通过购买正版图书来进行更系统地学习,原书地址。本站将依照本系列文章录制视频教程,届时本文未作记录的部分以及项目实际开发中所需要的知识都会在视频教程中进行补充。

flask Flask Web Development(2nd edition) 2018-03-29 16:41 1509370