15 min. read

פרק קודם:

פייתון 27 - בדיקות

מה נלמד

  • מה זה Mocking
  • Dependency Injection
  • Stubs
  • unittest.mock
  • Test Coverage

טסטים

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

מה זה Mocking?

מוק זהו רכיב אשר מטרתו היא לזייף את התנהגות.

נמחיש זאת בקוד הבא:

file.txt:

1
Hello World

main.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Service:
def __init__(self, fileName):
self._FileName = fileName

def GetData(self):
with open(self._FileName,'r') as file:
return file.readline()


def Process():
service = Service('file.txt')

data = service.GetData()

if len(data) > 0:
print(data)


if __name__ == "__main__":
Process()

אם חשבתם לעצמכם “זוהי גרסא ארוכה ל-hello world” אז צדקתם!
הקוד מדפיס את מה שנמצא בקובץ.
הקוד הזה מורכב כי יש כאן כמה שכבות:

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

בשביל מה צריך כל כך הרבה שכבות?

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

אולם לקוד הזה יש גם כמה תהיות:

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

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

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

  • במידה ואנו רוצים שורות בודדות אז נוכל להשתמש ב-readlines ונתמודד עם רשימה של מחרוזות.
    List[String].
  • אם נרצה להתייחס לקובץ כטקסט נקרא ל -read במקום זה ונתייחס אליו כמחרוזת אחת.

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

קודם כל נצטרך להגדיר עם איזה מידע אנו עובדים.
ה-Service:

  • לא ייקבל פרמטרים, ז”א שהפונקציה GetData תדע מאיפה להביא מידע.
  • הפונקציה תחזיר מחרוזת בודדה ולא רשימה String ולא - List[String].

ובכך הגדרנו התנהגות מוגדרת היטב אשר אנו יודעים איך לבצע אותה.

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

1
2
3
class MockedService:
def GetData(self):
return 'Hello World'

כעת אם נשתמש בו:

1
2
3
4
5
6
7
8
9
10
11
def Process():
service = MockedService()

data = service.GetData()

if len(data) > 0:
print(data)


if __name__ == "__main__":
Process()

תמיד יוחזר Data ותמיד יודפס Hello World.

למה צריך Mocking?

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

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

וככלל:

כל גורם חיצוני יהווה מכשול לטסטים, מכיוון שהוא עשוי להשתנות.

ומה רע בשינוי?

טסטים צריכים להיות דטרמיניסטיים.

ז”א שלא משנה מתי, איך ולמה, כאשר אריץ טסטים - עליהם לבצע ולבדוק את אותו הדבר בכל ריצה!

datetime.now Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import datetime

class Discount:
def IsActive(self):
return False

def DiscountPrice(self, category, CurrentPrice):
pass

class PastryEveningDiscount(Discount):
def IsActive(self):
now = datetime.datetime.now()
if now.hour > 18 and now.hour < 23:
return True

def DiscountPrice(self, category, CurrentPrice):
if category == "Pastry":
return CurrentPrice * 0.8 #20% discount
return CurrentPrice

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

1
2
3
4
5
6
7
8
import dates

def test_PastryEveningDiscount_IsActive():
discounter = dates.PastryEveningDiscount()

isActive = discounter.IsActive()

assert isActive, 'discount is not active'

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

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

mocking

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

1
2
3
4
5
6
7
8
9
10
11
12
13
class PastryEveningDiscount(Discount):
def __init__(self, getHour):
self._getHour = getHour

def IsActive(self):
hour = self._getHour()
if hour > 18 and hour < 23:
return True

def DiscountPrice(self, category, CurrentPrice):
if category == "Pastry":
return CurrentPrice * 0.8 #20% discount
return CurrentPrice

נוכל לשכתב את הטסט כך שיהיה דטרמיניסטי, ועליו להוסיף גם טסט ליום:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import dates

def test_PastryEveningDiscount_IsActive_DuringEvening():
eveningHour = 20 # 0-24 hour format
discounter = dates.PastryEveningDiscount(lambda : eveningHour)

isActive = discounter.IsActive()

assert isActive, 'discount is not active'

def test_PastryEveningDiscount_IsActive_False_DuringDay():
eveningHour = 15 # 0-24 hour format
discounter = dates.PastryEveningDiscount(lambda : eveningHour)

isActive = discounter.IsActive()

# unittest.assertFalse(isActive) # במה שלמדנו בפרק 27 נוכל להשתמש בפונקציות מיוחדות לאסרטים
assert isActive == False, 'discount is active during the day'

הטלת קובייה

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

1
2
3
4
5
6
7
8
9
import random

class CriticalHitChance:
def Chance(self):
rand = random.randint(1,6)
return rand == 6 # 1 out of 6

def ChangeDamage(self, damage):
return damage * 1.4 # 40% damage increase

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

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

