寫單元測試可以檢查程式是否按預期執行,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
就可以看到結果