• בלוג
  • איך לשפר את קוד הבדיקות ב pytest שלכם באמצעות Fixtures

איך לשפר את קוד הבדיקות ב pytest שלכם באמצעות Fixtures

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

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

1. מהו Fixture

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

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

נמשיך למספר דוגמאות כדי לראות איך זה עובד.

2. דוגמא 1: יצירת תיקיה ריקה

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

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

המחלקה שאני רוצה לבדוק נראית כך:

class Calculator:
    def __init__(self, filename):
        self.filename = filename

    def add(self, x, y):
        self._report(f'{x} + {y} = {x + y}')

    def sub(self, x, y):
        self._report(f'{x} - {y} = {x - y}')

    def _report(self, line):
        with open(self.filename) as fout:
            fout.write(line)
            fout.write('\n')

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

הפקודה tempfile.TemporaryDirectory יוצרת בשבילנו תיקיה זמנית וגם תמחק אותה בסוף התוכנית. אפשר להשתמש בה לפני הבדיקה שלנו בתור קוד אתחול ולכן ניצור Fixture עבורה, והקוד נראה כך:

@pytest.fixture()
def tempdir():
    previous_dir = os.getcwd()
    with tempfile.TemporaryDirectory() as d:
        os.chdir(d)
        yield d

    os.chdir(previous_dir)

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

בשביל להשתמש ב Fixture אציין את שמו בתור פרמטר בחתימת הבדיקה באופן הבא:

def test_add(tempdir):
    c = Calculator('output.txt')
    c.add(10, 20)
    text_string = Path('output.txt').read_text()
    assert text_string == "10 + 20 = 30\n"

זה עבד ממש טוב ואפילו מצליח להריץ את הבדיקה ולהיכשל:

$ python -m pytest tests/fixtures_demos.py 

self = <lib.calculator.Calculator object at 0x1071273a0>, line = '10 + 20 = 30'

    def _report(self, line):
>       with open(self.filename) as fout:
E       FileNotFoundError: [Errno 2] No such file or directory: 'output.txt'

נראה שהתוכנית שלי אפילו לא יצרה את הקובץ - מזל שתפסנו את זה עכשיו לפני שהעלינו גירסא לפרודקשן! נחזור לקוד התוכנית ונתקן את הפונקציה _report לקוד הבא:

    def _report(self, line):
        with open(self.filename, 'w') as fout:
            fout.write(line)
            fout.write('\n')

ובהרצה הבאה של הבדיקה הכל כבר מסתדר.

נשים לב שאם ננסה להוסיף בדיקה נוספת הפעם בלי להפעיל את הפונקציות של Calculator הבדיקה תיכשל עם הודעה שהקובץ לא קיים (למרות שרק לפני רגע בדיקה קודמת יצרה אותו):

def test_no_previous_output(tempdir):
    text_string = Path('output.txt').read_text()
    assert text_string == "10 + 20 = 30\n"

אגב במצבים כאלה שאנחנו כותבים בדיקה ויודעים שהיא הולכת להיכשל משתלם לסמן את הבדיקה בתור כישלון ידוע מראש וכך pytest יכול לדווח על הכישלון בתור הצלחה:

@pytest.mark.xfail
def test_no_previous_output(tempdir):
    text_string = Path('output.txt').read_text()
    assert text_string == "10 + 20 = 30\n"

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

3. דוגמא 2: יצירת בסיס נתונים חדש לבדיקות

כמו שיצרנו תיקיה חדשה אפשר להשתמש ב SQLite3 כדי ליצור בסיס נתונים חדש לצרכי בדיקות. בעזרת monkeypatch אני יכול לדרוס את החלק בקוד התוכנית שיוצר את בסיס הנתונים וכך להריץ את הפונקציה הספציפית שאני בודק בדיוק בסביבה שאני צריך.

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

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

הקוד אחרי התיקון נראה כך:

