测试覆盖率¶
为你的应用编写单元测试可以让你检查你写的代码是否按照预期的方式工作。Flask 提供了一个测试客户端,可以用来模拟发送到应用的请求并返回响应数据。
你应该测试尽可能多的代码。函数里的代码只会在函数被调用时运行,而分支里的代码,比如 if
块,只会在条件满足时运行。你要确保每一个函数都使用覆盖每一个分支的数据进行测试。
你越接近 100% 覆盖率,你就越能确信对应用做出一个改动时不会意外地改变其他行为。然而,100% 的覆盖率并不能保证你的应用没有错误。尤其是,它没有测试用户如何在浏览器里与应用进行交互。尽管如此,测试覆盖率是一个在开发中使用的重要工具。
备注
这部分在教程的后面被引入,但是在未来的项目里你应该在开发时同步进行测试。
你将使用 pytest 和 coverage 来测试你的代码,并评估测试覆盖率。安装它们:
$ pip install pytest coverage
设置(setup)和固件(fixture)¶
测试代码位于 tests
目录中。这个目录与 flaskr
包 同级,而不是在它里面。tests/conftest.py
文件包含每一个测试都会使用的叫做 fixtures(固件) 的设置函数。测试在名称以 test_
开头的 Python 模块中,而且这些模块中的每一个测试函数的名称也以 test_
开头。
每一个测试都会创建一个新的临时数据库文件,并填充一些将在测试中使用的数据。写一个 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
来配置应用和数据库,以便使用测试配置而不是你的本地开发配置。
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
固件创建的应用对象调用 app.test_client()
。测试将使用这个客户端来发送请求到应用,不用运行服务器。
runner
固件和 client
类似。app.test_cli_runner()
创建一个运行器,它可以用来调用注册到应用的 Click 命令。
pytest 通过匹配固件的函数名称和测试函数中的参数名称来使用固件。举例来说,你接下来要写的 test_hello
函数接受一个 client
参数。pytest 通过参数名匹配到 client
固件函数,调用它,然后传递返回值到测试函数。
应用工厂¶
应用工厂本身没有什么可以测试。大多数的代码已经被每一个测试执行了,所以如果有什么地方测试失败了,其他的测试会注意到。
唯一可以改变的行为是传递测试配置。如果配置没有被传递,应该有一些默认配置,否则配置应该被重写。
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
每次被调用时都应该返回相同的连接。在上下文之后,连接应该被关闭。
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
函数并输出一条消息。
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
命令。
认证¶
对于大多数视图,用户需要登录才能访问。在测试中实现登录最简单的方式是用客户端发出一个 POST
请求到 login
视图。与其每次都写出来,不如写一个类,在方法里实现这些逻辑,然后为它们创建一个固件,并传递 client
固件给它。
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
请求发来时,它应该重定向到登录 URL,而且用户的数据应该在数据库中。无效的数据应该显示错误消息。
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 'http://localhost/auth/login' == response.headers['Location']
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
状态码。
当 register
视图重定向到 login
视图时,headers
会有一个带有登录 URL 的 Location
首部(Header)。
data
包含字节格式的响应体。如果你期待页面上有一个特定的值被渲染,检查它是否在 data
里。字节必须和字节对比。如果你想要对比文本,则使用 get_data(as_text=True)
。
pytest.mark.parametrize
告诉 pytest 使用不同的参数运行相同的测试函数。在这里你使用它来测试不同的无效输入和错误消息,而不用重复写三次相同的代码。
login
视图的测试和 register
视图的测试非常相似。后者是测试数据库中的数据,前者是测试 session
应该在登录后设置好 user_id
。
def test_login(client, auth):
assert client.get('/auth/login').status_code == 200
response = auth.login()
assert response.headers['Location'] == 'http://localhost/'
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
。
def test_logout(client, auth):
auth.login()
with client:
auth.logout()
assert 'user_id' not in session
博客¶
所有的博客视图都使用了你之前编写 auth
固件。它们会调用 auth.login()
,并且从客户端发送后续的请求都将会作为 test
用户登录。
index
视图应该显示关于随测试数据添加的帖子的信息。当做为作者登录时,应该有一个编辑帖子的链接。
你也可以在测试 index
视图时测试更多的认证行为。当未登录时,每一个页面会显示登录或注册链接。当登录后,会有一个链接用来登出。
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
。
@pytest.mark.parametrize('path', (
'/create',
'/1/update',
'/1/delete',
))
def test_login_required(client, path):
response = client.post(path)
assert response.headers['Location'] == 'http://localhost/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
对于 GET
请求,create
和 update
视图应该渲染并返回一个 200 OK
状态码。当有效的数据通过一个 POST
请求发送,create
应该插入新的数据到数据库中,而且 update
应该修改现有的数据。对于非法数据,这两个页面都应该显示一个错误消息。
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,并且帖子应该不再存在于数据库中。
def test_delete(client, auth, app):
auth.login()
response = client.post('/1/delete')
assert response.headers['Location'] == 'http://localhost/'
with app.app_context():
db = get_db()
post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
assert post is None
运行测试¶
有一些额外的配置可以添加到项目的 setup.cfg
文件。它们并不是必须的,但是可以简化运行测试和计算覆盖率的命令。
[tool:pytest]
testpaths = tests
[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, inifile: setup.cfg
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
查看报告。
继续阅读 部署到生产环境。