בדיקות Python באמצעות PyTest


ספריית pytest נכתבה כתחליף למודול unittest הפנימי של פייתון והיא מציעה מנגנון מצד אחד פשוט יותר ומצד שני עם הרבה יותר יכולות בהשוואה למודול הסטנדרטי.

1. אודות הספריה

פייטסט היא ספריית בדיקות וותיקה של פייתון שנכתבה במטרה לספק ממשק טוב יותר לכתיבת בדיקות. לאורך השנים היא עברה הרבה שינויים וכיום נכתבת ומתוחזקת על ידי מתנדבים בכל העולם. החל מגירסא 5 של הספריה היא תומכת אך ורק בפייתון 3 ומעלה, אבל גירסא 4.6 עדיין מתוחזקת ומספקת תמיכה בבדיקות לפייתון 2.

האג'נדה של פייטסט מאוד פשוטה - היא רוצה לתת לכם את הממשק הכי פשוט כדי להתחיל לכתוב בדיקות, ועם כמה שיותר אפשרויות הרחבה מוחבאות מתחת למכסה המנוע. פייטסט מצליחה לעשות את זה באמצעות ארכיטקטורה של פלאגינים (עליה לא נדבר בוובינר זה).

העובדה שפייטסט כולה כתובה בתור פלאגינים הופכת אותה לקלה מאוד להרחבה ואכן מתכנתים בכל העולם כותבים היום פלאגינים לפייטסט. אנחנו כן נתקין ונראה מספר פלאגינים מעניינים שיעזרו לנו בכתיבת הבדיקות.

2. מבנה תוכנית בדיקה

בלי לבזבז יותר מדי זמן על הקדמות בואו נקפוץ ישר לקוד ולכתיבת פונקציה ותוכנית בדיקה עבורה ב pytest. האמת שזה ממש פשוט.

אני יוצר פרויקט פייתון חדש ובו תיקייה בשם lib ובתוכה הקובץ utils.py. הקובץ מכיל בסך הכל שתי פונקציות:

def twice(x):
    return x * 2


def thrice(x):
    return x * 3

נרצה לכתוב תוכנית שמוודאת את נכונות הפונקציות ולכן נפתח תיקיה חדשה בתיקיית הפרויקט בשם tests ובתוכה קובץ בשם test_utils.py. בקובץ רישמו את התוכן הבא:

import lib.utils as utils


def test_twice_1():
    assert utils.twice(10) == 20, f"twice(10) != 20"

def test_twice_2():
    assert utils.twice(0) == 0, f"twice(0) != 0"

def test_twice_3():
    assert utils.twice(-5) == -10, f"twice(-5) != -10"


def test_thrice_1():
    assert utils.thrice(10) == 30, f"thrice(10) != 30"


def test_thrice_2():
    assert utils.thrice(0) == 0, f"thrice(0) != 0"


def test_thrice_3():
    assert utils.thrice(-5) == -15, f"thrice(-5) != -15"

קוד הבדיקה של pytest מסודר בפונקציות כאשר כל פונקציה בודקת דבר מסוים. נהוג לכתוב assert יחיד בתוך כל פונקציית בדיקה (למרות שאפשר גם לכתוב יותר). פייטסט תעצור את ריצת פונקציית הבדיקה ב assert הראשון שנכשל בדיוק בשביל שנארגן את הבדיקות כך שכל בדיקה בודקת דבר אחד בלבד. זאת הסיבה שבקוד הבא תודפס רק הודעת ההדפסה הראשונה ולא השניה:

def test_twice_1():
    print("aaa")
    assert False
    print("bbb")
    assert utils.twice(10) == 20, f"twice(10) != 20"

בניגוד למה שאולי אתם מכירים מספריות בדיקות אחרות (ומ unittest עצמה של פייתון), בפייטסט יש רק פקודת assert אחת. באופן אוטומטי פקודה זו מסתכלת על הפרמטרים שהעברתם לה ומנסה להדפיס את הודעת השגיאה הכי אינפורמטיבית שאפשר, ויודעת להתמודד עם כל סוג השוואה. אנחנו כן יכולים לכתוב פונקציות עזר לבדיקה שיקראו בפנים ל assert כשאנחנו מזהים קוד משותף בין מספר בדיקות.

לדוגמא בתוך קוד הבדיקה אני יכול לכתוב פונקצית עזר שמקבלת פונקציה, קלט ופלט ומוודאת שהם שווים ואז להשתמש בה בקוד הבדיקה:

def assert_fx_equals_y(f, x, y):
    assert f(x) == y

