עדיף לא לבדוק

09/02/2022

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

1. הקוד

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

async function f1() { return 5; }
async function f2() { return 9; }
async function f3() { return 2; }

async function complexStuff() {
    let x = 0;
    x += await f1();
    x += await f2();
    x += await f3();

    return x;
}

הקוד עבר ניקוי בשביל להתאים לפוסט, אבל דמיינו שבגירסה האמיתית הפונקציות f1, f2 ו f3 תופסות כמה עשרות שורות כל אחת של חישובים מסובכים ושליפות מ DB, והפונקציה complexStuff גם היא קוראת לכל מיני מקורות מידע חיצוניים ומחברת את הכל לתוצאה אחת.

2. הבדיקה המזיקה

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

test('complexStuff', () => {
    jest.spyOn(util, 'f1').mockResolvedValue(5);
    jest.spyOn(util, 'f2').mockResolvedValue(9);
    jest.spyOn(util, 'f3').mockResolvedValue(2);

    expect(complexStuff()).resolves.toEqual(16);
});

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

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

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

3. מה עושים במקום

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

async function f1() { return 5; }
async function f2() { return 9; }
async function f3() { return 2; }

async function sumHelpers(...helpers) {
    let x = 0;
    for (let f of helpers) {
        x += await f();
    }

    return x;
}

async function complexStuff() {
    return sumHelpers(f1, f2, f3);
}

עכשיו אני יכול לכתוב תוכנית בדיקה שתוודא ש sumHelpers עושה את מה שהיא הבטיחה שתעשה, ותוכניות בדיקה נפרדות ל f1, f2 ו f3, ולהשאיר את complexStuff בלי בדיקה.

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