class Calculator:
    def __init__(self, filename=None, *args, reporter=None):
        if reporter is not None:
            self.reporter = reporter
        elif filename is not None:
            self.reporter = FileReporter(filename)
        else:
            raise Exception("Must specifiy either a file name or a reporter")

    def add(self, x, y):
        self._report(f'{x} + {y} = {x + y}')

    def sub(self, x, y):
        self._report(f'{x} - {y} = {x - y}')

    def _report(self, line):
        self.reporter.report(line)


class FileReporter:
    def __init__(self, filename):
        self.filename = filename

    def report(self, line):
        with open(self.filename, 'w') as fout:
            fout.write(line)
            fout.write('\n')

ואני יכול להריץ את הבדיקות ולראות שההתנהגות הקודמת עדיין עובדת כמו שצריך.

נמשיך לכתיבת ה Reporter שכותב לבסיס הנתונים. הקוד עשוי להיראות כך:

class DBReporter:
    def __init__(self, db):
        self.db = db
        self.cursor = self.db.cursor()
        self.cursor.execute("CREATE TABLE IF NOT EXISTS log (ID INTEGER PRIMARY KEY AUTOINCREMENT, message TEXT)")

    def report(self, line):
        self.cursor.execute("INSERT INTO log (message) VALUES (?)", (line,))

בשביל לבדוק את הקוד אכתוב בדיקה שמבצעת:

  1. יוצרת באמצעות Fixture בסיס נתונים חדש וריק.

  2. יוצרת DBReporter מתוך בסיס נתונים זה ומעבירה אותו ל Calculator.

  3. מפעילה את הפונקציה add ובודקת אם נוצרה השורה בבסיס הנתונים.

ה Fixture יראה כך:

@pytest.fixture()
def tempdb():
    with tempfile.NamedTemporaryFile() as tf:
        db = sqlite3.connect(tf.name)
        yield db

והבדיקה עצמה נראית כך:

def test_add_db(tempdb):
    c = Calculator(reporter=DBReporter(tempdb))
    c.add(10, 20)
    [message, *_] = tempdb.cursor().execute("SELECT message FROM log").fetchone()
    assert message == "10 + 20 = 30"

וגם הפעם נרצה לוודא שאין שאריות מבדיקה קודמת. הבעיה שסימון הבדיקה עם xfail לא עובד טוב:

@pytest.mark.xfail
def test_no_previous_output(tempdb):
    [message, *_] = tempdb.cursor().execute("SELECT message FROM log").fetchone()
    assert message == "10 + 20 = 30"

כשאני מריץ את הבדיקה אני מקבל את הפלט הבא:

    @pytest.mark.xfail
    def test_no_previous_output(tempdb):
>       [message, *_] = tempdb.cursor().execute("SELECT message FROM log").fetchone()
E       sqlite3.OperationalError: no such table: log

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

בשביל לזהות Exceptions ב pytest יהיה עלינו להשתמש במבנה נוסף של הספריה שנקרא pytest.raises. הקוד אחרי התיקון נראה כך:

def test_no_previous_output(tempdb):
    with pytest.raises(sqlite3.OperationalError):
        tempdb.cursor().execute("SELECT message FROM log").fetchone()

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

4. אורך חיים של Fixture

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

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

from selenium import webdriver
import pytest

@pytest.fixture()
def browser():
    driver = webdriver.Firefox()
    driver.implicitly_wait(5)
    yield driver
    driver.close()


def test_title(browser):
    browser.get('https://duckduckgo.com')
    assert browser.title == "DuckDuckGo — Privacy, simplified."


def test_logo(browser):
    browser.get('https://duckduckgo.com')
    a = browser.find_element_by_css_selector('a#logo_homepage_link')
    assert a.get_attribute('href') == "https://duckduckgo.com/about"

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

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

  1. הערך class אומר שה Fixture יווצר פעם אחת לכל פונקציות הבדיקה ששייכות לאותו Class.

  2. הערך module אומר שה Fixture יווצר פעם אחת לכל פונקציות הבדיקה ששייכות לאותו מודול.

  3. הערך session אומר שה Fixture יווצר פעם אחת בכל מהלך הבדיקה.

  4. הערך function הוא ברירת המחדל ואומר שה Fixture יווצר מחדש בכל פונקציה.