def test_twice_1():
    assert_fx_equals_y(utils.twice, 10, 20)

אבל הרבה פעמים נעדיף לכתוב פונקציה בוליאנית ולהשתמש ב assert בתוך קוד הבדיקה כדי לקבל קוד נקי וברור יותר:

def fx_equals_y(f, x, y):
    return f(x) == y

def test_twice_1():
    assert fx_equals_y(utils.twice, 10, 20)

3. הרצת הבדיקות (מ PyCharm ומשורת הפקודה)

בשביל להריץ את הבדיקות משורת הפקודה מספיק להשתמש בפקודה הבאה:

python -m pytest tests/test_utils.py

אם לא נציין את שם הקובץ באופן אוטומטי פייטסט יחפש את תיקיות וקבצי הבדיקה בפרויקט לפי שמות הקבצים: קבצים שמתחילים במילה test_ או מסתיימים ב _test ונמצאים בתיקיה בשם tests נחשבים בתור קבצי בדיקה.

בתוך הקובץ פונקציות שמתחילות ב test_ נחשבות בתור פונקציות בדיקה ויופעלו אוטומטית.

אפשר לציין שם בדיקה בצורה מפורשת עם הפרמטר -k וכך הפקודה הבאה תפעיל רק את הבדיקות שבשמן מופיע הביטוי twice_1:

python -m pytest tests/test_utils.py -k twice_1

מתוך PyCharm יהיה לנו נוח להפעיל את הבדיקות באמצעות לחיצה על כפתור ימני על תיקיית בדיקות או קובץ בדיקה ובחירת האפשרות Run. בשביל להפעיל את התמיכה ב pytest מתוך פרויקט ב PyCharm עליכם תחילה להיכנס להגדרות הפרויקט, לבחור Tools ושם לבחור Python Integrated Tools ואז לבחור ב default test runner את pytest.

4. פרמטרים לבדיקה

פייטסט תומכת בהרצת בדיקה מספר פעמים עם פרמטרים שונים ולכן לא באמת צריך לכתוב את כל הקוד הכפול בקובץ הבדיקה שיצרנו. הרבה יותר נוח יהיה ליצור שתי פונקציות, אחת לבדיקת twice והשניה לבדיקת thrice, ולבקש מ pytest להריץ אותן באופן אוטומטי מספר פעמים עם קלטים שונים.

במודול pytest.mark נמצא מספר דקורייטורס שעוזרים לנו לשלוט על קוד הבדיקות. הדקורטור הרלוונטי לפרמטרים נקרא pytest.mark.parametrize ונשתמש בו כך:

@pytest.mark.parametrize(
    'input,expected_output',
    [
        (10, 20),
        (0, 0),
        (-5, -10),
     ]
)
def test_twice_1(input, expected_output):
    assert utils.twice(input) == expected_output

הפרמטר הראשון של parametrize הוא שמות הפרמטרים כמו שהפונקציה מצפה לקבל אותם אז שימו לב שזה חייב להתאים למה שכתבתם בשורת הפעלת הפונקציה. הפרמטר השני הוא מערך של Tuples שכל אחד מהם מכיל את הפרמטרים בסדר המתאים.

בפלט הבדיקה נראה שהבדיקה מופיעה שלוש פעמים, כל פעם עם פרמטרים שונים.

5. שינוי תלויות (Monkey Patching)

נעבור לכתיבת בדיקות קצת יותר מורכבות הפעם של קוד שמשתמש בקלט ופלט. פייטסט יתפוס את הפלט הסטנדרטי באופן אוטומטי וייתן לנו לקרוא ממנו אז נתחיל עם פונקציה שמדפיסה למסך את הטקסט Hello World.

פתחו קובץ חדש בשם greeter.py ובו כתבו את התוכן הבא:

class Greeter:
    def hi(self):
        print("Hello World")

בשביל לבדוק את המחלקה נפתח קובץ בדיקה בשם test_greeter.py בתוך תיקיית הבדיקות. בגלל שהפונקציה המקורית hi מדפיסה למסך, לא מספיק לי הפעם להפעיל קוד ולבצע assert על התוצאה. ל print אין תוצאה וממילא מה שמעניין אותי הוא תוצאת הלוואי שלה, כלומר הטקסט שהופיע על המסך.

אנחנו יכולים לבקש מפייטסט לרמות את print ולהעביר אלינו את כל הדברים שנכתבו למסך באמצעות שינוי sys.stdout, והאמת שאפילו לא צריך לבקש. פייטסט יעשה את זה בצורה אוטומטית. הקוד הבא כולל אלמנט חדש וקצת מפתיע דרכו אנחנו בודקים את הטקסט:

