وبلاگ شخصی
محمّدرضا
علی حسینی

جایی که تجربیات, علایق
و چیزهایی که یادگرفته‌ام را
با هم مرور می‌کنیم.

اوّلین تجربه‌ی من از Test Driven Development- چرا باید همین حالا از TDD برای توسعه‌ی نرم‌افزار استفاده کنید

اوّلین تجربه‌ی من از Test Driven Development- چرا باید همین حالا از TDD برای توسعه‌ی نرم‌افزار استفاده کنید

من خیلی وقت بود که دوست‌داشتم برنامه‌هارا به صورت TDD یا همان Test Driven Development توسعه بدهم(به فارسی می‌توان گفت: توسعه آزمون محور، ولی من ترجیح می‌دهم که از همان مخفّف انگلیسی استفاده کنم).
این علاقه دلایل زیادی داشت. اوّل از همه اینکه ایده‌ی جالبی به‌نظر می‌رسید و برای من در اوّلین برخورد خیلی عجیب بود. انگار قرار بود کار را سر و ته انجام بدهم.
دلیل دوم هم این بود که تقریباً همه درمورد مزیّت‌های آن صحبت می‌کردند و من هم کنجکاو شده بودم که ببینم آیا واقعاً کار می‌کند یا نه.
بالأخره چند روز پیش که داشتم روی وب‌اپلیکیشن جامپلوی کار می‌کردم، تصمیم‌گرفتم که از این به بعد با TDD جلو بروم.
در این نوشته می‌خواهم اوّلین تجربه‌ام را از TDD با شما به اشتراک بگذارم. راستش این نوشته نه آموزش TDD است و نه آموزش انگولار. با این حال من در مورد هرکدام توضیح کوتاهی می‌دهم تا افرادی که آشنایی ندارند سردرگم نشوند.
اوّل برویم سراغ یک توضیح کوتاه درمورد اینکه اصلاً TDD چی هست. شاید روزی یک مطلب کامل فارسی درمورد TDD نوشتم.

TDD در ۱۰ ثانیه

TDD تکرار این ۶ مرحله هنگام نوشتن یک نرم‌افزار است:
۱-یک تست بنویس.
۲-تست‌را اجرا کن و شکست بخور.
۳-کد اصلی‌را بزن.
۴-تست‌هارا دوباره اجرا کن، ولی این بار دیگر شکست نخور.
۵-کدت‌را ریفکتور کن

خب امیدوارم ایده‌ی پشت TDD را گرفته باشید. حالا برویم سراغ تجربه‌ی من. درمورد اینکه چطوری می‌شود این مراحل‌را در انگولار(Angular) انجام داد پایین توضیح می‌دهم. هدفم این است که حتّی افرادی که با typescript و javascript آشنایی ندارند هم بفهمند که دارد چه اتّفاقی می‌افتد.

۱-آماده کردن محیط

من می‌خواستم که چندتا تابع برای validate کردن ورودی‌ها بنویسم. یکی برای اینکه مشخّص کند ورودی فارسی است، یکی برای اینکه مشخّص کند شماره تلفن همراهی که کاربر داده فرمت درستی دارد، یکی هم برای اینکه ببیند ایمیل وارد شده فرمت درستی دارد یا نه، که البته برای اینکه نوشته طولانی نشود دیگر این آخری‌را اینجا نیاورده ام.
اوّل از همه باید یک فایل جدید می‌ساختم تا این تابع‌ها درونش قرار بگیرند. اسم این فایل‌را گذاشتم: input-validator.ts.
در انگولار تست‌ها در فایل‌هایی قرار می‌گیرند که آخر اسمشان spec.ts. باشد. برای همین یک فایل دیگر هم به نام input-validator.spec.ts کنار فایل قبلی ساختم.
وقتی دستور زیر را اجرا کنیم، به‌صورت خودکار همه‌ی تست‌های تمامی فایل‌هایی که آخر اسمشان spec.ts. هست، اجرا می‌شوند.

ng test

۲-اوّلین تست

حالا زمان این است که اوّلین تستمان‌را بنویسیم. طبق مراحل TDD، اوّل باید تست نوشت و بعد خود برنامه‌را. پس قبل از اینکه شروع به نوشتن تابع is_persian کنم، که قرار است تشخیص دهد یک متن فارسی است یا نه، شروع کردم به نوشتن اوّلین تست برای آن.
برای نوشتن تست برای یک unit، کوچکترین واحد قابل تست، اوّل باید یک Test suite بسازیم.
در هر suite مجموعه‌ای از تست‌های مرتبط به یک جنبه‌ی مشخّص از نرم‌افزار قرار می‌گیرد.

