测试覆盖率

为你的应用程序编写单元测试可以让你检查你编写的代码是否按预期工作。 Flask 提供了一个测试客户端,可以模拟对应用程序的请求并返回响应数据。

你应该尽可能多地测试你的代码。 函数中的代码只有在函数被调用时才会运行,分支中的代码(例如 if 代码块)只有在条件满足时才会运行。 你需要确保每个函数都使用覆盖每个分支的数据进行测试。

你越接近 100% 的覆盖率,你就越能放心地进行更改而不会意外地改变其他行为。 然而,100% 的覆盖率并不能保证你的应用程序没有错误。 特别是,它不测试用户如何在浏览器中与应用程序交互。 尽管如此,测试覆盖率是在开发过程中使用的重要工具。

注意

这在教程中介绍得比较晚,但在你未来的项目中,你应该在开发时进行测试。

你将使用 pytestcoverage 来测试和衡量你的代码。 安装它们两个

$ pip install pytest coverage

设置和 Fixture

测试代码位于 tests 目录中。 此目录 flaskr相邻,而不是在其中。 tests/conftest.py 文件包含名为 fixtures 的设置函数,每个测试都将使用这些函数。 测试位于以 test_ 开头的 Python 模块中,并且这些模块中的每个测试函数也以 test_ 开头。

每个测试都将创建一个新的临时数据库文件,并填充一些将在测试中使用的数据。 编写一个 SQL 文件来插入该数据。

tests/data.sql
INSERT INTO user (username, password)
VALUES
  ('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'),
  ('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79');

INSERT INTO post (title, body, author_id, created)
VALUES
  ('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00');

app fixture 将调用工厂并传递 test_config 以配置应用程序和数据库以进行测试,而不是使用你的本地开发配置。

tests/conftest.py
import os
import tempfile

import pytest
from flaskr import create_app
from flaskr.db import get_db, init_db

with open(os.path.join(os.path.dirname(__file__), 'data.sql'), 'rb') as f:
    _data_sql = f.read().decode('utf8')


@pytest.fixture
def app():
    db_fd, db_path = tempfile.mkstemp()

    app = create_app({
        'TESTING': True,
        'DATABASE': db_path,
    })

    with app.app_context():
        init_db()
        get_db().executescript(_data_sql)

    yield app

    os.close(db_fd)
    os.unlink(db_path)


@pytest.fixture
def client(app):
    return app.test_client()


@pytest.fixture
def runner(app):
    return app.test_cli_runner()

tempfile.mkstemp() 创建并打开一个临时文件,返回文件描述符和文件路径。 DATABASE 路径被覆盖,因此它指向此临时路径而不是实例文件夹。 设置路径后,将创建数据库表并插入测试数据。 测试结束后,临时文件将被关闭和删除。

TESTING 告诉 Flask 应用程序处于测试模式。 Flask 更改了一些内部行为,使其更容易测试,其他扩展也可以使用该标志来使其更容易测试。

client fixture 调用 app.test_client(),其中应用程序对象由 app fixture 创建。 测试将使用客户端向应用程序发出请求,而无需运行服务器。

runner fixture 与 client 类似。 app.test_cli_runner() 创建一个 runner,它可以调用在应用程序中注册的 Click 命令。

Pytest 通过将其函数名称与测试函数中参数的名称匹配来使用 fixture。 例如,你接下来要编写的 test_hello 函数接受一个 client 参数。 Pytest 将其与 client fixture 函数匹配,调用它,并将返回的值传递给测试函数。

工厂

关于工厂本身并没有太多要测试的内容。 大部分代码已经为每个测试执行,因此如果出现问题,其他测试会注意到。

唯一可以更改的行为是传递测试配置。 如果未传递配置,则应该有一些默认配置,否则配置应该被覆盖。

tests/test_factory.py
from flaskr import create_app


def test_config():
    assert not create_app().testing
    assert create_app({'TESTING': True}).testing


def test_hello(client):
    response = client.get('/hello')
    assert response.data == b'Hello, World!'

你在教程开始时编写工厂时添加了 hello 路由作为示例。 它返回 “Hello, World!”,因此测试检查响应数据是否匹配。

数据库

在应用程序上下文中,get_db 每次调用都应返回相同的连接。 上下文结束后,连接应关闭。

tests/test_db.py
import sqlite3

import pytest
from flaskr.db import get_db


def test_get_close_db(app):
    with app.app_context():
        db = get_db()
        assert db is get_db()

    with pytest.raises(sqlite3.ProgrammingError) as e:
        db.execute('SELECT 1')

    assert 'closed' in str(e.value)

