פיתוח מונחה מבחנים הפך פופולרי בשנים האחרונות. מתכנתים רבים ניסו טכניקה זו, נכשלו והגיעו למסקנה כי TDD אינו שווה את המאמץ הדרוש.
יש מתכנתים שחושבים שבאופן תיאורטי זה נוהג טוב, אך לעולם אין מספיק זמן להשתמש באמת ב- TDD. ואחרים חושבים שזה בעצם בזבוז זמן.
אם אתה מרגיש ככה, אני חושב שאולי לא תבין מהו TDD באמת. (בסדר, המשפט הקודם היה למשוך את תשומת ליבכם). יש ספר טוב מאוד על TDD, Test Driven Development: By Example, מאת קנט בק, אם אתה רוצה לבדוק את זה וללמוד עוד.
במאמר זה אעבור על יסודות הפיתוח מונע הבדיקה, תוך התייחסות לתפיסות מוטעות נפוצות לגבי טכניקת TDD. מאמר זה הוא גם הראשון מתוך מספר מאמרים שאפרסם, הכל על פיתוח מונע מבחן.
מדוע להשתמש ב- TDD?
ישנם מחקרים, עבודות ודיונים על מידת היעילות של TDD. למרות שזה בהחלט שימושי שיש מספרים מסוימים, אני לא חושב שהם עונים על השאלה מדוע עלינו להשתמש ב- TDD מלכתחילה.
תגיד שאתה מפתח אתרים. סיימת זה עתה תכונה קטנה. האם אתה מחשיב את זה מספיק כדי לבדוק תכונה זו רק על ידי אינטראקציה ידנית עם הדפדפן? אני לא חושב שזה מספיק להסתמך רק על בדיקות שנעשו על ידי מפתחים באופן ידני. למרבה הצער זה אומר שחלק מהקוד אינו מספיק טוב.
אך השיקול לעיל נוגע לבדיקה, ולא TDD עצמה. אז למה TDD? התשובה הקצרה היא "מכיוון שזו הדרך הפשוטה ביותר להשיג גם קוד באיכות טובה וגם כיסוי בדיקה טוב".
התשובה הארוכה יותר נובעת ממה באמת TDD ... נתחיל עם הכללים.
חוקי המשחק
דוד בוב מתאר את TDD בשלושה כללים:
- אינך רשאי לכתוב קוד ייצור כלשהו אלא אם כן לעבור בדיקת יחידה נכשלת. - אינך רשאי לכתוב מבחן יחידה יותר מכפי שמספיק כדי להיכשל; וכשלים באוסף הם כשלים. - אינך רשאי לכתוב קוד ייצור יותר מכפי שמספיק כדי לעבור את מבחן היחידה הכושל.אני גם אוהב גרסה קצרה יותר, שמצאתי כאן:
- כתוב רק מבחן יחידה כדי להיכשל. - כתוב רק קוד ייצור מספיק כדי לעבור את מבחן היחידה הכושלת.כללים אלה פשוטים, אך אנשים המתקרבים ל- TDD מפרים לעתים קרובות אחד או יותר מהם. אני מאתגר אותך: האם אתה יכול לכתוב פרוייקט קטן לפי כללים אלה בהחלט ? בפרויקט קטן אני מתכוון למשהו אמיתי, לא רק לדוגמא שדורשת כמו 50 שורות קוד.
כללים אלה מגדירים את המכניקה של TDD, אך הם בהחלט לא כל מה שאתה צריך לדעת. למעשה, תהליך השימוש ב- TDD מתואר לעיתים קרובות כמחזור אדום / ירוק / רפקטור. בואו נראה במה מדובר.
מחזור רפקטור אדום ירוק