انگولار و TDD

انگولار از فریمورک jasmine استفاده می‌کند. در این فریمورک از تابع describe برای ساخت suite استفاده می‌شود.
نمی‌خواهم وارد جزئیات بشوم ولی برای اینکه معنی کدهارا بفهمید با دو تا تکّه کد نحوه‌ی ساخت suite و test case را توضیح می‌دهم.
کد زیر یک suite می‌سازد که موقع اجرا با عنوان My Suite در نتایج مشخّص می‌شود.

describe(‘My Suite’, () => {
  // Tests will place here
});

حالا باید تست‌هارا بین آکولادها قرار داد.
هر تست با تابع it ساخته می‌شود. اوّل کد زیر را ببینید تا توضیح بدهم:

It(‘test description’, () => {
  expect(your code).toBe(expected value);
});

اوّلین پارامتر این تابع توضیحی درمورد تست است، چیزی که انتظار داریم رخ بدهد.
کد تست درون آکولادها قرار می‌گیرد.
تست‌ها با تابع expect ساخته می‌شوند. کدی که باید تست شود درون این تابع قرار می‌گیرد. این تابع Property های مختلفی دارد که کار assertion هارا انجام می‌دهند. پراستفاده‌ترین آن‌ها toBe است که مقداری که انتظار داریم برگردانده شود را با مقداری که کد درون expect واقعاً برگردانده مقایسه می‌کند. اگر مقادیر یکسان باشند تست pass شده است. در غیر این صورت به خاطر failure یک چراغ قرمز دریافت می‌کنیم.
خب دیگر حاشیه کافی است. بهتر است برویم سر مطلب خودمان.
قبل از اینکه شروع کنم و تابع‌را بنویسم اوّلین تست‌را برای آن نوشتم. با خودم گفتم در ساده‌ترین حالت این تابع باید وقتی که یک string از حروف فارسی دریافت می‌کند مقدار true برگرداند. این رشته هم هیچ‌چیزی به جز حروف فارسی ندارد, حتّی whitespace.
پس برای شروع یک test suite به فایل input-validator.spec.ts اضافه کردم و داخلش یک test case ساده نوشتم:

import {is_persian} from './input-validator';

describe('Test is_persian function that should return true on persian strings and false on others', () => {
  it('Test Persian String without whitespace and english letters', () => {
    expect(is_persian('سلام')).toBe(true);
  });
});

برای اینکه به خاطر نبودن تابعی به نام is_persian برنامه به ارور نخورد, در فایل input-validator.ts کد زیر را اضافه کردم:

export function is_persian(value: string) {
}

خب حالا تست‌هارا اجرا کردم. یک پنجره‌ی chrome باز شد و حاصل اجرای تست‌هارا به شکل گرافیکی نمایش داد(با کلیک روی عکس‌ها آن‌ها را در اندازه‌ی بزرگ و در پنجره‌ی جدید خواهید دید):

شروع TDD با انگولار اولین مرحله

خوشبختانه تست شکست خورد. پس باموفّقیّت مرحله‌ی دوم TDD را هم پشت سر گذاشتم.

۴-نوشتن کوچکترین بخشی از تابع که تست را پاس می‌کند

در مرحله‌ی سوم باید فقط به اندازه‌ای که تست پاس بشود کد بزنیم. البته از آنجایی که این تابع کار خیلی ساده‌ای را قرار است انجام بدهد, عملاً کوچکترین بخش کل تابع می‌شود.
با این حساب شروع کردم به نوشتن تابع. اینطوری که یک ثابت تعریف کردم که محدوده‌ی یونیکد حروف فارسی و علامت‌هایی مثل تشدید و … را شامل می‌شد.
بعد هم با استفاده از آن درون تابع یک regex تعریف کردم که ورودی‌را روی پترنش تست می‌کرد.

const charRange = '[\\s,\u06A9\u06AF\u06C0\u06CC\u060C\u062A\u062B\u062C\u062D\u062E\u062F\u063A' +
                   '\u064A\u064B\u064C\u064D\u064E\u064F\u067E\u0670\u0686\u0698\u200C\u0621-\u0629' +
                   '\u0630-\u0639\u0641-\u0654]';