def test_greeter_text(capfd):
    g = Greeter()
    g.hi()
    outerr = capfd.readouterr()
    text = outerr.out
    assert text == "Hello World\n"

שימו לב לפרמטר capfd שהעברתי לפונקציית הבדיקה: פרמטר זה נקרא Fixture ואנחנו נדבר עליהם יותר לעומק ממש בחלק הבא. בינתיים מספיק לדעת ש Fixture הוא הדרך של פייטסט לכתוב קוד איתחול שקורה לפני הבדיקה ומשפיע עליה באיזושהי צורה. קוד האיתחול בדרך כלל מעביר לבדיקה את האוביקט שהוא יצר או אוביקט איתו הבדיקה יכולה לתקשר עם מה שקרה.

במקרה של capfd זהו Fixture שתופס את כל הפלט הסטנדרטי ופלט השגיאות של התוכנית. הפונקציה readouterr מחזירה לנו העתק של פלט זה ואנחנו יכולים להשתמש במידע כדי לבדוק שאכן הודפסו הדברים שציפינו.

מה שמעניין כאן שמספיק לציין את שם הפרמטר capfd כדי לקבל את ה Fixture. ובאופן כללי הארכיטקטורה של Fixtures היא שבדיקה יכולה לבקש קוד איתחול מסוים לפי שמו, ובשביל זה היא מציינת את שם ה Fixture בתור פרמטר.

הפיקצ'ר הבא שנדבר עליו נקרא monkeypatch והוא יאפשר לנו להתמודד עם הפונקציה הבאה של המחלקה Greeter:

class Greeter:
    def hi(self):
        print("Hello World")

    def years_to_months(self):
        print("How old are you (in years)? ")
        age_in_years = input("> ")
        age_in_months = age_in_years * 12
        print(f"Wow that's {age_in_months} months old")

חדי העין שביניכם יכולים כבר לראות שיש טעות בקוד, אבל אנחנו נחכה קצת לבדיקה כדי לזהות את הטעות. השאלה היא איך מעבירים קלט סטנדרטי לבדיקה?

בניגוד לפלט הסטנדרטי, כאן פייטסט לא עוזר לנו בצורה אוטומטית ואנחנו צריכים בצורה ידנית לשנות את sys.stdin ולהחליף אותו באוביקט שקריאה ממנו תתן את המספר שאנחנו רוצים לתת לבדיקה. בואו נניח שאני רוצה להעביר לתוכנית הבדיקה את המספר 5 כדי לקבל את התוצאה 60 חודשים, אז אני צריך אוביקט שיתנהג כמו sys.stdin אבל קריאה ממנו לא תעצור את התוכנית אלא תחזיר את המספר 5.

כשמדברים על קלט ופלט האוביקט שאנחנו מחפשים נקרא io.StringIO. הוא מתנהג בדיוק כמו קובץ אבל על קלט ופלט קבועים. אני יוצר אחד כזה שיחליף את stdin עם הפקודה:

>>> import io
>>> my_stdin = io.StringIO('5')
>>> my_stdin.readline()
'5'

האתגר הבא הוא להחליף את sys.stdin של התוכנית בזה שאני יצרתי ובאופן אוטומטי, ותשמחו לשמוע שיש לפייטסט פיקצ'ר שעוזר בזה בדיוק - הוא נקרא monkeypatch.

הקוד הבא יבדוק כמו שצריך את Greeter עם דריסת הקלט והפלט:

def test_greeter_age(capfd, monkeypatch):
    monkeypatch.setattr(sys, 'stdin', io.StringIO('5'))
    g = Greeter()
    g.years_to_months()
    assert "60" in capfd.readouterr().out

הפלט לא אמור להפתיע אף אחד ומראה את הכישלון בבדיקה:

    def test_greeter_age(capfd, monkeypatch):
        monkeypatch.setattr(sys, 'stdin', io.StringIO('5'))
        g = Greeter()
        g.years_to_months()
>       assert "60" in capfd.readouterr().out
E       assert '60' in "How old are you (in years)? \n> Wow that's 555555555555 months old\n"
E        +  where "How old are you (in years)? \n> Wow that's 555555555555 months old\n" = CaptureResult(out="How old are you (in years)? \n> Wow that's 555555555555 months old\n", err='').out
E        +    where CaptureResult(out="How old are you (in years)? \n> Wow that's 555555555555 months old\n", err='') = <bound method CaptureFixture.readouterr of <_pytest.capture.CaptureFixture object at 0x10bcc7dc0>>()
E        +      where <bound method CaptureFixture.readouterr of <_pytest.capture.CaptureFixture object at 0x10bcc7dc0>> = <_pytest.capture.CaptureFixture object at 0x10bcc7dc0>.readouterr