init-db 命令应调用 init_db 函数并输出一条消息。

tests/test_db.py
def test_init_db_command(runner, monkeypatch):
    class Recorder(object):
        called = False

    def fake_init_db():
        Recorder.called = True

    monkeypatch.setattr('flaskr.db.init_db', fake_init_db)
    result = runner.invoke(args=['init-db'])
    assert 'Initialized' in result.output
    assert Recorder.called

此测试使用 Pytest 的 monkeypatch fixture 来替换 init_db 函数,并用一个记录它已被调用的函数替换它。 你上面编写的 runner fixture 用于按名称调用 init-db 命令。

身份验证

对于大多数视图,用户需要登录。 在测试中执行此操作的最简单方法是使用客户端向 login 视图发出 POST 请求。 你可以编写一个带有方法的类来执行此操作,并使用 fixture 将客户端传递给每个测试,而不是每次都写出来。

tests/conftest.py
class AuthActions(object):
    def __init__(self, client):
        self._client = client

    def login(self, username='test', password='test'):
        return self._client.post(
            '/auth/login',
            data={'username': username, 'password': password}
        )

    def logout(self):
        return self._client.get('/auth/logout')


@pytest.fixture
def auth(client):
    return AuthActions(client)

使用 auth fixture,你可以在测试中调用 auth.login()test 用户身份登录,该用户作为 app fixture 中测试数据的一部分插入。

register 视图应在 GET 上成功呈现。 在使用有效表单数据的 POST 上,它应重定向到登录 URL,并且用户的数据应在数据库中。 无效数据应显示错误消息。

tests/test_auth.py
import pytest
from flask import g, session
from flaskr.db import get_db


def test_register(client, app):
    assert client.get('/auth/register').status_code == 200
    response = client.post(
        '/auth/register', data={'username': 'a', 'password': 'a'}
    )
    assert response.headers["Location"] == "/auth/login"

    with app.app_context():
        assert get_db().execute(
            "SELECT * FROM user WHERE username = 'a'",
        ).fetchone() is not None


@pytest.mark.parametrize(('username', 'password', 'message'), (
    ('', '', b'Username is required.'),
    ('a', '', b'Password is required.'),
    ('test', 'test', b'already registered'),
))
def test_register_validate_input(client, username, password, message):
    response = client.post(
        '/auth/register',
        data={'username': username, 'password': password}
    )
    assert message in response.data

client.get() 发出 GET 请求并返回 Flask 返回的 Response 对象。 同样,client.post() 发出 POST 请求,将 data 字典转换为表单数据。

要测试页面是否成功呈现,需要发出一个简单的请求并检查 200 OK status_code。 如果呈现失败,Flask 将返回 500 Internal Server Error 代码。

headers 将在注册视图重定向到登录视图时具有带有登录 URL 的 Location 标头。

data 包含响应的正文(以字节为单位)。 如果你希望在页面上呈现某个值,请检查它是否在 data 中。 字节必须与字节进行比较。 如果你想比较文本,请改用 get_data(as_text=True)

pytest.mark.parametrize 告诉 Pytest 使用不同的参数运行相同的测试函数。 你在这里使用它来测试不同的无效输入和错误消息,而无需编写三次相同的代码。

login 视图的测试与 register 的测试非常相似。 session 应该在登录后设置 user_id,而不是测试数据库中的数据。

tests/test_auth.py
def test_login(client, auth):
    assert client.get('/auth/login').status_code == 200
    response = auth.login()
    assert response.headers["Location"] == "/"

    with client:
        client.get('/')
        assert session['user_id'] == 1
        assert g.user['username'] == 'test'


@pytest.mark.parametrize(('username', 'password', 'message'), (
    ('a', 'test', b'Incorrect username.'),
    ('test', 'a', b'Incorrect password.'),
))
def test_login_validate_input(auth, username, password, message):
    response = auth.login(username, password)
    assert message in response.data

with 代码块中使用 client 允许在返回响应后访问上下文变量,例如 session。 通常,在请求外部访问 session 会引发错误。

测试 logoutlogin 相反。 注销后,session 不应包含 user_id

tests/test_auth.py
def test_logout(client, auth):
    auth.login()

    with client:
        auth.logout()
        assert 'user_id' not in session

博客

所有博客视图都使用你之前编写的 auth fixture。 调用 auth.login(),客户端的后续请求将以 test 用户身份登录。

index 视图应显示有关使用测试数据添加的帖子的信息。 当以作者身份登录时,应该有一个链接来编辑帖子。

你还可以在测试 index 视图时测试更多身份验证行为。 当未登录时,每个页面都会显示登录或注册链接。 登录后,会有一个注销链接。