1
2
3
4
5
6
7
8
9
10
11
>>> random.seed(10)
>>> random.randint(0,6)
4
>>> random.seed(10)
>>> random.randint(0,6)
4
>>> random.seed(10)
>>> random.randint(0,6)
4
>>> random.randint(0,6)
0

כששמנו seed = 10 אז קיבלנו 4 כל פעם.

איך נתקן כדי שהטסט יהיה דטרמיניסטי?
שוב בזיוף התנהגות!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import random

class Cube:
def Throw(self):
return random.randint(1,6)

class FakeCube:
def __init__(self, number):
self.__number = number

def Throw(self):
return self.__number

realCube = Cube()
realCube.Throw()

fakeCube = FakeCube(4)
fakeCube.Throw() #always 4 now

Dependency Injection

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

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

למעשה זה מביא לנו כלי עצום שבעזרתו נזייף התנהגות על מנת ליצור טסטים דטרמיניסטים ואמינים.

Mock & Stub

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

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

1
2
3
4
5
6
7
class Service:
def get(self):
pass

class ServiceStub(Service):
def get(self):
return 'Hello World'

נוכל לומר ש-ServiceStub הוא Stub כי:

  • הוא מחזיר רק Hello World.
  • אינו דינאמי, ז”א שאנו לא יכולים לשנות אותו מבחוץ.
  • נבנה רק למטרה הזו.

נלמד עכשיו בעזרת unittest.mock איך יוצרים, שולטים ועובדים במוקינג דינאמי.


תרגיל 1

א. מה זה Mock?
ב. מה זה Stub?
ג. מה ההבדל בינהם?
ד. איך לבצע הזרקת תלויות?

תשובות


unittest.mock

ספריית המוק הזו הוכנסה לפייתון וכיום מגיעה עם הספרייה unittest.

ניקח את דוגמת ה-HitDamage:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import random


class Cube:
def Roll(self):
return random.randint(1, 6)


class CriticalHitChance:
def __init__(self, cube):
self.__cube = cube

def Chance(self):
rand = self.__cube.Roll()
return rand == 6 # 1 out of 6

def ChangeDamage(self, damage):
return damage * 1.4 # 40% damage increase


def test_CriticalHitChance_Chance_OnSix_Returns_True():
hitChance = CriticalHitChance(Cube())

hasChance = hitChance.Chance()

assert hasChance

המטרה שלנו שהטסט test_CriticalHitChance_Chance_OnSix_Returns_True יהיה אמין ונכון.

דרך א - שינוי המתודה

פייתון היא שפה דינאמית, נוכל לממש זיוף התנהגות ע”י עריכת הפונקציה:

1
2
3
4
5
6
7
8
def test_CriticalHitChance_Chance_OnSix_Returns_True():
specialCube = Cube()
specialCube.Roll = lambda: 6
hitChance = CriticalHitChance(specialCube)

hasChance = hitChance.Chance()

assert hasChance

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

דרך ב- שימוש ב-MagicMock

1
2
3
4
5
6
7
8
def test_CriticalHitChance_Chance_OnSix_Returns_True():
specialCube = Cube()
specialCube.Roll = MagicMock(return_value=6)
hitChance = CriticalHitChance(specialCube)

hasChance = hitChance.Chance()

assert hasChance

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

  • לבצע side effect כמו לזרוק שגיאות או לשנות משתנה אחר
  • לערוך את ערך ההחזרה
  • שימוש במתודות פנימיות של פייתון כמו __lt__, __gt__, __len__, __str__ וכדו’
  • שימוש ב-iteratorים.
  • בדיקה שהפונקציה נקראה
  • בדיקת פרמטרים כשהפונקציה נקראת.

אם אתם רוצים לוודא שהפונקציות שלכם תמיד נראות כמו הפונקציות האמיתיות אתם יכולים להשתמש ב-create_autospec().

1
2
3
4
5
6
7
8
def test_CriticalHitChance_Chance_OnSix_Returns_True():
specialCube = Cube()
specialCube.Roll = create_autospec(specialCube.Roll, return_value=6)
hitChance = CriticalHitChance(specialCube)

hasChance = hitChance.Chance()

assert hasChance

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

patch@

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

1
2
3
4
5
6
7
8
9
10
from unittest.mock import patch

@patch.object(Cube, 'Roll')
def test_CriticalHitChance_Chance_OnSix_Returns_True_with_patch(cube):
cube.Roll.return_value = 6
hitChance = CriticalHitChance(cube)

hasChance = hitChance.Chance()

assert hasChance

בעזרת patch@ נוכל לערוך מתודות, ובעזרת patch.object@ נוכל לערוך אובייקטים!
שימו לב לפרט נוסף, כעת אנו מקבלים את אובייקט המוק בעזרת פרמטר למתודה!

Method Assertion

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

1
2
3
4
5
6
7
8
9
10
@patch.object(Cube, 'Roll')
def test_CriticalHitChance_Chance_OnSix_Returns_True_with_patch(cube):
cube.Roll.return_value = 6

