• בלוג
  • תבנית Factory לבדיקות (או: איפה שמים את ה Mock)

תבנית Factory לבדיקות (או: איפה שמים את ה Mock)

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

1. קוד הדוגמה

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

import time
from datetime import datetime, timedelta

class Meeting:
    def schedule(self, start_time, duration):
        print("scheduling meeting")
        if start_time < datetime.now():
            raise Exception("Meeting scheduled in the past")

        self.update_calendar()

    def update_calendar(self):
        print("adding the new meeting to google calendar")

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

הצלחנו לדמיין? יופי, נמשיך.

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

class TestMeeting(unittest.TestCase):
    def test_cant_schedule_on_past_dates(self):
        m = Meeting()
        with self.assertRaises(Exception):
            m.schedule(datetime.now() - timedelta(days=10), timedelta(hours=2))

    def test_can_schedule_on_future_dates(self):
        m = Meeting()
        m.schedule(datetime.now() + timedelta(days=10), timedelta(hours=2))
        assert(True)

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

2. שינוי התנהגות לבדיקות מבפנים

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


class Meeting:
    def __init__(self, test_mode=False):
        self.test_mode = test_mode

    def schedule(self, start_time, duration):
        print("scheduling meeting")
        if start_time < datetime.now():
            raise Exception("Meeting scheduled in the past")

        if not self.test_mode:
            self.update_calendar()

    def update_calendar(self):
        print("adding the new meeting to google calendar")

ואז בקוד הבדיקה אני יוצר את הפגישה עם:

def test_can_schedule_on_future_dates(self):
  m = Meeting(True)
  m.schedule(datetime.now() + timedelta(days=10), timedelta(hours=2))
  assert(True)

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

3. שינוי התנהגות לבדיקות מבחוץ

יותר מומלץ לשנות את ההתנהגות מבחוץ - כלומר מתוך קוד הבדיקה. בפייתון אני יכול לעשות את זה בקלות, בגלל שאפשר לשנות מתודות אפילו אחרי יצירת האוביקט. קוד הבדיקה הבא מחליף את update_calendar במימוש ריק וכך מריץ את הבדיקה בלי החלק המיותר מהקוד:

class TestMeeting(unittest.TestCase):
    def test_cant_schedule_on_past_dates(self):
        m = Meeting()
        with self.assertRaises(Exception):
            m.schedule(datetime.now() - timedelta(days=10), timedelta(hours=2))

    def test_can_schedule_on_future_dates(self):
        m = Meeting()
        m.update_calendar = MagicMock()
        m.schedule(datetime.now() + timedelta(days=10), timedelta(hours=2))
        assert(True)

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

4. שינוי התנהגות לבדיקות באמצעות Factory

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

def create_test_meeting():
    m = Meeting()
    m.update_calendar = MagicMock()
    return m

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

def create(what):
    if what == Meeting:
        return create_test_meeting()
    elif what == Message:
        return create_test_message()

הדבר החשוב והפשוט הוא במקום למלא את קוד הבדיקות שלכם באותן 2-3 שורות שחוזרות על עצמן בכל בדיקה, עדיף לרכז את מאמצי יצירת אוביקטי הבדיקה במקום אחד, ושם גם לשמור את ה mock-ים, כי גם קוד בדיקה צריך תחזוקה.

נ.ב. המודול factory boy הוא ספריית פייתון שתעזור לכם לבנות Test Factories. הוא מבוסס על פקטורי-בוט של רובי (שבעבר היה נקרא factory girl ולכן השם).