tests/test_blog.py
import pytest
from flaskr.db import get_db


def test_index(client, auth):
    response = client.get('/')
    assert b"Log In" in response.data
    assert b"Register" in response.data

    auth.login()
    response = client.get('/')
    assert b'Log Out' in response.data
    assert b'test title' in response.data
    assert b'by test on 2018-01-01' in response.data
    assert b'test\nbody' in response.data
    assert b'href="/1/update"' in response.data

用户必须登录才能访问 createupdatedelete 视图。 登录用户必须是帖子的作者才能访问 updatedelete,否则将返回 403 Forbidden 状态。 如果具有给定 idpost 不存在,updatedelete 应返回 404 Not Found

tests/test_blog.py
@pytest.mark.parametrize('path', (
    '/create',
    '/1/update',
    '/1/delete',
))
def test_login_required(client, path):
    response = client.post(path)
    assert response.headers["Location"] == "/auth/login"


def test_author_required(app, client, auth):
    # change the post author to another user
    with app.app_context():
        db = get_db()
        db.execute('UPDATE post SET author_id = 2 WHERE id = 1')
        db.commit()

    auth.login()
    # current user can't modify other user's post
    assert client.post('/1/update').status_code == 403
    assert client.post('/1/delete').status_code == 403
    # current user doesn't see edit link
    assert b'href="/1/update"' not in client.get('/').data


@pytest.mark.parametrize('path', (
    '/2/update',
    '/2/delete',
))
def test_exists_required(client, auth, path):
    auth.login()
    assert client.post(path).status_code == 404

createupdate 视图应呈现并为 GET 请求返回 200 OK 状态。 当在 POST 请求中发送有效数据时,create 应将新的帖子数据插入数据库,而 update 应修改现有数据。 两个页面都应在无效数据上显示错误消息。

tests/test_blog.py
def test_create(client, auth, app):
    auth.login()
    assert client.get('/create').status_code == 200
    client.post('/create', data={'title': 'created', 'body': ''})

    with app.app_context():
        db = get_db()
        count = db.execute('SELECT COUNT(id) FROM post').fetchone()[0]
        assert count == 2


def test_update(client, auth, app):
    auth.login()
    assert client.get('/1/update').status_code == 200
    client.post('/1/update', data={'title': 'updated', 'body': ''})

    with app.app_context():
        db = get_db()
        post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
        assert post['title'] == 'updated'


@pytest.mark.parametrize('path', (
    '/create',
    '/1/update',
))
def test_create_update_validate(client, auth, path):
    auth.login()
    response = client.post(path, data={'title': '', 'body': ''})
    assert b'Title is required.' in response.data

delete 视图应重定向到索引 URL,并且该帖子应不再存在于数据库中。

tests/test_blog.py
def test_delete(client, auth, app):
    auth.login()
    response = client.post('/1/delete')
    assert response.headers["Location"] == "/"

    with app.app_context():
        db = get_db()
        post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
        assert post is None

运行测试

可以将一些额外的配置添加到项目的 pyproject.toml 文件中,这不是必需的,但可以使运行带有覆盖率的测试不那么冗长。

pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.coverage.run]
branch = true
source = ["flaskr"]

要运行测试,请使用 pytest 命令。 它将查找并运行你编写的所有测试函数。

$ pytest

========================= test session starts ==========================
platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
rootdir: /home/user/Projects/flask-tutorial
collected 23 items

tests/test_auth.py ........                                      [ 34%]
tests/test_blog.py ............                                  [ 86%]
tests/test_db.py ..                                              [ 95%]
tests/test_factory.py ..                                         [100%]

====================== 24 passed in 0.64 seconds =======================

如果任何测试失败,pytest 将显示引发的错误。 你可以运行 pytest -v 以获取每个测试函数的列表,而不是点。

要衡量测试的代码覆盖率,请使用 coverage 命令运行 pytest,而不是直接运行它。

$ coverage run -m pytest

你可以在终端中查看简单的覆盖率报告

$ coverage report

Name                 Stmts   Miss Branch BrPart  Cover
------------------------------------------------------
flaskr/__init__.py      21      0      2      0   100%
flaskr/auth.py          54      0     22      0   100%
flaskr/blog.py          54      0     16      0   100%
flaskr/db.py            24      0      4      0   100%
------------------------------------------------------
TOTAL                  153      0     44      0   100%

HTML 报告允许你查看每个文件中覆盖了哪些行

$ coverage html

这会在 htmlcov 目录中生成文件。 在浏览器中打开 htmlcov/index.html 以查看报告。

继续阅读 部署到生产环境