测试覆盖¶

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

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

    Note

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

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

    测试代码位于 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 路径被重载,这样它会指向临时路径,而不是实例文件夹。设置好路径之后,数据库表被创建,然后插入数据。测试结束后,临时文件会被关闭并删除。

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

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

    runner 固件类似于 client 。 创建一个运行器,可以调用应用注册的 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 都应当返回相同的连接。退出环境后,连接应当已关闭。

    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.  
    4. def fake_init_db():
    5. Recorder.called = True
    6.  
    7. monkeypatch.setattr('flaskr.db.init_db', fake_init_db)
    8. result = runner.invoke(args=['init-db'])
    9. assert 'Initialized' in result.output
    10. 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 用户。这个用户的数据已经在 固件中写入了数据。

    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 返回 对象。类似的client.post() 制作一个 POST 请求,转换 data 字典为表单数据。

    为了测试页面是否渲染成功,制作一个简单的请求,并检查是否返回一个 200 OK 。如果渲染失败,Flask 会返回一个 500 Internal Server Error 代码。

    当注册视图重定向到登录视图时, headers 会有一个包含登录URL 的 Location 头部。

    以字节方式包含响应的身体。如果想要检测渲染页面中的某个值,请 data 中检测。字节值只能与字节值作比较,如果想比较 Unicode文本,请使用get_data(as_text=True)

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

    login 视图的测试与 register 的非常相似。后者是测试数据库中的数据,前者是测试登录之后 应当包含 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 会引发一个异常。

    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 索引视图应当显示已添加的测试帖子数据。作为作者登录之后,应当有编辑博客的连接。

    用户必须登录后才能访问 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'] == ';

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

    3. with app.app_context():
    4.     db = get_db()
    5.     db.execute('UPDATE post SET author_id = 2 WHERE id = 1')
    6.     db.commit()
    7. auth.login()
    8. # current user can't modify other user's post
    9. assert client.post('/1/delete').status_code == 403
    10. # current user doesn't see edit link
    11. assert b'href="/1/update"' not in client.get('/').data
    12. @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. TOTAL 153 0 44 0 100%

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

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