ומה שיפה שיש לנו כאן את כל המידע שצריך בשביל להבין מה קרה: הפלט היא המספר 5 שמופיע 12 פעמים במקום תוצאת התרגיל 12 כפול 5, והסיבה היא כמובן טיפוס המשתנה. תיקון הקוד והפעלה מחדש והבדיקה כבר עוברת.

6. כתיבת Fixture

בהיותה ספריה המבוססת על הרחבות pytest מאפשרת גם לכם לכתוב Fixtures בלי בעיה. המבנה של Fixture כולל בגדול:

  1. הפעלת קוד איתחול
  2. העברת התוצאה לפונקציית הבדיקה
  3. הפעלת קוד ניקוי

בקוד זה אומר שאנחנו כותבים פוקנציה שמפעילה את קוד האתחול, קוראת ל yield עם האוביקט שצריך לעבור לפונקציית הבדיקה ולאחר מכן מפעילה את קוד הניקוי. בקוד הבדיקה שלנו למשל אפשר לדמיין Fixture שייצור את אוביקט ה Greeter בשבילנו לפני כל בדיקה:

@pytest.fixture
def greeter():
    # setup code
    g = Greeter()

    # pass the result to the test
    yield g

    # free up resources if needed

אני יכול להשתמש ב Fixture שיצרתי כמו שהשתמשתי בכל ה Fixtures של pytest:

def test_greeter_text(capfd, greeter):
    greeter.hi()
    outerr = capfd.readouterr()
    text = outerr.out
    assert text == "Hello World\n"


def test_greeter_age(capfd, monkeypatch, greeter):
    monkeypatch.setattr(sys, 'stdin', io.StringIO('5'))
    greeter.years_to_months()
    assert "60" in capfd.readouterr().out

אגב אפשר לשתף Fixture בין מספר בדיקות אם נשמור אותו בקובץ בשם conftest.py.

7. דוגמא: בדיקת API חיצוני

חוץ מבדיקת קוד פנימי pytest יכולה לעזור לנו גם להפעיל סט בדיקות על API חיצוני בעזרת המודולים requests או selenium. בואו נראה דוגמא קצרה עם requests לבדיקת הממשק של swapi.co.

השרת swapi.co יודע להחזיר מידע על דמויות ממלחמת הכוכבים, לדוגמא הנתיב:

https://swapi.co/api/people/1/

מחזיר מידע לגבי הדמות הראשית לוק סקייווקר ואפשר לראות שם באיזה סרטים הוא שיחק ומה גובהו.

המודול requests של פייתון יודע לגשת ל URL חיצוני ולמשוך משם אוביקט JSON.

שילוב requests עם pytest מאפשר לנו בקלות לכתוב קוד בדיקה ל API חיצוני. כמה דגשים לפני שנתחיל:

  1. המודול requests מאפשר עבודה עם Sessions. פתיחת מספר בקשות מאותו Session תהיה מהירה יותר מאשר פתיחת Session נפרד לכל בקשה ולכן כדאי לנו ליצור Fixture שיבנה את ה Session.

  2. באופן רגיל Fixture מופעל מחדש לכל פונקציית בדיקה. הפרמטר scope של ה Fixture יאפשר לנו לבקש מ pytest לשמור את אותו Session לכל הבדיקות.

קוד ה Fixture יהיה לכן:

@pytest.fixture(scope='module')
def req():
    with requests.Session() as s:
        s.headers.update({
            'Accept': 'application/json'
        })

        yield s

והבדיקה הראשונה לגבי לוק:

def test_luke(req):
    resp = req.get('https://swapi.co/api/people/1/')
    data = resp.json()
    assert data['name'] == 'Luke Skywalker'

כמובן שאפשר לשחק עם זה - למשל אם אתם רוצים להריץ מספר בדיקות לגבי המידע שמגיע מבקשה מסוימת אפשר לבנות Fixture שיחזיר את התוצאה של אותה בקשה, או להשתמש באוביקט Cache של בקשות. מצד שני אם אתם רוצים שכל בדיקה תשלח בקשה בנפרד המבנה של Fixture עבור ה Session יעבוד טוב.

8. כיסוי קוד

נסיים עם דוגמא להתקנת פלאגין. הפלאגין שבחרתי נקרא pytest-cov והוא עוזר לנו לבדוק Code Coverage לפרויקט. הפלאגין פועל ברובו בצורה אוטומטית ומוסיף דו"ח על כיסוי קוד לתוצאת הבדיקה.