שלב אדום
בשלב האדום עליכם לכתוב מבחן על התנהגות שאתם עומדים ליישם. כן, כתבתי התנהגות . המילה "מבחן" בפיתוח מונע מבחן מטעה. היינו צריכים לקרוא לזה מלכתחילה "התפתחות מונעת התנהגות". כן, אני יודע, יש אנשים שטוענים ש- BDD שונה מ- TDD, אבל אני לא יודע אם אני מסכים. אז בהגדרה הפשוטה שלי, BDD = TDD.
הנה באה תפיסה מוטעית נפוצה אחת: "ראשית אני כותב כיתה ושיטה (אך ללא יישום), ואז אני כותב מבחן לבדיקת שיטת הכיתה הזו". זה למעשה לא עובד ככה.
בואו ניקח צעד אחורה. מדוע הכלל הראשון של TDD מחייב שתכתוב מבחן לפני שתכתוב קטע כלשהו של קוד ייצור? האם אנחנו אנשים TDD מטורפים?
כל שלב במחזור ה- RGR מייצג שלב במחזור החיים של הקוד ואיך אתה יכול להתייחס אליו.
בשלב האדום אתה מתנהג כאילו אתה משתמש תובעני שרוצה להשתמש בקוד שעומד להיכתב בצורה הפשוטה ביותר. עליכם לכתוב מבחן המשתמש בקטע קוד כאילו הוא כבר מיושם. תשכחו מהיישום! אם בשלב זה אתה חושב כיצד אתה מתכוון לכתוב את קוד הייצור, אתה עושה זאת לא נכון!
בשלב זה אתה מתרכז בכתיבת ממשק נקי למשתמשים עתידיים. זה השלב שבו אתה מתכנן את אופן השימוש בקוד שלך על ידי הלקוחות.
כלל ראשון זה הוא החשוב ביותר והוא הכלל שמבדיל את TDD מהבדיקה הרגילה. אתה כותב מבחן כדי שתוכל לכתוב קוד ייצור. אתה לא כותב מבחן לבדיקת הקוד שלך.
בואו נסתכל על דוגמא.
// LeapYear.spec.jsdescribe('Leap year calculator', () => { it('should consider 1996 as leap', () => { expect(LeapYear.isLeap(1996)).toBe(true); });});
הקוד שלעיל הוא דוגמה לאופן שבו בדיקה עשויה להראות ב- JavaScript, באמצעות מסגרת הבדיקה של יסמין. אתה לא צריך להכיר את יסמין - מספיק להבין שזו it(...)
בדיקה וזו expect(...).toBe(...)
דרך לגרום ליסמין לבדוק אם משהו כצפוי.
במבחן לעיל בדקתי שהפונקציה LeapYear.isLeap(...)
חוזרת true
לשנת 1996. אתה עשוי לחשוב ש -1996 הוא מספר קסם ולכן הוא נוהג רע. זה לא. בקוד הבדיקה מספרים קסומים טובים, ואילו בקוד ייצור יש להימנע מהם.
למבחן זה יש למעשה כמה השלכות:
- שם מחשבון השנה המעוברת הוא
LeapYear
isLeap(...)
היא שיטה סטטית שלLeapYear
isLeap(...)
לוקח מספר (ולא מערך, למשל) כארגומנט ומחזירtrue
אוfalse
.
זה מבחן אחד, אך למעשה יש לו השלכות רבות! האם אנו זקוקים לשיטה שתדע אם שנה היא שנה מעוברת, או האם אנו זקוקים לשיטה המחזירה רשימה של שנות מעוברות בין תאריך התחלה לתום סיום? האם שם האלמנטים הוא בעל משמעות? אלו סוגי השאלות שעליך לזכור בעת כתיבת מבחנים בשלב האדום.
בשלב זה עליכם לקבל החלטות לגבי אופן השימוש בקוד. אתה מבסס את זה על מה שאתה באמת צריך כרגע ולא על מה שלדעתך עשוי להיות נחוץ.
הנה טעות נוספת: אל תכתוב חבורה של פונקציות / שיעורים שאתה חושב שתזדקק להם. התרכז בתכונה שאתה מיישם ובמה שנדרש באמת. לכתוב משהו שהתכונה אינה דורשת הוא הנדסת יתר.
מה לגבי הפשטה? נראה את זה אחר כך, בשלב הפקטור.
שלב ירוק
זה בדרך כלל השלב הקל ביותר, מכיוון שבשלב זה אתה כותב קוד (ייצור). אם אתה מתכנת, אתה עושה את זה כל הזמן.
הנה באה טעות גדולה נוספת: במקום לכתוב מספיק קוד כדי לעבור את המבחן האדום, אתה כותב את כל האלגוריתמים. תוך כדי פעולה זו, אתה בוודאי חושב מהו היישום המבצע ביותר. אין סיכוי!
בשלב זה עליכם להתנהג כמו מתכנת שיש לו משימה אחת פשוטה: לכתוב פיתרון ישר שגורם למבחן לעבור (והופך את האדום המדאיג בדוח הבדיקה לירוק ידידותי). בשלב זה, אתה רשאי להפר שיטות עבודה מומלצות ואף לשכפל קוד. שכפול קוד יוסר בשלב הרפקטור.
אבל מדוע יש לנו את הכלל הזה? מדוע איני יכול לכתוב את כל הקוד שכבר נמצא במוחי? משתי סיבות:
- A simple task is less prone to errors, and you want to minimize bugs.
- You definitely don’t want to mix up code which is under testing with code that is not. You can write code that is not under testing (aka legacy), but the worst thing you can do is mixing up tested and untested code.
What about clean code? What about performance? What if writing code makes me discover a problem? What about doubts?
Performance is a long story, and is out of the scope of this article. Let’s just say that performance tuning in this phase is, most of the time, premature optimization.
The test driven development technique provides two others things: a to-do list and the refactor phase.
The refactor phase is used to clean up the code. The to-do list is used to write down the steps required to complete the feature you are implementing. It also contains doubts or problems you discover during the process. A possible to-do list for the leap year calculator could be:
Feature: Every year that is exactly divisible by four is a leap year, except for years that are exactly divisible by 100, but these centurial years are leap years if they are exactly divisible by 400.
- divisible by 4- but not by 100- years divisible by 400 are leap anyway
What about leap years in Julian calendar? And years before Julian calendar?
The to-do list is live: it changes while you are coding and, ideally, at the end of the feature implementation it will be blank.
Refactor phase
In the refactor phase, you are allowed to change the code, while keeping all tests green, so that it becomes better. What “better” means is up to you. But there is something mandatory: you have to remove code duplication. Kent Becks suggests in his book that removing code duplication is all you need to do.
In this phase you play the part of a picky programmer who wants to fix/refactor the code to bring it to a professional level. In the red phase, you’re showing off your skills to your users. But in the refactor phase, you’re showing off your skills to the programmers who will read your implementation.
Removing code duplication often results in abstraction. A typical example is when you move two pieces of similar code into a helper class that works for both the functions/classes where the code has been removed.
For example the following code:
class Hello { greet() { return new Promise((resolve) => { setTimeout(()=>resolve('Hello'), 100); }); }}class Random { toss() { return new Promise((resolve) => { setTimeout(()=>resolve(Math.random()), 200); }); }}new Hello().greet().then(result => console.log(result));new Random().toss().then(result => console.log(result));
could be refactored into:
class Hello { greet() { return PromiseHelper.timeout(100).then(() => 'hello'); }}class Random { toss() { return PromiseHelper.timeout(200).then(() => Math.random()); }}class PromiseHelper { static timeout(delay) { return new Promise(resolve => setTimeout(resolve, delay)); }}const logResult = result => console.log(result);new Hello().greet().then(logResult);new Random().toss().then(logResult);
As you can see, in order to remove thenew Promise
and setTimeout
code duplication, I created a PromiseHelper.timeout(delay)
method, which serves both Hello
and Random
classes.
Just keep in mind that you cannot move to another test unless you’ve removed all the code duplication.
Final considerations
In this section I will try to answer to some common questions and misconceptions about Test Drive Development.
- T.D.D. requires much more time than “normal” programming!
What actually requires a lot of time is learning/mastering TDD as well as understanding how to set up and use a testing environment. When you are familiar with the testing tools and the TDD technique, it actually doesn’t require more time. On the contrary, it helps keep a project as simple as possible and thus saves time.
- How many test do I have to write?
The minimum amount that lets you write all the production code. The minimum amount, because every test slows down refactoring (when you change production code, you have to fix all the failing tests). On the other hand, refactoring is much simpler and safer on code under tests.
- With Test Driven Development I don’t need to spend time on analysis and on designing the architecture.
This cannot be more false. If what you are going to implement is not well-designed, at a certain point you will think “Ouch! I didn’t consider…”. And this means that you will have to delete production and test code. It is true that TDD helps with the “Just enough, just in time” recommendation of agile techniques, but it is definitely not a substitution for the analysis/design phase.
- Should test coverage be 100%?
No. As I said earlier, don’t mix up tested and untested code. But you can avoid using TDD on some parts of a project. For example I don’t test views (although a lot of frameworks make UI testing easy) because they are likely to change often. I also ensure that there is very a little logic inside views.
- I am able to write code with very a few bugs, I don’t need testing.
You may able to to that, but is the same consideration valid for all your team members? They will eventually modify your code and break it. It would be nice if you wrote tests so that a bug can be spotted immediately and not in production.
- TDD works well on examples, but in a real application a lot of the code is not testable.
I wrote a whole Tetris (as well as progressive web apps at work) using TDD. If you test first, code is clearly testable. It is more a matter of understanding how to mock dependencies and how to write simple but effective tests.
- Tests should not be written by the developers who write the code, they should be written by others, possibly QA people.
If you are speaking about testing your application, yes it is a good idea to ask other people to test what your team did. If you are speaking about writing production code, then that’s the wrong approach.
What’s next?
This article was about the philosophy and common misconceptions of TDD. I am planning to write other articles on TDD where you will see a lot of code and fewer words. If you are interested on how to develop Tetris using TDD, stay tuned!