测试覆盖率¶
为你的应用程序编写单元测试,让你能够检查你编写的代码是否按照你的预期工作。Flask 提供了一个测试客户端,它模拟对应用程序的请求并返回响应数据。
你应该尽可能多地测试你的代码。函数中的代码仅在函数被调用时运行,而分支中的代码(例如 if
块)仅在满足条件时运行。你希望确保每个函数都使用涵盖每个分支的数据进行测试。
你越接近 100% 的覆盖率,你就越能确信进行更改不会意外地改变其他行为。但是,100% 的覆盖率并不能保证你的应用程序没有错误。特别是,它不会测试用户如何在浏览器中与应用程序进行交互。尽管如此,测试覆盖率仍然是开发期间使用的一个重要工具。
注意
这在教程中较晚才介绍,但在你未来的项目中,你应该在开发时进行测试。
你将使用 pytest 和 coverage 来测试和测量你的代码。同时安装它们
$ pip install pytest coverage
设置和夹具¶
测试代码位于 tests
目录中。此目录位于 flaskr
包的旁边,而不是在其内部。 tests/conftest.py
文件包含称为夹具的设置函数,每个测试都将使用这些函数。测试位于以 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
夹具将调用工厂并传递 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
固定装置调用 app.test_client()
,其中应用程序对象由 app
固定装置创建。测试将使用客户端向应用程序发出请求,而无需运行服务器。
runner
固定装置类似于 client
。 app.test_cli_runner()
创建一个运行器,该运行器可以调用向应用程序注册的 Click 命令。
Pytest 通过将固定装置函数名称与测试函数中参数的名称进行匹配来使用固定装置。例如,您接下来要编写的 test_hello
函数采用 client
参数。Pytest 将其与 client
固定装置函数进行匹配,调用该函数,并将返回值传递给测试函数。
工厂¶
工厂本身没有太多要测试的内容。大部分代码将为每个测试执行,因此如果出现故障,其他测试将注意到。
唯一可以更改的行为是传递测试配置。如果未传递配置,则应该有一些默认配置,否则应该覆盖配置。
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
夹具,用一个记录已调用的函数替换 init_db
函数。您在上面编写的 runner
夹具用于按名称调用 init-db
命令。
身份验证¶
对于大多数视图,用户需要登录。在测试中执行此操作的最简单方法是向客户端的 login
视图发出 POST
请求。与其每次都编写,您可以编写一个包含方法的类来执行此操作,并使用夹具为每个测试传递客户端。
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
夹具,您可以在测试中调用 auth.login()
以登录为 test
用户,该用户已作为 app
夹具中的测试数据的一部分插入。
register
视图应在 GET
上成功呈现。在使用有效表单数据进行 POST
时,它应重定向到登录网址,并且用户数据应在数据库中。无效数据应显示错误消息。
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
将包含一个 Location
头,其中包含登录 URL。
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
会引发错误。
测试 logout
与 login
相反。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
用户必须登录才能访问 create
、update
和 delete
视图。登录的用户必须是帖子的作者才能访问 update
和 delete
,否则会返回 403 Forbidden
状态。如果给定 id
的 post
不存在,则 update
和 delete
应返回 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
create
和 update
视图应呈现并返回 200 OK
状态以用于 GET
请求。当在 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
以查看报告。
继续到 部署到生产环境。