پوشش تست

نوشتن تست‌های واحد برای برنامه به شما امکان می‌دهد بررسی کنید که کدی که نوشته‌اید همان‌طور که انتظار دارید کار می‌کند. فلاسک یک سرویس گیرنده آزمایشی ارائه می دهد که درخواست ها را به برنامه شبیه سازی می کند و داده های پاسخ را برمی گرداند.

شما باید تا حد امکان کد خود را آزمایش کنید. کد در توابع تنها زمانی اجرا می‌شود که تابع فراخوانی شود و کد در شاخه‌ها، مانند بلوک‌های``if``، تنها زمانی اجرا می‌شود که شرط برقرار باشد. شما می خواهید مطمئن شوید که هر تابع با داده هایی که هر شاخه را پوشش می دهد آزمایش می شود.

هرچه به پوشش ۱۰۰ درصدی نزدیک‌تر شوید، راحت‌تر خواهید بود که ایجاد تغییر به‌طور غیرمنتظره رفتارهای دیگر را تغییر نمی‌دهد. با این حال، پوشش ۱۰۰ درصدی تضمین نمی کند که برنامه شما باگ نداشته باشد. به ویژه، نحوه تعامل کاربر با برنامه در مرورگر را آزمایش نمی کند. با وجود این، پوشش تست ابزار مهمی برای استفاده در طول توسعه است.

توجه

این در اواخر آموزش معرفی شده است، اما در پروژه های آینده خود باید همزمان با توسعه آزمایش کنید.

برای آزمایش و اندازه گیری کد خود از pytest و coverage استفاده خواهید کرد. هر دو را نصب کنید:

$ pip install pytest coverage

راه اندازی و لوازم

کد تست در دایرکتوری tests قرار دارد. این دایرکتوری کنار بسته flaskr است، نه داخل آن. فایل tests/conftest.py حاوی توابع راه اندازی به نام fixtures است که هر تست از آنها استفاده خواهد کرد. تست‌ها در ماژول‌های پایتون هستند که با 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 را فراخوانی می کند و به جای استفاده از پیکربندی توسعه محلی شما، "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 به فلاسک می گوید که برنامه در حالت تست است. فلاسک برخی از رفتارهای داخلی را تغییر می‌دهد، بنابراین آزمایش آن آسان‌تر است، و سایر برنامه‌های افزودنی نیز می‌توانند از پرچم برای آسان‌تر کردن آزمایش آن‌ها استفاده کنند.

ابزار client fixture calls app.test_client() را با شی برنامه ایجاد شده توسط فیکسچر app فراخوانی می کند. تست‌ها از کلاینت برای ارسال درخواست به برنامه بدون اجرای سرور استفاده می‌کنند.

فیکسچر runner شبیه client است. app.test_cli_runner() یک runner ایجاد می کند که می تواند دستورات Click ثبت شده در برنامه را فراخوانی کند.

Pytest از ابزار ها با تطبیق نام توابع آنها با نام آرگومان ها در توابع تست استفاده می کند. به عنوان مثال، تابع test_hello که در ادامه می نویسید، آرگومان client را می گیرد. Pytest آن را با تابع ثابت client مطابقت می دهد، آن را فراخوانی می کند و مقدار بازگشتی را به تابع تست ارسال می کند.

کارخانه

چیز زیادی برای آزمایش در مورد خود کارخانه وجود ندارد. بیشتر کدها برای هر تست قبلاً اجرا می‌شوند، بنابراین اگر چیزی ناموفق باشد، تست‌های دیگر متوجه خواهند شد.

تنها رفتاری که می تواند تغییر کند گذراندن تنظیمات آزمایشی است. اگر پیکربندی تنظیم نشد، باید تنظیمات پیش فرض وجود داشته باشد، در غیر این صورت پیکربندی باید لغو شود.

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!'

هنگام نوشتن کارخانه در ابتدای آموزش مسیر 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 را فراخوانی کند و پیامی را ارسال کند.

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

این تست از ابزار Pytest monkeypatch برای جایگزینی تابع init_db با تابعی استفاده می‌کند که فراخوانی آن را ثبت می‌کند. فیکسچر runner که در بالا نوشتید برای فراخوانی دستور init-db با نام استفاده می‌شود.

احراز هویت

برای اکثر بازدیدها، یک کاربر نیاز به ورود به سیستم دارد. ساده ترین راه برای انجام این کار در تست ها این است که یک درخواست POST به نمای login با کلاینت ارسال کنید. به جای اینکه هر بار آن را بنویسید، می‌توانید یک کلاس با روش‌هایی برای انجام آن بنویسید و از یک ابزار برای پاس کردن آن به مشتری برای هر آزمون استفاده کنید.

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، می توانید در یک آزمایش با auth.login() تماس بگیرید تا به عنوان کاربر test وارد شوید، که به عنوان بخشی از داده های تست در app درج شده است.

نمای register باید با موفقیت در GET ارائه شود. در POST با داده‌های فرم معتبر، باید به URL ورود به سیستم هدایت شود و داده‌های کاربر باید در پایگاه داده باشد. داده های نامعتبر باید پیام های خطا را نمایش دهند.

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 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 را تنظیم کند.

tests/test_auth.py
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 باشد.

tests/test_auth.py
def test_logout(client, auth):
    auth.login()

    with client:
        auth.logout()
        assert 'user_id' not in session

بلاگ

همه نماهای وبلاگ از فیکسچر auth استفاده می کنند که قبلا نوشتید. auth.login() را فراخوانی کنید و درخواست‌های بعدی مشتری به‌عنوان کاربر test وارد سیستم می‌شود.

نمای index باید اطلاعات مربوط به پستی را که با داده های آزمایشی اضافه شده است نمایش دهد. هنگامی که به عنوان نویسنده وارد سیستم شوید، باید پیوندی برای ویرایش پست وجود داشته باشد.

همچنین می‌توانید برخی از رفتارهای احراز هویت را در حین آزمایش نمای index آزمایش کنید. هنگامی که وارد سیستم نمی شوید، هر صفحه پیوندهایی برای ورود یا ثبت نام نشان می دهد. هنگام ورود به سیستم، پیوندی برای خروج وجود دارد.

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 باید وارد سیستم شود. کاربر وارد شده باید نویسنده پست باشد تا به update و delete دسترسی پیدا کند، در غیر این صورت وضعیت ۴۰۴ ممنوعه برگردانده می‌شود. اگر post با id داده شده وجود نداشته باشد، update و delete باید ۴۰۴ یافت نشد را برگردانند.

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"] == "/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 باید داده‌های موجود را تغییر دهد. هر دو صفحه باید یک پیام خطا در مورد داده های نامعتبر نشان دهند.

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 باید به URL فهرست هدایت شود و پست دیگر در پایگاه داده وجود نداشته باشد.

tests/test_blog.py
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.

pyproject.toml
[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) ادامه دهید.