hitChance = CriticalHitChance(cube)

hasChance = hitChance.Chance()

cube.Roll.assert_called_once()
assert hasChance

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

למה אנחנו צריכים את זה?
למשל כדי לבדוק אם תנאי מסוים קרה או לא!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from unittest.mock import MagicMock

class Toaster:
def Toast(self, ingridients):
pass

class Oven:
def Bake(self, ingridients):
pass

class Baker():
def __init__(self, oven, toaster):
self.Oven = oven
self.Toaster = toaster

def Bake(self, ingridients):
if 'bread' in ingridients:
self.Toaster.Toast(ingridients)
elif 'flour' in ingridients:
self.Oven.Bake(ingridients)

def test_Baker_Toasts_Bread():
oven = Oven()
toaster = Toaster()
toaster.Toast = MagicMock()

baker = Baker(oven, toaster)

baker.Bake(['bread', 'cheese', 'tomato', 'bread'])
toaster.Toast.assert_called_once()

תרגיל 2

כתבו את אותה מהתודה ל-Oven ובדקו אם זה קורה גם כן!

side_effect

side_effect כשמו היא התנהגות שקוראת כתוצאה מהמוק וניתן להשתמש בו ב-3 דרכים.

לזרוק שגיאה

אם ה-side_effect הוא מחלקה של שגיאה, קריאה למתודה הזו תזרוק שגיאה:

1
2
3
4
5
6
7
8
9
10
11
def test_Baker_Bakes_WithThrow():
oven = Oven()
oven.Bake = MagicMock()
oven.Bake.side_effect = IndexError('Boom')

toaster = Toaster()
toaster.Toast = MagicMock()

baker = Baker(oven, toaster)

baker.Bake(['flour', 'milk', 'eggs', 'chocolate'])

במקרה הזה תיזרק שגיאה.
לעיתים נרצה לדעת איך הקוד שלנו מתמודד עם שגיאות!

לקרוא למתודה

נוכל להשתמש ב-side effect כדי לקרוא למתודה נוספת עם הפרמטרים

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def PrintThese(ingidients):
print(ingidients)
return DEFAULT

def test_Baker_Bakes_WithThrow():
oven = Oven()
oven.Bake = MagicMock()
oven.Bake.side_effect = PrintThese

toaster = Toaster()
toaster.Toast = MagicMock()

baker = Baker(oven, toaster)

baker.Bake(['flour', 'milk', 'eggs', 'chocolate'])

כדי שהמוק יחזיר את אותו הדבר יש להחזיר מהמתודה הזו DEFAULT אחרת נוכל לשנות את ערך ההחזרה בעזרת המתודה הזו.

Iterable

side_effect יכול לעזור לנו לשלוט ב-iterables, למשל כדי לעשות מוק למתודה עם yield.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from unittest.mock import MagicMock


class MyMy:
def Action(self, service):
sum = 0
for i in service.GetResults():
print(i)
sum += i
return sum == 190 # 190 for range(20)


class Service:
def GetResults(self):
for i in range(20):
yield i


def test_MyMy_return_true():
mymy = MyMy()
myService = Service()

result = mymy.Action(myService)
assert result

def test_MyMy_returns_false_on_different_sum():
mymy = MyMy()
myService = Service()
myService.GetResults = MagicMock()
myService.GetResults.side_effect = [range(30)]

result = mymy.Action(myService)
assert result == False

תרגיל 3

יש לכתוב טסטים למחלקת CardGame.

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

  • PlayerOnePoints
  • PlayerTwoPoints
  • TotalPointsNeeded

תתכננו, תעצבו וכתבו את הטסטים!

תשובה


Test Coverage

בנושא הבדיקות האחרון שלנו נלמד על כלי שנקרא pytest.cov.

זהו תוסף ל-pytest היודע לייצר כמות coverage לקוד שלנו.

מה זה coverage?

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

  1. כדי להתקין אותו נשתמש ב:
    pip install pytest.cov

  2. נסדר את התיקייה כך שיהיה לנו קובץ טסטים ואת קבצי הפייתון שלנו:

  3. לאחר מכן נריץ בעזרת pytest את ה-cov:
    pytest --cov --cov-report=html test_cards.py

שימו לב שהשתמשנו בתוצאת html המראה לנו באופן גראפי ויפה את הקוד שלנו:
הטסט מייצר index.html בתוך תיקיית testcov.

אם פותחים את זה, זה נראה כך:

נוכל להיכנס לכל אחד מהקבצים ולראות מה רץ ומה לא:

תרגיל 4

הריצו את הכלי coverage על הטסטים שכתבתם ותראו מה ה-coverage שקיבלתם!
האם יש מקרה קצה ששכחתם לבדוק?


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

תודה על הקריאה!


אהבתם? מוזמנים להביע תמיכה כאן: כוס קפה