تست در پایتون

چهارشنبه 12 آذر 1399

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

تست در پایتون

Pytest

در حالی که کتابخانه استاندارد پایتون دارای فریم‌ورک تست واحد به نام unittest است، pytest فریم‌ورک تست برای تست کد پایتون است.

pytest نوشتن، سازماندهی و اجرای تست‌ها را ساده و البته سرگرم‌کننده می‌سازد. در مقایسه با unittest، از کتابخانه استاندارد پایتون، pytest:

1. به کد boilerplate (کدهای پایه پروژه برای اجرا) کمتری نیاز دارد تا تست‌های شما خواناتر باشند.

2. از دستور assert پشتیبانی می‌کند، که در مقایسه با متدهای assertSomething، مانند assertEquals، assertTrue، و assertContains، در unittest بسیار راحت‌تر و خواناتر به خاطر سپرده می‌شود.

3. به دلیل اینکه بخشی از کتابخانه استاندارد پایتون نیست، مرتبا آپدیت می‌شود.

4. از یک رویکرد کاربردی استفاده می‌کند.

به علاوه، با pytest، می‌توانید در همه پروژه‌های پایتون سبک ثابتی داشته باشید. شما دو برنامه وب در stack خود دارید؛ یکی با Django ساخته شده است و دیگری با Flask ساخته شده است. بدون pytest، شما به احتمال زیاد از فریم‌ورک تست Django، به همراه اکستنشن Flask، مانند Flask-Testing  استفاده می‌کنید. بنابراین مجموعه‌های تست شما سبک‌های مختلفی دارند. از طرف دیگر، با pytest، مجموعه‌های تست شما سبک کد ثابتی دارند و پرش از یکی به دیگری را آسان می‌سازد.

Pytest همچنین دارای یک اکوسیستم پلاگین بزرگ و با حفظ جامعه است.

چند نمونه:

pytest-django. مجموعه‌ای از ابزارها را که به طور خاص برای تست برنامه‌های Django ساخته شده‌اند، فراهم می‌کند.

pytest-xdist. برای اجرای تست‌ها به صورت موازی استفاده می‌شود.

pytest-cov. پشتیبانی از پوشش کد (code coverage) را اضافه می‌کند.

pytest-instafail. به جای اینکه تا پایان اجرا منتظر بماند، بلافاصله خطاها و شکست‌ها را نشان می‌دهد.

Mocking

تست‌های خودکار باید سریع، مستقل، قابل تکرار/قطعی باشند. بنابراین اگر به تست کدی نیاز دارید که HTTP request خارجی را برای API شخص ثالث می‌سازد، باید درخواست را mock  کنید. چرا؟ اگر این کار را نکنید، پس آن تست خاص:

1. از آنجایی که HTTP request در شبکه ساخته می‌شود، کند است.

2. به سرویس شخص ثالث و سرعت خود شبکه بستگی دارد.

3. از آنجایی که تست می‌تواند نتیجه متفاوتی را بر اساس پاسخ از API تولید کند، غیرقطعی است.

همچنین ایده خوبی است که سایر عملیات طولانی اجرا را mock کنید، مانند کوئری‌های دیتابیس و taskهای async، زیرا تست‌های خودکار به طور مکرر، و در هر کامیت push که به سورس کنترل داده می‌شود، اجرا می‌شوند.

Mocking تمرین جایگزینی آبجکت‌های واقعی با آبجکت‌های mock شده است، که در زمان اجرا رفتار آن‌ها را تقلید می‌کند. بنابراین به جای HTTP request واقعی از طریق شبکه، وقتی متد mocked فراخوانی می‌شود ما فقط پاسخ مورد انتظار را برمی‌گردانیم.

مثلا:

import requests


def get_my_ip():
    response = requests.get(
        'http://ipinfo.io/json'
    )
    return response.json()['ip']


def test_get_my_ip(monkeypatch):
    my_ip = '123.123.123.123'

    class MockResponse:

        def __init__(self, json_body):
            self.json_body = json_body

        def json(self):
            return self.json_body

    monkeypatch.setattr(
        requests,
        'get',
        lambda *args, **kwargs: MockResponse({'ip': my_ip})
    )

    assert get_my_ip() == my_ip

در اینجا چه اتفاقی می‌افتد؟

ما از monkeypatch fixture ی pytest استفاده کردیم تا همه فراخوانی‌ها برای متد get از ماژول requests با lambda callback جایگزین کنیم که همیشه نمونه‌ای از MockedResponse را برمی‌گرداند.

