בדיקות אוטומטיות לתוכניות Python

03/11/2016

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

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

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

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

1. הרצת קובץ נפרד מתוך קובץ הבדיקות

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

במקרה שיש לנו תוכנית חיצונית נפרדת לגמרי שיש לה קלט ופלט ברורים נשתמש במודול subprocess. הפונקציה communicate של המודול מאפשרת שליחת קלט כ stdin לתוכנית חיצונית וקריאת הפלט שלה מתוך stdout ו stderr.

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

class TestEx1(unittest.TestCase):
    def test_print_hello_python(self):
        proc = subprocess.Popen(['python', '01.py', '5'],
                                stdout=subprocess.PIPE)
        for i in range(5):
            line = proc.stdout.readline()
            self.assertEqual('hello python', line.strip().lower())

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

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

class TestEx1(unittest.TestCase):
    def test_print_hello_python(self):
        proc = subprocess.Popen([sys.executable, '01.py', '5'],
                                stdout=subprocess.PIPE)
        for i in range(5):
            line = proc.stdout.readline()
            self.assertEqual('hello python', line.strip().lower())

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

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

ex1 = __import__('01')

class TestEx1(unittest.TestCase):
    def test_sum_positive(self):
        res = ex1.mysum(10, 20, 30)
        self.assertEqual(60, res, 'mysum(10,20,30) = %d (expected 60)' % res)

השמוש ב __import__ הכרחי כששם הקובץ מתחיל במספר.

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

2. דריסת פונקציות עם הספריה mock

ספריה מופלאה שמשלימה את execfile היא הספריה mock. היא חושפת decorator פשוט המשנה פונקציה ובסיום העבודה מחזיר אותה לקדמותה.

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

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

כך נראה הקוד:

from mock import patch

class TestEx2(unittest.TestCase):
    @patch('sys.stdout', new_callable=StringIO)
    @patch('random.randint', return_value=10)
    def test_sum_7_random_integers(self, rand_spy, out_spy):
        execfile('02.py')
        self.assertIn('70', out_spy.getvalue(),
                      'Expected to find 70 but got: %s' % out_spy.getvalue())

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

3. מה הלאה

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

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