רקע
בדיקות יחידה (Unit Test) הם כלי חשוב מאוד בארגז הכלים של מפתח. הן מאפשרות לבדוק בכל רגע שהמחלקה עובדת כמצופה, וששינויים שנעשים בתוך המחלקה לא שוברים את ההתנהגות שלה.
אחד הכלים שה-Unit Testing Framework של Visual Studio מספק לנו הוא ExpectedExceptionAttribute. נראה דוגמה קטנה ונאיבית שבאמצעותה נבין למה הוא שימושי:
{
[TestMethod]
public void DivisionByNumber()
{
int num = 2;
Assert.AreEqual(4, 8 / num);
}
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void DivisionByZero()
{
int num = 0;
Assert.AreEqual(4, 8 / num);
}
}
בניגוד לבדיקה הראשונה שבה אני בודק האם מתקבלת התוצאה הצפויה, בבדיקה השניה אין תוצאה נכונה, כל תוצאה שנקבל תהיה שגויה כי אין תוצאה נכונה לחילוק באפס. מה שקורה הוא שהמערכת זורקת חריג (Exception). למקרים כאלו נועד ה-ExpectedException, הוא בודק שאכן החריג התקבל, ומסמן שהבדיקה הצליחה. אם לא נזרק חריג הבדיקה תסומן כבדיקה שנכשלה.
הבעיה
הבעיה של הגישה הזו מתגלה במקרים מורכבים יותר.
נניח שאנחנו כותבים מחלקה של חשבון בנק, ובה פונקציה למשיכת כסף. הפונקציה בודקת האם יש מספיק כסף בחשבון, אם כן, היא מעדכנת את היתרה, ובמקרה שלא, זורקת חריג (המחלקה לא מכירה את השיטה הישראלית של לחיות באוברדראפט). כך נראית המחלקה:
{
public int Balance { get; set; }
public void Withdraw(int amount)
{
Balance -= amount;
if (Balance<0)
{
throw new BankException();
}
}
}
וכך יראו הבדיקות של השיטה:
public void Withdraw1()
{
BankAccount account = new BankAccount { Balance = 1000 };
account.Withdraw(300);
Assert.AreEqual(700, account.Balance);
}
[TestMethod]
[ExpectedException(typeof(BankException))]
public void Withdraw2()
{
BankAccount account = new BankAccount { Balance = 100 };
account.Withdraw(300);
Assert.AreEqual(100, account.Balance);
}
כמו שניתן היה לראות בקוד למעלה המימוש של המחלקה שגוי, ובמקרה שהסכום לא מספיק היתרה בחשבון תופחת, ורק אחר כך יזרק החריג. בעיה כזאת היתה אמורה להתגלות בבדיקה השניה (Withdraw2) שבה אני בודק שנזרק חריג, ושהסכום בחשבון לא השתנה מהסכום ההתחלתי. אבל, שלא כצפוי, זה לא קורה והבדיקה חוזרת תקינה.
הסיבה פשוטה: כאשר אני מנסה למשוך כסף מעבר ליתרה, נזרק חריג, ריצת השיטה מופסקת והמחשב כלל לא מגיע לשורה עם ה-Assert, אבל הוא חושב שזה תקין כי הוא ציפה לחריג, וזה אכן קרה.
המסקנה היא שאין לנו דרך לבדוק האם שיטה שזורקת חריג עשתה או לא עשתה פעולה מסוימת.
הפתרון – שלב א – פתרון פשוט
הפתרון הפשוט לבעיה הוא לעקוף את השימוש ב-ExpectedException בכוחות עצמנו. בכמה שלבים:
- להכניס את הקריאה לשיטה “הבעייתית” למבנה של try…catch.
- אם הגענו לשורה שאחרי הקריאה “הבעיתיית”, זו שגיאה, כי כשמצפים לבעיה, ואין בעיה, זו עצמה בעיה. ולכן נודיע ל-Framework שהבדיקה נכשלה.
- בחלק של ה-catch נתפוס רק חריג מהסוג שלו אנחנו מצפים, ולא נעשה איתו כלום. כאן יש רווח כפול: אם החריג הוא מהסוג שציפנו לו, ריצת הבדיקה תמשיך כתיקונה, ואם מסוג אחר, החריג יזרק והבדיקה תכשל, כשסיבת הכשלון היא החריג שאירע בפועל.
- בתוך ה-catch אנחנו יכולים לבדוק האם אכן השיטה עשתה נכון (או, כמו במקרה שלנו, לא עשתה) את פעולתה.
והנה הקוד (המספרים בהערות מציינים את הסעיפים דלעיל):
public void Withdraw3()
{
BankAccount account = new BankAccount { Balance = 100 };
try //1
{
account.Withdraw(300);
Assert.Fail(); //2
}
catch (AccountException) //3
{
Assert.AreEqual(100, account.Balance); //4
}
}
הפתרון הזה הוא פתרון פשוט ויעיל למקרים שבהם רוצים לבדוק ששיטה שזורקת Exception פועלת באופן תקין מעבר לעצם זריקת החריג. הבעיה היחידה בו היא שהוא מסרבל את הבדיקות, קשה לכתוב כך בדיקות (בעיקר כמדובר על כמות גדולה), וקשה לקרוא אותן ולהבין בקלות מה ואיך עושה כל בדיקה.
אז אין דרך לכתוב שורה בודדת כל פעם ולתת למחשב להתמודד עם כל ההשלכות בעצמו?
בודאי שיש!
הפתרון – שלב ב – המחלקה ExceptionAssert
כתבתי את המחלקה הבאה:
{
try
{
command.Invoke();
string msg = string.Format(“Test method did not throw an exception. An exception of type {0} was expected.”, typeof(T).Name);
Assert.Fail(msg);
}
catch (T)
{
}
}
וככה נראית הקריאה:
public void Withdraw4()
{
BankAccount account = new BankAccount { Balance = 100 };
ExceptionAssert.OfType<AccountException>(
() => account.Withdraw(300));
Assert.AreEqual(100, account.Balance);
}
השיטה עצמה פועלת בדיוק כמו הקוד הקודם, וכל מי שהבין אותה, ומכיר קצת פונקציות גנריות וביטויי למדא (Lambda) יבין גם את הקוד הזה.
כדי לבדוק שהמחלקה אכן עובדת כמצופה כתבתי בדיקות למחלקה שלי. הנה הן:
public class ExceptionAssertTest
{
[TestMethod]
public void ExpectedExceptionType()
{
int num = 0;
ExceptionAssert.OfType<DivideByZeroException>(() =>
num = num / num);
}
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void UnexpectedExceptionType()
{
int num = 0;
ExceptionAssert.OfType<NullReferenceException>(() =>
num = num / num);
}
[TestMethod]
[ExpectedException(typeof(AssertFailedException))]
public void NotThrowException()
{
int num = 0;
ExceptionAssert.OfType<DivideByZeroException>(() =>
num = num / 1);
}
[TestMethod]
[ExpectedException(typeof(AssertFailedException))]
public void AssertAfterException()
{
int num = 0;
ExceptionAssert.OfType<DivideByZeroException>(() =>
num = num / num);
Assert.AreEqual(6, num);
}
}
בדקתי את השיטה הזו בארבעה מקרים:
- שבמקרה שמגיעה שגיאה מהטיפוס אליו התכוננתי, הבדיקה מסיימת בהצלחה.
- שבמקרה שמגיעה שגיאה מסוג אחר, הבדיקה מסיימת עם השגיאה שנזרקה בפועל.
- שבמקרה שבו לא נזרקה שגיאה, הבדיקה נכשלת (AssertFailedException).
- שבמקרה שבו חזרה שגיאה מהסוג שציפיתי לו, הריצה ממשיכה וה-Assert שכתוב אח”כ נכשל.
כמובן, לא יכולתי לבדוק את השיטה שאני כתבתי באמצעותה, ולכן השתמשתי ב-ExpectedException. מה גם שכאן לא היה צורך לבדוק משהו נוסף אחרי ריצת השיטה.
סיכום
- ExpectedException הוא attribute הוא נחוץ ושימושי בכתיבת Unit test.
- יש מקרים שבהם הגישה שלו לא מאפשרת לבדוק ולזהות שגיאות מסוימות.
- אפשר לכתוב את הבדיקות בגישה שונה, שמאפשרת לבדוק גם במקרים כאלו.