התקינו את הפלאגין דרך הממשק של PyCharm ולאחר מכן הוסיפו עוד פונקציה למחלקה Greeter. זה הקוד המעודכן אצלי:

class Greeter:
    def hi(self):
        print("Hello World")

    def years_to_months(self):
        print("How old are you (in years)? ")
        age_in_years = int(input("> "))
        age_in_months = age_in_years * 12
        print(f"Wow that's {age_in_months} months old")

    def get_name(self):
        print("who are you?")
        self.name = input("> ")

עכשיו נפעיל את הבדיקה ונבקש מפייתון לזהות כמה אחוז מהקוד שלי מכוסה על ידי הבדיקות:

$ python -m pytest --cov

והתוצאה היא דוח שמראה את כל הקבצים בתוכנית ואיזה אחוז מהקובץ מכוסה על ידי כל בדיקה. קל לראות שיש פה הרבה קבצים מיותרים כדוגמת תיקיית הבדיקות עצמה וכל תיקיית venv. האמת ש pytest-cov עובד הרבה יותר טוב כשהפרויקט שלכם נמצא בתוך namespace משלו.

בהנחה שכל הפרויקט שלכם שמור בתיקיה מסוימת, תוכלו להעביר את שם התיקיה כפרמטר ל cov כדי ש cov יבדוק רק את הקבצים שרלוונטים עבורכם. במקרה שלנו יש לנו רק קובץ אחד ולכן אפשר להעביר את שם הקובץ (בלי הסיומת) בתור הפמרטר:

python -m pytest --cov=greeter

והתוצאה כוללת גם רשימה של בדיקות שהופעלו וגם דוח שמראה כמה אחוז מהקוד של greeter מכוסה על ידי בדיקות:

========================================================================================= test session starts ==========================================================================================
platform darwin -- Python 3.8.0, pytest-5.3.1, py-1.8.0, pluggy-0.13.1
rootdir: /Users/ynonperek/work/courses/python/pytest
plugins: xdist-1.30.0, forked-1.1.3, cov-2.8.1
collected 10 items                                                                                                                                                                                     

tests/test_demo1.py .                                                                                                                                                                            [ 10%]
tests/test_greeter.py ..                                                                                                                                                                         [ 30%]
tests/test_swapi.py .                                                                                                                                                                            [ 40%]
tests/test_utils.py ......                                                                                                                                                                       [100%]

---------- coverage: platform darwin, python 3.8.0-final-0 -----------
Name         Stmts   Miss  Cover
--------------------------------
greeter.py      11      2    82%

באמצעות הוספת המתג cov-report אנחנו יכולים לקבל דוחות יותר מפורטים, לדוגמא הדוח הבא מראה איזה שורות לא מכוסות על ידי בדיקות:

(venv) localhost:pytest ynonperek$ python -m pytest --cov=greeter --cov-report term-missing
========================================================================================= test session starts ==========================================================================================
platform darwin -- Python 3.8.0, pytest-5.3.1, py-1.8.0, pluggy-0.13.1
rootdir: /Users/ynonperek/work/courses/python/pytest
plugins: xdist-1.30.0, forked-1.1.3, cov-2.8.1
collected 10 items                                                                                                                                                                                     

tests/test_demo1.py .                                                                                                                                                                            [ 10%]
tests/test_greeter.py ..                                                                                                                                                                         [ 30%]
tests/test_swapi.py .                                                                                                                                                                            [ 40%]
tests/test_utils.py ......                                                                                                                                                                       [100%]

---------- coverage: platform darwin, python 3.8.0-final-0 -----------
Name         Stmts   Miss  Cover   Missing
------------------------------------------
greeter.py      11      2    82%   12-13


========================================================================================== 10 passed in 1.63s ==========================================================================================

9. סיכום pytest

העבודה עם פייטסט מאפשרת לנו להתחיל לכתוב קוד בדיקות לפרויקט מאוד מהר ובלי Boilerplate, ובזכות הארכיטקטורה של הספריה קל מאוד להרחיב אותה ולהתאים אותה לצרכים של כל פרויקט ופרויקט. ל pytest אין כמעט עקומת למידה או חסרונות משמעותיים ולכן הבחירה בו כפריימוורק לבדיקות היא בדרך כלל הימור בטוח.

כל דוגמאות הקוד מהוובינר זמינות גם אונליין בגיטהאב בקישור:

https://github.com/ynonp/pytest-webinar-demos