测试覆盖¶

为应用写单元测试可以检查代码是否按预期执行。 Flask 提供了测试客户端,可以模拟向应用发送请求并返回响应数据。

应当尽可能多地进行测试。函数中的代码只有在函数被调用的情况下才会运行。分支中的代码,如 if 块中的代码,只有在符合条件的情况下才会运行。测试应当覆盖每个函数和每个分支。

越接近 100% 的测试覆盖,越能够保证修改代码后不会出现意外。但是 100% 测试覆盖不能保证应用没有错误。通常,测试不会覆盖用户如何在浏览器中与应用进行交互。尽管如此,在开发过程中,测试覆盖仍然是非常重要的。

Note

这部分内容在教程中是放在后面介绍的,但是在以后的项目中,应当在开发的时候进行测试。

我们使用 pytestcoverage 来进行测试和衡量代码。先安装它们:

  1. pip install pytest coverage

配置和固件¶

测试代码位于 tests 文件夹中,该文件夹位于 flaskr 包的 旁边 ,而不是里面。 tests/conftest.py 文件包含名为 fixtures (固件)的配置函数。每个测试都会用到这个函数。测试位于 Python 模块中,以 test 开头,并且模块中的每个测试函数也以 test 开头。

每个测试会创建一个新的临时数据库文件,并产生一些用于测试的数据。写一个SQL 文件来插入数据。

tests/data.sql

  1. INSERT INTO user (username, password)
  2. VALUES
  3. ('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'),
  4. ('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79');
  5.  
  6. INSERT INTO post (title, body, author_id, created)
  7. VALUES
  8. ('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00');

app 固件会调用工厂并为测试传递 test_config 来配置应用和数据库,而不使用本地的开发配置。

tests/conftest.py

  1. import os
  2. import tempfile
  3.  
  4. import pytest
  5. from flaskr import create_app
  6. from flaskr.db import get_db, init_db
  7.  
  8. with open(os.path.join(os.path.dirname(__file__), 'data.sql'), 'rb') as f:
  9. _data_sql = f.read().decode('utf8')
  10.  
  11.  
  12. @pytest.fixture
  13. def app():
  14. db_fd, db_path = tempfile.mkstemp()
  15.  
  16. app = create_app({
  17. 'TESTING': True,
  18. 'DATABASE': db_path,
  19. })
  20.  
  21. with app.app_context():
  22. init_db()
  23. get_db().executescript(_data_sql)
  24.  
  25. yield app
  26.  
  27. os.close(db_fd)
  28. os.unlink(db_path)
  29.  
  30.  
  31. @pytest.fixture
  32. def client(app):
  33. return app.test_client()
  34.  
  35.  
  36. @pytest.fixture
  37. def runner(app):
  38. return app.test_cli_runner()

tempfile.mkstemp() 创建并打开一个临时文件,返回该文件对象和路径。DATABASE 路径被重载,这样它会指向临时路径,而不是实例文件夹。设置好路径之后,数据库表被创建,然后插入数据。测试结束后,临时文件会被关闭并删除。

TESTING 告诉 Flask 应用处在测试模式下。 Flask 会改变一些内部行为以方便测试。其他的扩展也可以使用这个标志方便测试。

client 固件调用app.test_client()app 固件创建的应用对象。测试会使用客户端来向应用发送请求,而不用启动服务器。

runner 固件类似于 clientapp.test_cli_runner() 创建一个运行器,可以调用应用注册的 Click 命令。

Pytest 通过匹配固件函数名称和测试函数的参数名称来使用固件。例如下面要写 test_hello 函数有一个 client 参数。 Pytest 会匹配client 固件函数,调用该函数,把返回值传递给测试函数。

工厂¶

工厂本身没有什么好测试的,其大部分代码会被每个测试用到。因此如果工厂代码有问题,那么在进行其他测试时会被发现。

唯一可以改变的行为是传递测试配置。如果没传递配置,那么会有一些缺省配置可用,否则配置会被重载。

tests/test_factory.py

  1. from flaskr import create_app
  2.  
  3.  
  4. def test_config():
  5. assert not create_app().testing
  6. assert create_app({'TESTING': True}).testing
  7.  
  8.  
  9. def test_hello(client):
  10. response = client.get('/hello')
  11. assert response.data == b'Hello, World!'

在本教程开头的部分添加了一个 hello 路由作为示例。它返回“Hello, World!” ,因此测试响应数据是否匹配。

数据库¶

在一个应用环境中,每次调用 get_db 都应当返回相同的连接。退出环境后,连接应当已关闭。

tests/test_db.py

  1. import sqlite3
  2.  
  3. import pytest
  4. from flaskr.db import get_db
  5.  
  6.  
  7. def test_get_close_db(app):
  8. with app.app_context():
  9. db = get_db()
  10. assert db is get_db()
  11.  
  12. with pytest.raises(sqlite3.ProgrammingError) as e:
  13. db.execute('SELECT 1')
  14.  
  15. assert 'closed' in str(e)

