写单元测试可以检查程序是否按预期执行,Flask 可以模拟发送请求并回传资料
应当尽可能多进行测试,函数中的程序只有在函数被呼叫的情况下才会运行
程序中的判断条件,例如 if 判断条件下,只有在符合条件的情况下才会运行
测试应该覆盖每个函数和每个判断条件
越接近 100% 的测试覆盖率,越能够保证修改程序後不会出现意外
但是 100% 覆盖率也不能保证程序没有错误,因为单元测试测试不会包含使用者如何在浏览器中操作
尽管如此,在开发过程中测试覆盖率仍然是非常重要的
这边会使用 pytest 和 coverage 来进行测试和评估
先进行安装:
pip install pytest coverage
测试程序位於tests
资料夹中,该资料夹位於flaskr
的同层而不是里面
tests/conftest.py
文件包含名为fixtures
的设定函数,每个测试都会用到这个函数
测试位於 Python 模组中,以test_
开头,并且模组中的每个测试函数也以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
fixtures 会呼叫工厂并为测试传递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() 建立一个可以呼叫应用中注册功能 Click 指令的 runner
Pytest 通过对应函数名称和测试函数的参数名称来使用 fixture
例如下面要写test_hello
函数有一个client
参数
Pytest 就会使用名为client
的 fixture 函数,呼叫函数并把回传值给测试函数
工厂本身没有什麽好测试的,因为大部分程序会被每个测试用到
因此如果工厂程序有问题,那麽在进行其他测试时会被发现
唯一可以改变的行为是传递测试 config,如果有传递设定内容要被覆写
否则应该要使用预设值
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!'
还记得在一开始这系列之前我们有在flaskr/__init__.py
建立过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
函数并输出一个讯息
接着使用 Pytest’s monkeypatch
fixture 来取代init_db
函数进行测试
使用前面写的runner
fixture 透过名称呼叫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
在大多数 view 里,使用者需要先登入
在测试中最方便的方法是使用用户端制作一个 POST
请求发送给login
view
与其每次都写一遍,不如写一个 class 来做这件事,并使用一个 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
view 应该在GET
请求时渲染成功
在POST
请求中,表单资料通过验证时 view 应该要写入使用者资料到资料库,并重新导向到登入页
资料验证失败时,要显示错误讯息
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 '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 物件
而POST
也是类似的作法 client.post(),把data
dict 当作要送出的表单
为了测试页面是否渲染成功,制作一个简单的 request,并检查是否返回一个200 OK
status_code
如果渲染失败 Flask 会回传一个500 Internal Server Error
状态码
headers will have a Location header with the login URL when the register view redirects to the login view.
当注册 view 重新导向(3XX)到登入 view 时,headers 会包含登入 URL 的 Location
data 以 bytes 方式回传
如果想要检测渲染页面中的某个值,那就在data
中检查
bytes 值只能与 bytes 值作比较。如果想比较字串,要使用get_data(as_text=True)
pytest.mark.parametrize
告诉 Pytest 以不同的参数执行同一个测试
这里用於测试不同的非法输入和错误讯息,避免重复写三次相同的程序
login
view 的测试和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'] == '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
通常情况下,若是在请求之外存取session
会发生错误
如果在with
区块中使用client
则允许我们在 response 回传之後操作环境变数,例如 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
所有部落格的 view 使用之前所写的auth
fixture
呼叫auth.login()
,并且用户端的後续请求会作为叫做test
的使用者
index
view 应该要显示已经加入的测试文章,作为作者登入之後应该要有编辑的连结
当测试index
view时,还可以测试更多验证行为
当没有登录时每个页面显示登入或注册连结,当登入之後则是要有登出连结
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
的 view
文章作者才能造访update
和delete
,否则回传一个403 Forbidden
状态码
如果要访问的文章id
不存在,那麽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'] == '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
view 应该渲染画面和回传一个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
view 要重新导向到首页,并且文章要从资料库里被删除
tests/test_blog.py
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
档案,这些设定不是必需的,但是可以让覆盖率测试不这麽冗长
setup.cfg
[tool:pytest]
testpaths = tests
[coverage:run]
branch = True
source =
flaskr
使用pytest
来执行测试,该指令会找到并且执行所有测试!
如果有测试失败, pytest 会显示引发的错误
例如这边我们就遇到一个错误
这可能是因为你的虚拟环境重开过,重新打包就没事了
打包的指令是
pip install -e .
可以使用pytest -v
得到每个测试的列表,而不是一串点
要评估测试的覆盖率的话,可以使用coverage
指令来执行 pytest
coverage run -m pytest
接着就可以取得简单的覆盖率报告
coverage report
除此之外还可以生成 HTML 的报告,可以看到每个档案中哪些内容有被测试覆盖
coverage html
这个指令会在htmlcov
资料夹中产生测试报告
在浏览器中打开htmlcov/index.html
就可以看到结果
<<: 自动化 End-End 测试 Nightwatch.js 之踩雷笔记:关闭多视窗
>>: 【Day 17】Google Apps Script - API 篇 - Spreadsheet Service - 电子试算表服务介绍
运算子 所谓的运算子就是就是一些符号,运算广义来说就是对资料做的任何动作。 平常生活中的加减乘除是一...
Appendix: 架构考古学 联盟会计系统 简述 1960 年代,很简单的 CRUD 记帐系统,由...
第3天写了打字特效炫起来! 今天来个姊妹篇,手写字特效炫起来! 已经默默进入SVG几天了... 老样...
目的 1.降低资料重复性(Data Redundancy) 2.避免资料更新异常(Anomalies...
今天来看看一个常见问题。 { first_name: 'chris', last_name: 'wa...