ما از آبجکت استفاده کردیم زیرا requests آبجکت Response را برمی‌گرداند.

ما می‌توانیم تست‌ها را با استفاده از متد create_autospec از ماژول unittest.mock ساده کنیم. این متد یک آبجکت mock را با همان پراپرتی‌ها و متدها همان‌طور که آبجکت به عنوان پارامتر ارسال می‌شود ایجاد می‌کند:

from unittest import mock

import requests
from requests import Response


def get_my_ip():
    response = requests.get(
        'http://ipinfo.io/json'
    )
    return response.json()['ip']


def test_get_my_ip(monkeypatch):
    my_ip = '123.123.123.123'
    response = mock.create_autospec(Response)
    response.json.return_value = {'ip': my_ip}

    monkeypatch.setattr(
        requests,
        'get',
        lambda *args, **kwargs: response
    )

    assert get_my_ip() == my_ip

اگرچه pytest رویکرد monkeypatch را برای mocking توصیه می‌کند، اکستنشن pytest-mock و کتابخانه vanilla unittest.mock از کتابخانه استاندارد نیز رویکردهای خوبی هستند.

Code Coverage

یکی دیگر از جنبه‌های مهم تست‌ها پوشش کد (code coverage) است. این یک معیار است که نسبت بین تعداد خطوط اجرا شده در طول اجرای تست و تعداد کل همه خطوط در کد پایه (code base) را به شما می‌گوید. برای این کار می‌توانید از پلاگین pytest-cov استفاده کنید، که Coverage.py را با pytest ادغام می‌کند.

پس از نصب، برای اجرای تست‌ها با گزارش پوشش، گزینه cov- - را اضافه کنید، مثل:

$ python -m pytest --cov=.

خروجی مثل این را تولید می‌کند:

================================== test session starts ==================================
platform linux -- Python 3.7.9, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
rootdir: /home/johndoe/sample-project
plugins: cov-2.10.1
collected 6 items

tests/test_sample_project.py ....                                             [ 66%]
tests/test_sample_project_mock.py .                                           [ 83%]
tests/test_sample_project_mock_1.py .                                         [100%]

----------- coverage: platform linux, python 3.7.9-final-0 -----------
Name                                  Stmts   Miss  Cover
---------------------------------------------------------
sample_project/__init__.py                1      1     0%
tests/__init__.py                         0      0   100%
tests/test_sample_project.py              5      0   100%
tests/test_sample_project_mock.py        13      0   100%
tests/test_sample_project_mock_1.py      12      0   100%
---------------------------------------------------------
TOTAL                                    31      1    97%


==================================  6 passed in 0.13s ==================================

برای هر فایل در مسیر پروژه موارد زیر را دریافت می‌کنید:

Stmts. تعداد خطوط کد

Miss. تعداد خطوطی که توسط تست‌ها اجرا می‌شوند

Cover. درصد پوشش فایل

در پایان، یک خط با مجموع کل پروژه وجود دارد.

به خاطر داشته باشید که اگرچه توصیه می‌شود تا درصد پوشش بالایی را به دست آورید، این بدان معنا نیست که تست‌های شما تست‌های خوبی هستند، هر یک از مسیرهای دارای اکسپشن و موارد مورد رضایت را تست کنید. مثلا assert sum(3, 2) == 5 می‌تواند درصد پوشش بالایی را به دست آورد اما کد شما هنوز عملا تست نشده است زیرا مسیرهای اکسپشن تحت پوشش قرار نگرفته‌اند.

Hypothesis

Hypothesis کتابخانه‌ای برای مدیریت تست‌های مبتنی بر پراپرتی در پایتون است. به جای اینکه برای هر آرگومانی که می‌خواهید تست کنید، موارد تست مختلفی بنویسید، تست مبتنی بر پراپرتی دامنه وسیعی از داده‌های تست رندم را ایجاد می‌کند که به اجرای تست‌های قبلی بستگی دارد. این کتابخانه به شما کمک می‌کند تا استحکام مجموعه تست خود را افزایش دهید و در عین حال افزونگی تست را کاهش دهید. به طور خلاصه، کد تست شما تمیزتر، تکرار کمتر، و به طور کلی کارآمدتر خواهد بود در حالی که هنوز طیف گسترده‌ای از داده‌های تست را پوشش می‌دهد.

مثلا، شما باید تست‌ها را برای تابع زیر بنویسید:

def increment(num: int) -> int:
    return num + 1