init-db 命令应当调用 init_db 函数并输出一个信息。

tests/test_db.py

  1. def test_init_db_command(runner, monkeypatch):
  2. class Recorder(object):
  3. called = False
  4.  
  5. def fake_init_db():
  6. Recorder.called = True
  7.  
  8. monkeypatch.setattr('flaskr.db.init_db', fake_init_db)
  9. result = runner.invoke(args=['init-db'])
  10. assert 'Initialized' in result.output
  11. assert Recorder.called

这个测试使用 Pytest’s monkeypatch 固件来替换 init_db 函数。前文写的 runner 固件用于通过名称调用 init-db 命令。

验证¶

对于大多数视图,用户需要登录。在测试中最方便的方法是使用客户端制作一个POST 请求发送给 login 视图。与其每次都写一遍,不如写一个类,用类的方法来做这件事,并使用一个固件把它传递给每个测试的客户端。

tests/conftest.py

  1. class AuthActions(object):
  2. def __init__(self, client):
  3. self._client = client
  4.  
  5. def login(self, username='test', password='test'):
  6. return self._client.post(
  7. '/auth/login',
  8. data={'username': username, 'password': password}
  9. )
  10.  
  11. def logout(self):
  12. return self._client.get('/auth/logout')
  13.  
  14.  
  15. @pytest.fixture
  16. def auth(client):
  17. return AuthActions(client)

通过 auth 固件,可以在调试中调用 auth.login() 登录为test 用户。这个用户的数据已经在 app 固件中写入了数据。

register 视图应当在 GET 请求时渲染成功。在 POST 请求中,表单数据合法时,该视图应当重定向到登录 URL ,并且用户的数据已在数据库中保存好。数据非法时,应当显示出错信息。

tests/test_auth.py

  1. import pytest
  2. from flask import g, session
  3. from flaskr.db import get_db
  4.  
  5.  
  6. def test_register(client, app):
  7. assert client.get('/auth/register').status_code == 200
  8. response = client.post(
  9. '/auth/register', data={'username': 'a', 'password': 'a'}
  10. )
  11. assert 'http://localhost/auth/login' == response.headers['Location']
  12.  
  13. with app.app_context():
  14. assert get_db().execute(
  15. "select * from user where username = 'a'",
  16. ).fetchone() is not None
  17.  
  18.  
  19. @pytest.mark.parametrize(('username', 'password', 'message'), (
  20. ('', '', b'Username is required.'),
  21. ('a', '', b'Password is required.'),
  22. ('test', 'test', b'already registered'),
  23. ))
  24. def test_register_validate_input(client, username, password, message):
  25. response = client.post(
  26. '/auth/register',
  27. data={'username': username, 'password': password}
  28. )
  29. 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 中检测。字节值只能与字节值作比较,如果想比较 Unicode文本,请使用get_data(as_text=True)

pytest.mark.parametrize 告诉 Pytest 以不同的参数运行同一个测试。这里用于测试不同的非法输入和出错信息,避免重复写三次相同的代码。

login 视图的测试与 register 的非常相似。后者是测试数据库中的数据,前者是测试登录之后 session 应当包含 user_id

tests/test_auth.py

  1. def test_login(client, auth):
  2. assert client.get('/auth/login').status_code == 200
  3. response = auth.login()
  4. assert response.headers['Location'] == 'http://localhost/'
  5.  
  6. with client:
  7. client.get('/')
  8. assert session['user_id'] == 1
  9. assert g.user['username'] == 'test'
  10.  
  11.  
  12. @pytest.mark.parametrize(('username', 'password', 'message'), (
  13. ('a', 'test', b'Incorrect username.'),
  14. ('test', 'a', b'Incorrect password.'),
  15. ))
  16. def test_login_validate_input(auth, username, password, message):
  17. response = auth.login(username, password)
  18. assert message in response.data

with 块中使用 client ,可以在响应返回之后操作环境变量,比如session 。 通常,在请求之外操作 session 会引发一个异常。

logout 测试与 login 相反。注销之后, session 应当不包含user_id

tests/test_auth.py

  1. def test_logout(client, auth):
  2. auth.login()
  3.  
  4. with client:
  5. auth.logout()
  6. assert 'user_id' not in session

博客¶

所有博客视图使用之前所写的 auth 固件。调用auth.login() ,并且客户端的后继请求会登录为test 用户。

index 索引视图应当显示已添加的测试帖子数据。作为作者登录之后,应当有编辑博客的连接。

当测试 index 视图时,还可以测试更多验证行为。当没有登录时,每个页面显示登录或注册连接。当登录之后,应当有一个注销连接。