נוסיף בקוד שלנו את ערך ה scope בעת יצירת ה Fixture. בגלל שמדובר בדפדפן הערך session יהיה הכי מתאים כאן:

@pytest.fixture(scope='session')
def browser():
    driver = webdriver.Firefox()
    driver.implicitly_wait(5)
    yield driver
    driver.close()

הפעלה מחדש של הבדיקה ועכשיו שתי הבדיקות עוברות ברצף והתוצאה מתקבלת הרבה יותר מהר.

5. העברת פרמטרים ל Fixture

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

בדוגמא של דפדפנים יהיה מועיל להפעיל את כל סט הבדיקות שלנו על מספר דפדפנים ולוודא שהכל עובר גם ב Firefox וגם ב Chrome. סלניום מספק ממשק אחיד לשני הדפדפנים וההבדל היחיד הוא בשורת היצירה: את Firefox יוצרים עם webdriver.Firefox() ואת כרום יוצרים עם webdriver.Chrome(). פרמטרים הם תוספת מצוינת למצב כזה שכן הם יאפשרו לי בשינוי קטן של ה Fixture לגרום לכל הבדיקות לרוץ עבור כל אחד מהדפדפנים.

הכתיב של Parameterized Fixtures הוא קצת שונה מהכתיב של Parameterized Tests רגילים ונראה כך:

@pytest.fixture(scope='session', params=[webdriver.Firefox, webdriver.Chrome])
def browser(request):
    driver = request.param()
    driver.implicitly_wait(5)
    yield driver
    driver.close()

הפרמטר params מקבל מערך של פרמטרים, פונקציית ה Fixture מקבלת אוביקט בשם request והמאפיין param של אוביקט ה request מציין את הערך של הפרמטר הנוכחי. אגב בזמנכם הפנוי שווה להסתכל ב Debugger באוביקט request - יש המון דברים נוספים מעניינים שעוברים ל Fixture בתוך אוביקט זה.

6. שיתוף Fixtures באמצעות קובץ conftest.py

ככל שהפרויקט גדל סיכוי טוב שנרצה לשתף את ה Fixtures שלנו בין מספר קבצים. ב pytest קובץ השיתוף נקרא conftest.py ואפשר לשים קובץ בשם זה בכל תיקיית בדיקות. באופן אוטומטי pytest משלב את ה Fixtures מכל קבצי ה conftest.py שהוא מוצא, ואם יש מספר Fixtures באותו שם הפנימי יותר יקבל עדיפות.

בקוד שלנו נוכל להעביר את ה Fixture החוצה לקובץ conftest.py וכך להשתמש בדפדפן ממספר קבצי בדיקות למשל כדי לבדוק מספר אתרים. בגלל שמדובר באותו Fixture ובגלל שה scope מוגדר להיות session, כל קבצי הבדיקה יקבלו בדיוק את אותו דפדפן ולא תהיה סגירה ופתיחה של הדפדפן בין הקבצים. בגלל הפרמטרים של ה Fixture כל הבדיקות מכל הקבצים יופעלו אוטומטית פעמיים: פעם אחת עבור Firefox ושניה עבור Chrome.

7. לאן ממשיכים מכאן

פייטסט עצמו מגיע עם מספר Built In Fixtures ששווה להכיר. הפקודה pytest -q --fixtures מדפיסה על המסך את רשימת כל ה Fixtures שזמינים לקוד שלכם, רשימה שכוללת את כל ה Fixtures שאתם כתבתם ואת כל ה Fixtures שבנויים בתוך pytest. אפשר גם למצוא את הרשימה בדף התיעוד באתר של פייטסט.

בנוסף ב API Refernce תוכלו למצוא את כל הפרמטרים של הדקורייטור fixture.

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