می‌توانید تست زیر را بنویسید:

import pytest


@pytest.mark.parametrize(
    'number, result',
    [
        (-2, -1),
        (0, 1),
        (3, 4),
        (101234, 101235),
    ]
)
def test_increment(number, result):
    assert increment(number) == result

این روش مشکلی ندارد. کد شما تست شده و code coverage بالا است. کد شما بر اساس دامنه ورودی‌های ممکن چقدر خوب تست شده است؟ تعداد زیادی عدد صحیح وجود دارد که می‌توانند تست شوند، اما فقط چهار مورد از آن‌ها در تست استفاده می‌شوند. در بعضی شرایط این کافی است. در شرایط دیگر، چهار مورد کافی است، یعنی کد machine learning غیر قطعی. در مورد اعداد واقعا کوچک و بزرگ چطور؟ یا بگویید تابع شما لیستی از اعداد صحیح را می‌گیرد. اگر لیست خالی باشد یا حاوی یک عنصر، صدها عنصر، یا هزاران عنصر باشد چطور؟ در برخی شرایط ما به سادگی نمی‌توانیم همه موارد ممکن را فراهم کنیم. اینجاست که تست مبتنی بر پراپرتی وارد عمل می‌شود.

الگوریتم‌های Machine learning استفاده بسیار خوبی برای تست مبتنی بر پراپرتی هستند زیرا تولید (و نگهداری) نمونه‌های تست برای مجموعه‌های پیچیده داده دشوار است.

فریم‌ورک‌هایی مانند Hypothesis دستورالعمل‌هایی (به نام Strategies) برای تولید داده‌های تست تصادفی ارائه می‌دهند. Hypothesis همچنین نتایج اجراهای قبلی تست را ذخیره می‌کند و از آن‌ها برای ایجاد موارد جدید استفاده می‌کند.

Strategies الگوریتم‌هایی هستند که بر اساس شکل داده‌های ورودی، داده‌های تصادفی ساختگی تولید می‌کنند. این داده‌های رندم ساختگی است زیرا داده‌های تولید شده بر اساس داده‌های تست‌های قبلی ساخته شده است.

همان تست با استفاده از تست مبتنی بر پراپرتی از طریق Hypothesis اینگونه به نظر می‌رسد:

from hypothesis import given
import hypothesis.strategies as st


@given(st.integers())
def test_add_one(num):
    assert increment(num) == num - 1

()st.integers یک Hypothesis Strategy است که اعداد صحیح تصادفی را برای تست تولید می‌کند در حالی که given@ برای پارامترسازی تابع تست استفاده می‌شود. بنابراین وقتی تابع تست فراخوانی می‌شود، اعداد صحیح تولید شده، از Strategy، به تست ارسال می‌شوند.

$ python -m pytest test_hypothesis.py --hypothesis-show-statistics

================================== test session starts ===================================
platform darwin -- Python 3.8.5, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
rootdir: /home/johndoe/sample-project
plugins: hypothesis-5.37.3
collected 1 item

test_hypothesis.py .                                                               [100%]
================================= Hypothesis Statistics ==================================

test_hypothesis.py::test_add_one:

  - during generate phase (0.06 seconds):
    - Typical runtimes: < 1ms, ~ 50% in data generation
    - 100 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because settings.max_examples=100


=================================== 1 passed in 0.08s ====================================

بررسی کردن تست

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

checkers یا همان بررسی‌کننده‌های Runtime (یا داینامیک)، مانند Typeguard و pydantic، می‌توانند به به حداقل رساندن تعداد تست‌ها کمک کنند.

جمع‌بندی

تست اغلب می‌تواند یک کار ترسناک باشد، اما امیدوارم که این مقاله ابزارهایی را ارائه داده باشد که شما بتوانید برای سهولت تست از آن‌ها استفاده کنید. بر روی تست‌های خود متمرکز شوید. تست‌های شما باید همچنین سریع، مستقل، و قطعی/قابل تکرار باشد. در نهایت، داشتن اعتماد به نفس در مجموعه تست‌هایتان به شما کمک می‌کند تا محصول خود را مهم‌تر و بهتر تولید کنید، و شب‌ها به راحتی بخوابید.

برنامه نویسان

نویسنده 3355 مقاله در برنامه نویسان
  • Python
  • 3k بازدید
  • 2 تشکر

کاربرانی که از نویسنده این مقاله تشکر کرده اند

در صورتی که در رابطه با این مقاله سوالی دارید، در تاپیک های انجمن مطرح کنید