tests/test_blog.py

  1. import pytest
  2. from flaskr.db import get_db
  3.  
  4.  
  5. def test_index(client, auth):
  6. response = client.get('/')
  7. assert b"Log In" in response.data
  8. assert b"Register" in response.data
  9.  
  10. auth.login()
  11. response = client.get('/')
  12. assert b'Log Out' in response.data
  13. assert b'test title' in response.data
  14. assert b'by test on 2018-01-01' in response.data
  15. assert b'test\nbody' in response.data
  16. assert b'href="/1/update"' in response.data

用户必须登录后才能访问 createupdatedelete 视图。帖子作者才能访问 updatedelete 。否则返回一个 403 Forbidden状态码。如果要访问 postid 不存在,那么 updatedelete应当返回 404 Not Found

tests/test_blog.py

  1. @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'

  2. def test_author_required(app, client, auth):

  3. # change the post author to another user
  4. with app.app_context():
  5.     db = get_db()
  6.     db.execute('UPDATE post SET author_id = 2 WHERE id = 1')
  7.     db.commit()
  8. auth.login()
  9. # current user can't modify other user's post
  10. assert client.post('/1/update').status_code == 403
  11. assert client.post('/1/delete').status_code == 403
  12. # current user doesn't see edit link
  13. assert b'href="/1/update"' not in client.get('/').data
  14. @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 请求, createupdate 视图应当渲染和返回一个200 OK 状态码。当 POST 请求发送了合法数据后, create 应当在数据库中插入新的帖子数据, update 应当修改数据库中现存的数据。当数据非法时,两者都应当显示一个出错信息。

tests/test_blog.py

  1. def test_create(client, auth, app):
  2. auth.login()
  3. assert client.get('/create').status_code == 200
  4. client.post('/create', data={'title': 'created', 'body': ''})
  5.  
  6. with app.app_context():
  7. db = get_db()
  8. count = db.execute('SELECT COUNT(id) FROM post').fetchone()[0]
  9. assert count == 2
  10.  
  11.  
  12. def test_update(client, auth, app):
  13. auth.login()
  14. assert client.get('/1/update').status_code == 200
  15. client.post('/1/update', data={'title': 'updated', 'body': ''})
  16.  
  17. with app.app_context():
  18. db = get_db()
  19. post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
  20. assert post['title'] == 'updated'
  21.  
  22.  
  23. @pytest.mark.parametrize('path', (
  24. '/create',
  25. '/1/update',
  26. ))
  27. def test_create_update_validate(client, auth, path):
  28. auth.login()
  29. response = client.post(path, data={'title': '', 'body': ''})
  30. assert b'Title is required.' in response.data

delete 视图应当重定向到索引 URL ,并且帖子应当从数据库中删除。

tests/test_blog.py

  1. def test_delete(client, auth, app):
  2. auth.login()
  3. response = client.post('/1/delete')
  4. assert response.headers['Location'] == 'http://localhost/'
  5.  
  6. with app.app_context():
  7. db = get_db()
  8. post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
  9. assert post is None

运行测试¶

额外的配置可以添加到项目的 setup.cfg 文件。这些配置不是必需的,但是可以使用测试更简洁明了。

setup.cfg

  1. [tool:pytest]
  2. testpaths = tests
  3.  
  4. [coverage:run]
  5. branch = True
  6. source =
  7. flaskr

使用 pytest 来运行测试。该命令会找到并且运行所有测试。

  1. pytest
  2.  
  3. ========================= test session starts ==========================
  4. platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
  5. rootdir: /home/user/Projects/flask-tutorial, inifile: setup.cfg
  6. collected 23 items
  7.  
  8. tests/test_auth.py ........ [ 34%]
  9. tests/test_blog.py ............ [ 86%]
  10. tests/test_db.py .. [ 95%]
  11. tests/test_factory.py .. [100%]
  12.  
  13. ====================== 24 passed in 0.64 seconds =======================

如果有测试失败, pytest 会显示引发的错误。可以使用pytest -v 得到每个测试的列表,而不是一串点。

可以使用 coverage 命令代替直接使用 pytest 来运行测试,这样可以衡量测试覆盖率。

  1. coverage run -m pytest

在终端中,可以看到一个简单的覆盖率报告:

  1. coverage report
  2.  
  3. Name Stmts Miss Branch BrPart Cover
  4. ------------------------------------------------------
  5. flaskr/__init__.py 21 0 2 0 100%
  6. flaskr/auth.py 54 0 22 0 100%
  7. flaskr/blog.py 54 0 16 0 100%
  8. flaskr/db.py 24 0 4 0 100%
  9. ------------------------------------------------------
  10. TOTAL 153 0 44 0 100%

还可以生成 HTML 报告,可以看到每个文件中测试覆盖了哪些行:

  1. coverage html

这个命令在 htmlcov 文件夹中生成测试报告,然后在浏览器中打开htmlcov/index.html 查看。

下面请阅读 部署产品

原文: https://dormousehole.readthedocs.io/en/latest/tutorial/tests.html