iT邦幫忙

2021 iThome 鐵人賽

DAY 18
0
Software Development

都是 P 開頭的程式語言,你是在 py 啥啦系列 第 18

[18] [燒瓶裡的部落格] 08. 撰寫測試

  • 分享至 

  • xImage
  •  

寫單元測試可以檢查程式是否按預期執行,Flask 可以模擬發送請求並回傳資料

應當盡可能多進行測試,函數中的程式只有在函數被呼叫的情況下才會運行
程式中的判斷條件,例如 if 判斷條件下,只有在符合條件的情況下才會運行
測試應該覆蓋每個函數和每個判斷條件

越接近 100% 的測試覆蓋率,越能夠保證修改程式後不會出現意外
但是 100% 覆蓋率也不能保證程式沒有錯誤,因為單元測試測試不會包含使用者如何在瀏覽器中操作
盡管如此,在開發過程中測試覆蓋率仍然是非常重要的

這邊會使用 pytestcoverage 來進行測試和評估
先進行安裝:

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 會改變一些內部行為方便測試,其他的擴展也可以使用這個標籤讓測試更容易進行

clientfixture 呼叫app.test_client()app fixture 建立的應用程式物件
測試會使用用戶端來發送請求,而不用預先啟動服務

runnerfixture 類似於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 使用之前所寫的authfixture
呼叫auth.login(),並且用戶端的後續請求會作為叫做test的使用者

index view 應該要顯示已經加入的測試文章,作為作者登入之後應該要有編輯的連結

當測試indexview時,還可以測試更多驗證行為
當沒有登錄時每個頁面顯示登入或註冊連結,當登入之後則是要有登出連結

畫面渲染

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

使用者權限

使用者必須登入後才能造訪createupdatedelete 的 view
文章作者才能造訪updatedelete,否則回傳一個403 Forbidden狀態碼
如果要訪問的文章id不存在,那麼updatedelete要回傳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請求,createupdate 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就可以看到結果


上一篇
[17] [燒瓶裡的部落格] 07. 專案可安裝化(打包)
下一篇
[19] [燒瓶裡的部落格] 09. 正式部署
系列文
都是 P 開頭的程式語言,你是在 py 啥啦30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言