כבר מזמן הגעתי למסקנה שכמעט כל דבר מתגלה כמורכב יותר ממה שנראה לנו ברגע הראשון. הסתכלו סביבכם. קחו חפץ פשוט ויומיומי כמו לדוגמה, שקית ניילון לסנדוויץ' של הילדים. חשבתם פעם איך מצליחים לייצר את זה בלי שהצדדים ידבקו אחד לשני? או למשל איך אופים לחם מיוחד כזה שיש בו בועות אוויר גדולות? (אני עדיין לא הצלחתי!); או איך בטריה עובדת? כל דבר דורש פרטים רבים וידע רב. מצד שני, אם אנחנו מנסים ללמוד משהו, עדיף לא לרדת מיד לפרטי פרטים אלא קודם לתאר את הרעיון בקווים כללים ולתת דוגמאות פשוטות, ורק בהמשך לצלול יותר לעומק.
זו גם הגישה בקורסי בדיקות כשמלמדים את טכניקות הבדיקה הבסיסיות.
חלוקה למחלקות שקילות? פשוט! יש לנו שדה שמקבל ערכים בין 1 ל-10. מחלקת השקילות התקפה (valid) היא {10..1}, והמחלקות הלא-תקפות הן המספרים הגדולים מ-10 או הקטנים מ-1. הלאה: בדיקות ערכי גבול לאותה דוגמה: ערכי גבול תקפים הם 1 ו-10, והלא-תקפים הם 0 ו-11.
קל, פשוט... בואו נתקדם לטכניקה הבאה!
טוב – אני קצת מגזים... משקיעים יותר זמן בלימוד כל טכניקה, אבל בסופו של דבר נשארים בשלב מאוד בסיסי. התוצאה רעה משתי סיבות: קודם כל, זה מעמיד את מקצוע הבדיקות כמשהו פשוט לגמרי שכל אדם עם דופק יכול ללמוד במהירות ולבצע. הבעייה השניה היא שהעולם האמיתי לרוב הרבה יותר מסובך מהמקרים הפשוטים המוצגים בכיתה, וכשמנסים לממש את הטכניקות על מוצר אמיתי מתקבלת ההרגשה שהתאוריה היתה נהדרת אבל במציאות היא לא ממש עובדת. בטור הפעם אתמקד בטכניקה הראשונה שמלמדים: חלוקה למחלקות שקילות, ואכנס קצת יותר לעומק - מעבר למקרים הטריוויאליים.
תזכורת: מחלקות שקילות
טכניקת הבדיקה של "חלוקה למחלקות שקילות" מבוססת על כך שניתן לחלק את מרחב הקלטים שתוכנה מקבלת לקבוצות. כל קבוצה מכילה ערכי קלט שעבורם התוכנה מריצה בדיוק את אותן שורות קוד.
בואו נכנס טיפה "לתוך הקוד" כדי להבין את העיקרון עליו מבוססת הטכניקה.
כל תוכנה ניתן לחלק לבלוקים של שורות קוד הרצות אחת אחרי השניה (כלומר, ללא הסתעפויות כתוצאה ממשפטי תנאי). מרגע שהתוכנה נכנסה לתוך בלוק כזה, כל שורות הקוד שבבלוק יורצו אחת אחרי השניה. למשל (פסודו-קוד):
במקרה זה, מחלקות השקילות הן:
לאחר ביצוע החלוקה, ניקח מכל מחלקת שקילות נציג אחד כלשהו ונריץ את התוכנה עליו. התיאוריה שמאחורי הטכניקה היא שאם התוכנה עובדת נכון עבור נציג אחד של קלט מהקבוצה, יש סבירות גבוהה שהקוד ירוץ נכון גם עבור כל הקלטים האחרים שבאותה קבוצה.
מגבלות (לכאורה)
אפשר להעלות לא מעט טענות למה הטכניקה של מחלקות שקילות אינה "חזקה" (כלומר, לא באמת נותנת ביטחון גבוה בנכונות הקוד). מספיק להסתכל על דוגמה ששונה במעט מהקודמת:
מבנה התוכנה זהה למקרה הקודם ולכן גם הגדרת מחלקות השקילות זהה. אבל מיד רואים שהטכניקה נכשלת לגמרי: בחירה בנציג ממחלקה 2 שאינו 0, לא תזהה את הבאג של חלוקה ב-0.
האמנם כישלון? בואו נחשוב על הדרישות עבור הפונקציה ()calc_inverse. הם משהו בסגנון הזה:
בסקירה של הדרישות, יש סיכוי טוב שמישהו היה עולה על הבעייה וממליץ על דרישה שלישית:
עכשיו ברור שמחלקות השקילות הן:
מה המסקנה? נראה שהחלוקה למחלקות שקילות צריכה להתבסס על הדרישות, לפחות כנקודת התחלה. אבל יש מקרים שהתבססות על הדרישות בלבד אינה מספיקה. יתכן שמסיבות שונות המפתחים מממשים את הקוד בצורה שונה ממה שחשבנו, ואז החלוקה הנכונה נובעת (גם) מהקוד. למשל: פונקציה שממיינת רשימה של מספרים. על פניו, כל רשימה – ארוכה או קצרה - שייכת לאותה מחלקת שקילות. בפועל, מסיבות של מהירות ביצוע, יתכן שהמימוש ישתמש שאלגוריתם bubble sort לרשימות קצרות וב-quick sort לרשימות ארוכות.
מסתבר אם כן שהדרך הטובה להחליט אם החלוקה שלכם נכונה, היא להגדיר את המחלקות לפי הדרישות, ואז לעבור על ההגדרות והשיקולים שלכם עם המפתחים. הם כבר יגידו לכם אם הקוד עושה משהו לא צפוי.
כנגד הטענות שהטכניקה אינה תמיד נכונה, ראיתי מאמר[1] שטוען כי באותם מקרים בהם נראה שהטכניקה כשלה זה לא בגלל שהטכניקה אינה נכונה, אלא שהחלוקה לא היתה מעודנת מספיק. למעשה, טוענים המחברים, כל באג לוגי שנמצא – בכל טכניקה שהיא - אפשר "לתרגם" למחלקת השקילות שהייתה מוצאת אותו. כלומר, כל באג מלמד אותנו איך לחלק יותר נכון את מרחב הקלט למחלקות שקילות. חוסר ידע שלנו על המערכת והקוד גרם לכך שהחלוקה לא היתה מדוייקת מספיק ולכן לא עלתה על כל הבאגים. ועוד אנחנו מעיזים להאשים את הטכניקה שאינה חזקה במיוחד...
אם כבר הזכרנו את המאמר: עוד טענה של המחברים היא שטכניקת חלוקה למחלקות שקילות, ברוב המקרים, אינה עדיפה על בחירה רנדומלית של קלט. בנוסף, כיוון שבמקרים מסובכים יש סבירות מסוימת שנפספס את החלוקה הנכונה ונמזג כמה מחלקות שקילות יחד, המחברים ממליצים להריץ מספר דוגמאות מכל מחלקת שקילות – דוגמאות שנבחרות באקראי או בחלוקה אחידה על פני מרחב המחלקה.
העלילה מסתבכת
כשנבוא לממש את הטכניקה על מערכת אמיתית יתכן ונעמוד לפני מצבים שבהם גם ידע מלא של הדרישות והקוד לא מספיק. יש מקרים שבהם מסובך להגדיר את החלוקה, ולמעשה ישנן חלוקות שונות שכל אחת מהן ניתנת להצדקה. דוגמה: נתונה תוכנה שמוצאת אם מחרוזת אחת (מחרוזת א') מכילה בתוכה מחרוזת אחרת (מחרוזת ב').
findstring stringA stringB
על כל הימצאות של ב' ב-א', הפונקציה מדפיסה "נמצאה המחרוזת!". למשל:
findstring ababa ab
String found!
String found!
מה החלוקה למחלקות שקילות? הניחו לרגע את הגליון, קחו דף ועט ונסו להגדיר את מחלקות השקילות.
חזרתם?
ובכן, מסתבר שאין תשובה אחת נכונה והמחלקות תלויות בקריטריון החלוקה שבוחרים! הנה כמה אפשרויות:
אפשרות א': חלוקה לפי קלט חוקי \ לא חוקי:
מחלקות תקפות:
מחלקות לא תקפות:
אפשרות ב': חלוקה לפי אורך המחרוזות
אפשרות ג': המיקום במחרוזת א' שבו נמצאת מחרוזת ב':
אפשר לחלק לפי כמות הפעמים ש-ב' נמצאת ב-א'; על פי אורך המחרוזות; על פי תכולת המחרוזות (כן\לא רווחים, סימני פיסוק, סימנים מיוחדים); האם יש חפיפה בין הופעות חוזרות של ב' (כגון: א' = aaaaaaa ו-ב' = aaa). וכו' וכו'...
עכשיו החלוקה נראית כמו משימה סיזיפית... אבל שימו לב איך כל חלוקה מעלה רעיונות חדשים לבדיקות!
סיכום
חלוקה למחלקות שקילות אינה פשוטה כמו שנדמה בהתחלה. מה שכתבתי כאן גם הוא רק חלק מהסיפור, ואפשר להתעמק עוד[1]. במקרים רבים תהיה יותר מחלוקה נכונה אחת. הטכניקה מאלצת אותנו לעשות ניתוח של מרחב הקלט, דבר שעוזר לקבל הבנה טובה יותר של צירופי הקלטים השונים שאיתם התוכנה צריכה להתמודד, כשכל חלוקה מייצרת מקרי בדיקה שונים ובעלי ערך. אפשר להתחיל את הניתוח תוך שימוש בדרישות, אבל חשוב לא להזניח סקירה של המסקנות עם המפתחים, על מנת לגלות מקרים שבהם המימוש יותר מסובך ממה שאפשר היה להסיק מקריאת הדרישות.
[1] למשל, לאלה שאוהבים את הסגנון של ג'יימס באך: https://www.satisfice.com/blog/archives/1669