export function is_persian(value: string) {
  const regex = new RegExp('^' + charRange + '

چون با دستور ng test برنامه‌را اجرا کرده بودم به صورت خودکار با هر تغییر تست‌ها دوباره اجرا می‌شدند. پس بدون اینکه لازم باشد کاری بکنم پنجره‌ی کروم را باز کردم تا نتیجه‌را ببینم:

شروع TDD با Angular اولین شکست

کار نکرد. 🙂 کدی که زدم باید ایراد داشته باشد. ایرادی که اگر نمی‌خواستم از TDD استفاده کنم و برای تابع unit test بنویسم حالاحالا ها پیدا نمی‌شد. مطمئناً اگر می‌خواستم مثل قبل عمل کنم, باید تا موقعی که این تابع به ورودی گرفته شده از UI متّصل می‌شد صبر می‌کردم. بعد آن وقت که کار نمی‌کرد باید همه‌ی اجزایی که به این بخش ربط داشتند(خود UI, کامپوننت‌ها و…)را با console و این جور چیزها تست می‌کردم. یعنی نوشتن همین تست کوچک حداقل نیم‌ساعت زمان من‌را نجات داد. واقعاً کیف کردم. ولی همراه با این خوشحالی باید مشکل‌را هم برطرف می‌کردم.

۵-و حالا اوّلین چراغ سبز

پترن‌را یک بار دیگر چک کردم. دیدم یادم رفته است که قبل از $ یک + قرار بدهم. بدون آن + این پترن فقط یک حرف فارسی‌را درست تشخیص می‌داد. پس وقتی طول ورودی بیشتر از یک بود false بر می‌گرداند.

export function is_persian(value: string) {
  const regex = new RegExp('^' + charRange + '+

شروع TDD با انگولار اولین تست صحیح

کار کرد. این اوّلین unit test پاس شده زندگیم بود. دیدن آن چراغ سبز(همان دایره‌ی سبزی که زیر کلمه‌ی Jasmine قرار دارد) به آدم انرژی می‌دهد تا به سراغ تست بعدی برود. اینکه در بازه‌های زمانی کوتاه آدم جایزه(همان چراغ سبز) می‌گیرد, باعث می‌شود که انگیزه‌اش برای ادامه‌ی کار افزایش پیدا کند. مثل این است که فرآیند توسعه‌ی نرم‌افزار را با استفاده از گیم‌فیکشن جذاب‌تر کنید.

۶-حالا از اوّل

حالا باید تمامی مراحل‌را از اوّل تکرار کرد. این بار تابع باید نوشته‌های فارسی‌ای که در آن‌ها whitespace وجود دارد را هم قبول کند. چون محل قرارگیری کاراکترها در regex من فرقی نمی‌کند, پس ۳ حالت مختلف‌را در یک تست بررسی کردم:

it('#Test Persian Strings with space at beginning, middle and end', () => {
  expect(is_persian(' سلام')).toBe(true);
  expect(is_persian('سلا م')).toBe(true);
  expect(is_persian('سلام ')).toBe(true);
});

چون jasmine وقتی که تستی رد می‌شود, عنوان suite و تست را بدون جدا کننده پشت هم می‌نویسد, برای خوانایی بیشتر تصمیم‌گرفتم که اوّل عنوان تست‌ها علامت # را اضافه کنم(کاری که در داکیومنت‌های انگولار انجام شده).

شروع TDD با Angular2 تست موفق

تا اینجا که همه‌چیز خوب بود.

۷-یکبار دیگر از اوّل

دیدم هنوز هم اسم‌گذاری زیاد خوب نیست. تست باید طوری نوشته شود که با نگاه کردن به آن دقیقاً فهمید که کد چه کاری انجام می‌دهد. در فرمت فعلی این کار یکم سخت است.
برای همین تصمیم‌گرفتم که از این به بعد عنوان تست‌هارا با فرمت زیر بنویسم:

#unitName Should …(expected behavior )

این هم یونیت تست بعدی:

it('#is_persian Should return false if input containing persian digits.', () => {
  expect(is_persian('سلام۱')).toBe(false);
});

شروع TDD با انگولار2 نوشتن چند یونیت تست

۸-خلاصه‌اش کنیم و آخرش‌را ببینیم

برای اینکه نوشته زیادی طولانی نشود نتیجه‌ی نهایی تست‌های تابع is_persian را بدون توضیحات اضافی می‌بینیم:

import {is_persian} from './input-validator';
describe('Test is_persian function that should return true on persian strings and false on others.', () => {
  it('#Test Persian String without whitespace and english letters.', () => {
    expect(is_persian('سلام')).toBe(true);
  });
  it('#Test Persian Strings with space at beginning, middle and end.', () => {
    expect(is_persian(' سلام')).toBe(true);
    expect(is_persian('سلا م')).toBe(true);
    expect(is_persian('سلام ')).toBe(true);
  });
  it('#is_persian Should return false if input containing persian digits.', () => {
    expect(is_persian('سلام۱')).toBe(false);
  });
  it('#is_persian Should return false if input containing english digits', () => {
    expect(is_persian('سلام2')).toBe(false);
  });
  it('#is_persian Should return false if input containing english letters.', () => {
    expect(is_persian('سلl')).toBe(false);
  });
  it('#is_persian Should return true if string containing persian letters and tab.', () => {
    expect(is_persian('سل\tم')).toBe(true);
  });
});

۹- تابع بعدی

بعد از اینکه تابع قبلی کامل شد, شروع به نوشتن تابع اعتبارسنجی شماره تلفن کردم. مراحل نوشتن این تابع‌را به این خاطر می‌نویسم که برای من اینجا هم نکات جالبی درمورد TDD وجود داشت.
درست مثل قبل بدنه‌ی تابع‌را برای ارور نخوردن اضافه کردم:

export function is_valid_phone_number(value: string) {
}

موقعی که می‌خواستم تست‌هارا اضافه کنم, دیدم بهتر است باز هم نحوه‌ی اسم‌گذاری را عوض کنم.
حاصل دیدن نتیجه‌ی شکست‌ها و موفقیت‌ها این بود که بهتر است عنوان suite را تنها به اسم تابع یا کلاسی که قرار است تست شود اختصاص بدهم.
برای عنوان تست هم طبق همان فرمتی که در مرحله‌ی ۷ به آن رسیدم عمل کنم.
پس اوّلین تست برای تابع is_valid_phone_number را اینطوری نوشتم(اسم‌گذاری عجیب و غریب این تابع‌هارا بر من ببخشید. به‌خاطر حفظ پترن کلّی اسم توابع با کتابخانه‌ی خارجی‌ای که در پروژه استفاده می‌شد چیزی بهتر از این به ذهنم نرسید).

describe('is_valid_phone_number', () => {
  it('#Should return true if a valid phone entered.', () => {
    expect(is_valid_phone_number('09126547325')).toBe(true);
  });
});

شروع Test driven development با انگولار شکست اولین تست

باز هم مثل همیشه اوّلین تست شکست خورد.

۱۰-برای بار دوم در یک روز TDD زندگی من‌را نجات داد

برای اینکه تست قبلی پاس شود کد زیر را زدم:

export function is_valid_phone_number(value: string) {
  return /09\d{9}/.test(value);
}

برای اینکه حوصله‌تان سر نرود مراحلی که این بین اتفاق افتاده است را رد کردم تا به جایی برسم که test suite این تابع این شکلی شد:

describe('is_valid_phone_number', () => {
  it('#Should return true if a valid phone entered.', () => {
    expect(is_valid_phone_number('09126547325')).toBe(true);
  });

  it('#Should return false if number starts with +', () => {
    expect(is_valid_phone_number('+99126547325')).toBe(false);
  });

  it('#Should return false if number start with any number except zero', () => {
    expect(is_valid_phone_number('999126547325')).toBe(false);
  });

  it('#Should return false if pattern is correct but the length is less than 11', () => {
    expect(is_valid_phone_number('091265473')).toBe(false);   // Test odd length
    expect(is_valid_phone_number('0912654732')).toBe(false);   // Test even length
  });

  it('#Should return false if pattern is correct but the length is greater than 11', () => {
    expect(is_valid_phone_number('0912654732112')).toBe(false);   // Test odd length
    expect(is_valid_phone_number('091265473211')).toBe(false);   // Test even length
  });

});

در این مرحله دوباره یکی از تست‌هام شکست خورد.

اولین تجربه TDD با انگولار fail شدن unit test

اطّلاعاتی که در stacktrace هست نشان می‌داد که هر دو مورد آخرین تستم شکست خورده بود. بدون اغراق با دیدن این ارور خیلی خوشحال شدم. چون کاملاً مطمئن بودم که اگر نمی‌خواستم unit test بنویسم, هرگز این حالت به ذهنم نمی‌رسید.
حالا شما فرض کنید این کد غلط می‌رفت زیر دست کاربر و سیستم با کلّی مشکل روبه‌رو می‌شد.

۱۱-کدی که واقعاً درست است

بعد از مقداری تأمّل و تفکّر, فهمیدم که آخر پترنم یک $ جاگذاشتم. پس کد را درست کردم و دوباره زندگی زیبا شد.

export function is_valid_phone_number(value: string) {
  return /09\d{9}$/.test(value);
}

۱۲-آخرین قدم

حالا من می‌خواستم که اگر ورودی با اعداد فارسی بود, باز هم کد من بتواند پترن درست‌را تشخیص بدهد.
پس نشستم و همه‌ی کارهای قبلی‌را برای اعداد فارسی هم انجام دادم. فقط چون می‌خواستم خوانایی تست‌ها بالاتر برود, برای هرکدام از حالات فارسی و انگلیسی یک suite جدا درنظر گرفتم. نتیجه‌ی نهایی همه‌ی این حرف‌هایی که زدم شد کدهای زیر:

describe('is_valid_phone_number(Fa input)', () => {

  it('#Should return true if a valid phone number entered.', () => {
    expect(is_valid_phone_number('۰۹۱۲۶۶۳۲۹۸۶')).toBe(true);
  });

  it('#Should return false if number starts with +', () => {
    expect(is_valid_phone_number('+۹۱۲۶۶۳۲۹۸۶')).toBe(false);
  });

  it('#Should return false if number start with any number except zero', () => {
    expect(is_valid_phone_number('۹۹۱۲۶۶۳۲۹۸۶')).toBe(false);
  });

  it('#Should return false if pattern is correct when the length is less than 11', () => {
    expect(is_valid_phone_number('۰۹۱۲۶۶۳۲۹')).toBe(false);   // Test odd length
    expect(is_valid_phone_number('۰۹۱۲۶۶۳۲۹۸')).toBe(false);   // Test even length
  });

  it('#Should return false if pattern is correct when the length is greater than 11', () => {
    expect(is_valid_phone_number('۰۹۱۲۶۶۳۲۹۳۴۱۲')).toBe(false);   // Test odd length
    expect(is_valid_phone_number('۰۹۱۲۶۶۳۲۹۲۱۲')).toBe(false);   // Test even length
  });

  it('#Should return false if input containing letters.', () => {
    expect(is_valid_phone_number('۰۹۱۲۶۶۳س۹')).toBe(false);
  });

});

این هم تابع نهایی:

const faNumberRange = '[\u06F0-\u06F9]';

export function is_valid_phone_number(value: string) {
  const faNums = new RegExp('۰۹' + faNumberRange + '{9}

این تجربه‌ی من از اوّلین برخورد با TDD و استفاده از unit test هنگام نوشتن برنامه بود. باید بگویم که واقعاً جذب این متد شدم. چون به چشم خودم دیدم که واقعاً کار می‌کند. درست است که ما هرگز نمی‌توانیم از کارکرد درست نرم‌افزار به صورت ۱۰۰٪ مطمئن بشویم, ولی استفاده از TDD  در همین دو تا تابع کوچک باعث شد که من اشتباهات خیلی مهمی‌را انجام ندهم. به‌علاوه اگر می‌خواستم مثل همیشه برنامه‌را تست کنم, فهمیدن مشکل همین دو تا تابع خیلی کوچک ممکن بود چندین ساعت زمان بگیرد. پس از امروز به بعد تصمیم دارم که تقریباً همیشه با TDD کد بزنم، چون این کار زمان زیادی‌را برایم حفظ می‌کند. امیدوارم که الهه‌ی TDD این قربانی‌را از من بپذیرد. اگر شما تجربه‌ی خاصی از به‌کار بردن TDD دارید, ابزارهای خوبی‌را می‌شناسید یا هنوز درموردش شک دارید لطفاً در بخش نظرات با من و بقیه‌ی خوانندگان نظر باارزشتان‌را مطرح کنید.

احترام به کپی رایت

پایه‌ی تصویر این نوشته یک فایل لایه باز است که به خاطر درخواست سایت منتشر کننده لینکشان‌را آنطوری که خودشان گفته‌اند این پایین می‌گذارم:

Designed by Freepik

«نوشته‌های مرتبط»

پاسخی بگذارید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

«نوشته‌های ویژه»

«نوشته‌های محبوب»

«دیدگاه کاربران»