پوشش تست¶
نوشتن تستهای واحد برای برنامه به شما امکان میدهد بررسی کنید که کدی که نوشتهاید همانطور که انتظار دارید کار میکند. فلاسک یک سرویس گیرنده آزمایشی ارائه می دهد که درخواست ها را به برنامه شبیه سازی می کند و داده های پاسخ را برمی گرداند.
شما باید تا حد امکان کد خود را آزمایش کنید. کد در توابع تنها زمانی اجرا میشود که تابع فراخوانی شود و کد در شاخهها، مانند بلوکهای``if``، تنها زمانی اجرا میشود که شرط برقرار باشد. شما می خواهید مطمئن شوید که هر تابع با داده هایی که هر شاخه را پوشش می دهد آزمایش می شود.
هرچه به پوشش ۱۰۰ درصدی نزدیکتر شوید، راحتتر خواهید بود که ایجاد تغییر بهطور غیرمنتظره رفتارهای دیگر را تغییر نمیدهد. با این حال، پوشش ۱۰۰ درصدی تضمین نمی کند که برنامه شما باگ نداشته باشد. به ویژه، نحوه تعامل کاربر با برنامه در مرورگر را آزمایش نمی کند. با وجود این، پوشش تست ابزار مهمی برای استفاده در طول توسعه است.
توجه
این در اواخر آموزش معرفی شده است، اما در پروژه های آینده خود باید همزمان با توسعه آزمایش کنید.
برای آزمایش و اندازه گیری کد خود از pytest و coverage استفاده خواهید کرد. هر دو را نصب کنید:
$ pip install pytest coverage
راه اندازی و لوازم¶
کد تست در دایرکتوری tests
قرار دارد. این دایرکتوری کنار بسته flaskr
است، نه داخل آن. فایل tests/conftest.py
حاوی توابع راه اندازی به نام fixtures است که هر تست از آنها استفاده خواهد کرد. تستها در ماژولهای پایتون هستند که با test_
شروع میشوند، و هر تابع تست در آن ماژولها نیز با test_
شروع میشود.
هر آزمون یک فایل پایگاه داده موقت جدید ایجاد می کند و برخی از داده ها را پر می کند که در آزمایش ها استفاده می شود. یک فایل 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
را فراخوانی می کند و به جای استفاده از پیکربندی توسعه محلی شما، "test_config" را برای تعیین برنامه و پایگاه داده برای آزمایش پیکربندی می کند.
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
به فلاسک می گوید که برنامه در حالت تست است. فلاسک برخی از رفتارهای داخلی را تغییر میدهد، بنابراین آزمایش آن آسانتر است، و سایر برنامههای افزودنی نیز میتوانند از پرچم برای آسانتر کردن آزمایش آنها استفاده کنند.
ابزار client
fixture calls app.test_client()
را با شی برنامه ایجاد شده توسط فیکسچر app
فراخوانی می کند. تستها از کلاینت برای ارسال درخواست به برنامه بدون اجرای سرور استفاده میکنند.
فیکسچر runner
شبیه client
است. app.test_cli_runner()
یک runner ایجاد می کند که می تواند دستورات Click ثبت شده در برنامه را فراخوانی کند.
Pytest از ابزار ها با تطبیق نام توابع آنها با نام آرگومان ها در توابع تست استفاده می کند. به عنوان مثال، تابع test_hello
که در ادامه می نویسید، آرگومان client
را می گیرد. Pytest آن را با تابع ثابت client
مطابقت می دهد، آن را فراخوانی می کند و مقدار بازگشتی را به تابع تست ارسال می کند.
کارخانه¶
چیز زیادی برای آزمایش در مورد خود کارخانه وجود ندارد. بیشتر کدها برای هر تست قبلاً اجرا میشوند، بنابراین اگر چیزی ناموفق باشد، تستهای دیگر متوجه خواهند شد.
تنها رفتاری که می تواند تغییر کند گذراندن تنظیمات آزمایشی است. اگر پیکربندی تنظیم نشد، باید تنظیمات پیش فرض وجود داشته باشد، در غیر این صورت پیکربندی باید لغو شود.
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!'
هنگام نوشتن کارخانه در ابتدای آموزش مسیر hello
را به عنوان مثال اضافه کردید. «Hello, World!» را برمیگرداند، بنابراین آزمون بررسی میکند که دادههای پاسخ مطابقت دارند.
پایگاه داده¶
در یک زمینه برنامه، get_db
باید هر بار که تماس میگیرد همان اتصال را برگرداند. پس از متن، اتصال باید بسته شود.
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
را فراخوانی کند و پیامی را ارسال کند.
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
این تست از ابزار Pytest monkeypatch
برای جایگزینی تابع init_db
با تابعی استفاده میکند که فراخوانی آن را ثبت میکند. فیکسچر runner
که در بالا نوشتید برای فراخوانی دستور init-db
با نام استفاده میشود.
احراز هویت¶
برای اکثر بازدیدها، یک کاربر نیاز به ورود به سیستم دارد. ساده ترین راه برای انجام این کار در تست ها این است که یک درخواست POST
به نمای login
با کلاینت ارسال کنید. به جای اینکه هر بار آن را بنویسید، میتوانید یک کلاس با روشهایی برای انجام آن بنویسید و از یک ابزار برای پاس کردن آن به مشتری برای هر آزمون استفاده کنید.
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
، می توانید در یک آزمایش با auth.login()
تماس بگیرید تا به عنوان کاربر test
وارد شوید، که به عنوان بخشی از داده های تست در app
درج شده است.
نمای register
باید با موفقیت در GET
ارائه شود. در POST
با دادههای فرم معتبر، باید به URL ورود به سیستم هدایت شود و دادههای کاربر باید در پایگاه داده باشد. داده های نامعتبر باید پیام های خطا را نمایش دهند.
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 response.headers["Location"] == "/auth/login"
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
می دهد و شی Response
را که توسط فلاسک برگردانده شده است، برمی گرداند. به طور مشابه، client.post()
یک درخواست POST
ایجاد می کند و دیکت data
را به داده فرم تبدیل می کند.
برای آزمایش اینکه صفحه با موفقیت رندر میشود، یک درخواست ساده ارسال میشود و status_code
بررسی میشود. اگر رندر انجام شد ۲۰۰ خوبه
و اگر انجام نشد، فلاسک یک کد 500 خطای داخلی سرور
را برمیگرداند.
headers
زمانی که نمای ثبت نام به نمای ورود به سیستم هدایت می شود، سرصفحه Location
با URL ورود به سیستم خواهد داشت.
data
شامل بدنه پاسخ به صورت بایت است. اگر انتظار دارید مقدار خاصی در صفحه نمایش داده شود، بررسی کنید که در``data`` باشد. بایت ها باید با بایت ها مقایسه شوند. اگر می خواهید متن را با هم مقایسه کنید، به جای آن از get_data(as_text=True)
استفاده کنید.
pytest.mark.parametrize
به Pytest می گوید که همان تابع تست را با آرگومان های مختلف اجرا کند. شما در اینجا از آن برای آزمایش ورودی های نامعتبر مختلف و پیام های خطا بدون نوشتن یک کد سه بار استفاده می کنید.
تست های نمای login
بسیار شبیه به register
است. به جای آزمایش داده ها در پایگاه داده، session
باید پس از ورود به سیستم، user_id
را تنظیم کند.
def test_login(client, auth):
assert client.get('/auth/login').status_code == 200
response = auth.login()
assert response.headers["Location"] == "/"
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
استفاده از client
در بلوک with
امکان دسترسی به متغیرهای زمینه مانند session
را پس از بازگشت پاسخ فراهم می کند. به طور معمول، دسترسی به session
در خارج از یک درخواست باعث بروز خطا می شود.
آزمایش``logout`` برعکس login
است. sesion
نباید پس از خروج از سیستم حاوی user_id
باشد.
def test_logout(client, auth):
auth.login()
with client:
auth.logout()
assert 'user_id' not in session
بلاگ¶
همه نماهای وبلاگ از فیکسچر auth
استفاده می کنند که قبلا نوشتید. auth.login()
را فراخوانی کنید و درخواستهای بعدی مشتری بهعنوان کاربر test
وارد سیستم میشود.
نمای index
باید اطلاعات مربوط به پستی را که با داده های آزمایشی اضافه شده است نمایش دهد. هنگامی که به عنوان نویسنده وارد سیستم شوید، باید پیوندی برای ویرایش پست وجود داشته باشد.
همچنین میتوانید برخی از رفتارهای احراز هویت را در حین آزمایش نمای index
آزمایش کنید. هنگامی که وارد سیستم نمی شوید، هر صفحه پیوندهایی برای ورود یا ثبت نام نشان می دهد. هنگام ورود به سیستم، پیوندی برای خروج وجود دارد.
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
باید وارد سیستم شود. کاربر وارد شده باید نویسنده پست باشد تا به update
و delete
دسترسی پیدا کند، در غیر این صورت وضعیت ۴۰۴ ممنوعه
برگردانده میشود. اگر post
با id
داده شده وجود نداشته باشد، update
و delete
باید ۴۰۴ یافت نشد
را برگردانند.
@pytest.mark.parametrize('path', (
'/create',
'/1/update',
'/1/delete',
))
def test_login_required(client, path):
response = client.post(path)
assert response.headers["Location"] == "/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
نماهای create
و update
باید وضعیت ۲۰۰ خوبه
را برای درخواست GET
ارائه کرده و برگردانند. وقتی دادههای معتبر در یک درخواست POST
ارسال میشود، create
باید دادههای پست جدید را در پایگاه داده وارد کند و update
باید دادههای موجود را تغییر دهد. هر دو صفحه باید یک پیام خطا در مورد داده های نامعتبر نشان دهند.
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
باید به URL فهرست هدایت شود و پست دیگر در پایگاه داده وجود نداشته باشد.
def test_delete(client, auth, app):
auth.login()
response = client.post('/1/delete')
assert response.headers["Location"] == "/"
with app.app_context():
db = get_db()
post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
assert post is None
اجرای تست ها¶
Some extra configuration, which is not required but makes running tests with coverage
less verbose, can be added to the project's pyproject.toml
file.
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
branch = true
source = ["flaskr"]
برای اجرای تست ها از دستور pytest
استفاده کنید. تمام توابع تستی که نوشته اید را پیدا کرده و اجرا می کند.
$ pytest
========================= test session starts ==========================
platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
rootdir: /home/user/Projects/flask-tutorial
collected 23 items
tests/test_auth.py ........ [ 34%]
tests/test_blog.py ............ [ 86%]
tests/test_db.py .. [ 95%]
tests/test_factory.py .. [100%]
====================== 24 passed in 0.64 seconds =======================
اگر هر آزمایشی با شکست مواجه شود، pytest خطای ایجاد شده را نشان می دهد. شما می توانید pytest -v
را اجرا کنید تا لیستی از هر تابع تست به جای نقطه دریافت کنید.
برای اندازهگیری پوشش کد تستهای خود، از دستور coverage
برای اجرای pytest به جای اجرای مستقیم آن استفاده کنید.
$ coverage run -m pytest
می توانید یک گزارش پوشش ساده را در ترمینال مشاهده کنید:
$ coverage report
Name Stmts Miss Branch BrPart Cover
------------------------------------------------------
flaskr/__init__.py 21 0 2 0 100%
flaskr/auth.py 54 0 22 0 100%
flaskr/blog.py 54 0 16 0 100%
flaskr/db.py 24 0 4 0 100%
------------------------------------------------------
TOTAL 153 0 44 0 100%
گزارش HTML به شما اجازه می دهد تا ببینید که کدام خطوط در هر فایل پوشش داده شده است:
$ coverage html
این فایل ها را در دایرکتوری htmlcov
تولید می کند. htmlcov/index.html
را در مرورگر خود باز کنید تا گزارش را ببینید.
با استقرار برای کار(Deploy to Production) ادامه دهید.