تست‌نویسی در javascript/typescript — بخش ۲: تست واحد یا unit test

تست‌نویسی در javascript/typescript — بخش ۲: تست واحد یا unit test

آذر ۲۰, ۱۴۰۴

در این مقاله، قصد داریم وارد دنیای تست واحد (Unit Testing) بشیم. تست واحد یکی از پایه‌ای‌ترین و مهم‌ترین انواع تست‌های نرم‌افزاریه که در اون فقط یک واحد کوچیک از کد (معمولاً یک تابع یا متد) به صورت ایزوله تست می‌شه.

تست واحد به ما کمک می‌کنه تا:

  • خطاهای اولیه رو سریع شناسایی کنیم
  • تغییرات ایمن داشته باشیم، یعنی وقتی کدمون رو میخواهیم تغییر بدیم، دست و دلمون نلرزه که چندجای دیگه بترکه!
  • اعتماد به نفس بیشتری در ریلیز کردن نسخه‌ها داشته باشیم

توسعه مبتنی بر تست (Test-Driven Development - TDD)

قبل از اینکه وارد نوشتن تست‌ها بشیم، واسه اینکه تاکید کنم، تست نوشتن چقدر می تونه مهم باشه باید بگم اصلا ما یک متدولوژی توسعه داریم که برمبنای تست نویسیه و اسمش توسعه مبتنی بر تست یا TDD عه.

توی متدولوژی TDD ما اول تست می‌نویسیم، بعد کد. درباره اینکه چه موقعیه اوکیه این متدولوژی رو استفاده کنیم و چه موقع foul عه بعدا صحبت می کنیم ولی اول بذارید توضیح بدم که چقدر بامزه است، توسعه این سبکی!

چرخه Red-Green-Refactor

متدولوژی TDD رو اینجوری پیاده می‌کنند:

  1. قرمز (Red): اول یک تست برای تیکه کدمون می‌نویسیم که شکست می‌خوره
  2. سبز (Green): حداقل کد ممکن رو می‌نویسیم تا تست موفق بشه
  3. بازنگری (Refactor): کد رو بهبود می‌دیم بدون اینکه تست‌ها شکست بخورن

مثال عملی TDD

فرض کنید می‌خوایم یک تابع برای محاسبه فاکتوریل بنویسیم. دقت کنید ما تابع factorial رو هنوز ننوشتیم و تازه می‌خواهیم شروع کنیم به نوشتنش. متد TDD به ما میگه اول تستش رو خرد خرد بنویس تا به خودش برسی!

مرحله ۱: قرمز (نوشتن تست شکست خورده)

اول تست رو می‌نویسیم: (فعلا بیخیال syntax اش، میگیم جلوتر چیه داستانش!)

describe('factorial', () => {
  it('should calculate factorial of 5', () => {
    expect(factorial(5)).toBe(120);
  });
});

این تست حتماً شکست می‌خوره چون تابع factorial هنوز وجود نداره!

مرحله ۲: سبز (نوشتن حداقل کد برای موفقیت)

حالا حداقل کد ممکن رو می‌نویسیم:

export const factorial = (n) => {
  return 120;
};

تست موفق می‌شه! ولی این کد قاعدتا درست نیست.

مرحله ۳: بازنگری (افزایش تست‌ها و بهبود کد)

حالا تست‌های بیشتری اضافه می‌کنیم:

describe('factorial', () => {
  it('should calculate factorial of 0', () => {
    expect(factorial(0)).toBe(1);
  });

  it('should calculate factorial of 1', () => {
    expect(factorial(1)).toBe(1);
  });

  it('should calculate factorial of 5', () => {
    expect(factorial(5)).toBe(120);
  });
});

حالا برای پاس شدن تست‌ها، کد رو بهبود می‌دیم:

export const factorial = (n) => {
  if (n === 0 || n === 1) {
    return 1;
  }
  return n * factorial(n - 1);
};

مزایای TDD

  • کیفیت بالاتر: کدی که با TDD نوشته می‌شه معمولاً باگ کمتری داره
  • طراحی بهتر: TDD به شما کمک می‌کنه APIهای بهتری طراحی کنید
  • اعتماد به نفس بیشتر: با تست‌های جامع، جرئت تغییر و بهبود کد رو دارید
  • مستندسازی: تست‌ها به عنوان مستندات اجرایی عمل می‌کنن

معایب و چالش‌های TDD

  • زمان بیشتر: اول امر زمان بیشتری می‌بره
  • منحنی یادگیری شیب دار: یادگیری TDD نیاز به تمرین داره
  • برای همه چیز مناسب نیست: نوشتن تست با این روش، برای بعضی از قسمت‌های کد (مثل UI) مناسب نیست.

به عنوان یه rule of thumb اگر بخام بگم، تست TDD رو زمانی بنویسید که می‌دونید برای هر ورودی دقیقا چه خروجی قابل انتظار است، مثلا در validation ها یا state machine ها یا توابع ریاضی.

حالا که با TDD آشنا شدیم، بیاید وارد نوشتن اولین تست‌هامون بشیم!

شروع به نوشتن تست‌ها

خب اول از همه اگر پروژه ندارید یه پروژه با روال زیر بسازید و dependency ها رو نصب کنید. اگرم در حال حاضر هر نوع پروژه JavaScript یا typescript دارید، کافیه فقط dependency رو نصبش کنید.

npm init -y
npm install -D vitest

حالا فایل زیر رو با عنوان ‌helloWorld.test.ts مثلا ذخیره کنید.

import { test, expect } from 'vitest';

test('is a super simple test', () => {
  expect(true).toBe(true);
});

می‌تونید این تست رو با اجرای npx vitest از خط فرمان اجرا کنید و می‌بینید که تست‌ پاس میشه. اگر بخوام این فایل رو توضیح بدم:

یه تابع به نام test وجود داره که دو تا آرگومان می‌گیره: 1. یه رشته که نام تست رو نشون میده 2. یه تابع که بدنه تست رو شامل می‌شه

داخل اون تابع، از یه کتابخونه assert برای بیان انتظاراتمون استفاده می‌کنیم 1. در اینجا، انتظار داریم این دو تا چیز با هم برابر باشن 2. و اون‌ها هستن!

یک مثال دیگه

حالا بیاید یه مثال واقعی‌تر بررسی کنیم:

test('another test, but with some logic', () => {
  expect(1 + 1).toBe(2);
});

test('a test with a function', () => {
  const add = (a, b) => a + b;
  expect(add(1, 2)).toBe(3);
});

نکته مهم اینه که، وقتی یک تست مثل فایل بالا می‌نویسیم، می‌تونیم (نه اینکه باید!) هر تعداد خواستیم از این (...)test ها داشته باشیم . اینجوری می‌تونیم مطمئن بشیم هر سناریویی رو پوشش دادیم. وظیفه تست runner (در اینجا npx vitest) اینه که تست ما رو اجرا کنه و مطمئن بشه که همه چیز درست کار می‌کنه.

نحوه کار تست‌ها

یه تست فقط به خاطر اینکه شکست نخورده، قبول می‌شه.

این به این معنیه که اگه یه تست شکست بخوره، یعنی یه error انداخته شده. پس وقتی می‌گیم تست قبول شده، یعنی هیچ errorای ننداخته!

تستی که انتظار داریم شکست بخوره با test.fail

می‌تونیم از .test.fails برای تست‌هایی استفاده کنیم که انتظار داریم شکست بخورن:

test.fails('works with "test" as well', () => {
  expect(true).toBe(false);
});

این مثال ساده نشون می‌ده که چطور می‌تونیم تست‌هایی بنویسیم که انتظار داریم شکست بخورن.

نادیده گرفتن تست با it.skip

اگر می‌خواید یه تست خاص رو موقتاً اجرا نکنید (مثلاً در حال کار روی اون بخش هستید یا تست مشکل داره):

it.skip('this test is temporarily disabled', () => {
  expect(someFunction()).toBe('expected result');
});

اجرای فقط یه تست با it.only

اگر می‌خواید فقط یه تست خاص رو اجرا کنید و بقیه تست‌ها رو نادیده بگیرید (مثلاً وقتی روی یه بخش خاص کار می‌کنید و می‌خواید فقط تست مربوط به اون بخش رو ببینید):

it.only('this is the only test that will run', () => {
  expect(add(2, 3)).toBe(5);
});

به جای test در vitest میشه it هم نوشت و فرقی ندارند و به کرات به جای هم استفاده میشن، فلذا گیج نشید!

تست کد Async

خب فرض کنیم یه کد async رو بخواهیم تست کنیم. تست زیر به اشتباه قبول می‌شه، چون تابع بالادست setTimeout یه تابع سنکرونه و منتظر resolve این تایم اوت و رسیدن به assertion نمیشه. (رجوع به مفاهیم async)

it('it will pass, unfortunately', () => {
  setTimeout(() => {
    expect('This should fail.').toBe('Totally not the same.');
  }, 1000);
});

خب راه حلش اینه که به سادگی از async/await استفاده کنیم:

test('works with "test" as well', async () => {
  const result = await addAsync(2, 3);
  expect(result).toBe(5);
});

پیاده‌سازی تست‌های پایه ریاضی

بیایین یه مثال از توابع ریاضی پیاده کنیم و با استفاده از TDD تستشون کنیم. تابع ما add هست:

// arithmetic.js
export const add = (a, b) => {
  return a + b;
}

و تست مربوطه:

// src/arithmetic.test.js
import { describe, it, expect } from 'vitest';
import { add } from './arithmetic';

describe('add', () => {
  it('should add two numbers', () => {
    expect(add(1, 2)).toBe(3);
  });
});

حالا بیاید تست‌های بیشتری اضافه کنیم:

describe('add', () => {
  it('should add two positive numbers', () => {
    expect(add(1, 2)).toBe(3);
  });

  it('should add two negative numbers', () => {
    expect(add(-2, -3)).toBe(-5);
  });

  it('should add a positive and a negative number', () => {
    expect(add(5, -3)).toBe(2);
  });
});

مدیریت خطا و تست edge case

تست خطاهای برنامه یکی از مهمترین بخش‌های تست نویسیه. در دنیای واقعی، کاربران همیشه ورودی‌های معتبر نمی‌دن و سیستم‌ها همیشه درست کار نمی‌کنن. بیایین یاد بگیریم چطور این شرایط رو تست کنیم.

تست ورودی‌های نامعتبر

در دنیای واقعی، کاربران ممکنه ورودی‌هایی رو وارد کنن که برنامه انتظارشون رو نداره. این ورودی‌ها می‌تونن شامل:

  • مقادیر null یا undefined
  • رشته‌های خالی یا space
  • داده‌های با type اشتباه (مثلاً رشته به جای عدد)
  • داده‌های غیرمنتظره

بیاید با یه مثال ساده شروع کنیم. فرض کنید یه تابع داریم که باید یه رشته رو به عدد تبدیل کنه:

export const stringToNumber = (value) => {
  const number = Number(value);
  
  if (isNaN(number)) {
    throw new Error(`'${value}' cannot be parsed as a number.`);
  }
  
  return number;
}

حالا بیاید تست کنیم که این تابع چطور با ورودی‌های نامعتبر رفتار می‌کنه:

describe('stringToNumber', () => {
  it('should throw an error for non-number input', () => {
    expect(() => stringToNumber('foo')).toThrow("'foo' cannot be parsed as a number.");
  });

  it('should throw an error for null input', () => {
    expect(() => stringToNumber(null)).toThrow('null cannot be parsed as a number.');
  });

  it('should throw an error for empty string', () => {
    expect(() => stringToNumber('')).toThrow('Empty strings are not valid input');
  });

  it('should throw an error for undefined input', () => {
    expect(() => stringToNumber(undefined)).toThrow('undefined cannot be parsed as a number.');
  });
});

این تست‌ها به ما کمک می‌کنن تا مطمئن بشیم تابعمون به درستی با ورودی‌های غیرمنتظره رفتار می‌کنه و خطاهای مناسبی رو throw می‌کنه.

تست توابع با ورودی‌های نامعتبر

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

describe('stringToNumber', () => {
  it('converts a valid string to a number', () => {
    expect(stringToNumber('42')).toBe(42);
  });

  it('throws an error for invalid input', () => {
    expect(() => stringToNumber('foo')).toThrow("'foo' cannot be parsed as a number.");
  });

  it('throws an error for empty strings', () => {
    expect(() => stringToNumber('')).toThrow('Empty strings are not valid input');
  });

  it('throws an error for strings with only spaces', () => {
    expect(() => stringToNumber('    ')).toThrow('Empty strings are not valid input');
  });

  it('handles string numbers with leading/trailing spaces', () => {
    expect(stringToNumber('  42  ')).toBe(42);
  });

  it('handles negative numbers', () => {
    expect(stringToNumber('-42')).toBe(-42);
  });

  it('handles decimal numbers', () => {
    expect(stringToNumber('3.14')).toBe(3.14);
  });
});

تست خطاهای توابع ریاضی

حالا بیایین توابع ریاضی رو هم از خطاهای احتمالی محافظت کنیم:

// arithmetic.js
export const add = (a, b) => {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new Error('Both arguments must be numbers');
  }
  return a + b;
};

export const subtract = (a, b) => {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new Error('Both arguments must be numbers');
  }
  return a - b;
};

export const multiply = (a, b) => {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new Error('Both arguments must be numbers');
  }
  return a * b;
};

export const divide = (a, b) => {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new Error('Both arguments must be numbers');
  }
  if (b === 0) {
    throw new Error('Division by zero is not allowed');
  }
  return a / b;
};

و تست‌های مربوطه:

describe('arithmetic functions error handling', () => {
  describe('add', () => {
    it('should throw error for non-number first argument', () => {
      expect(() => add('5', 3)).toThrow('Both arguments must be numbers');
    });

    it('should throw error for non-number second argument', () => {
      expect(() => add(5, '3')).toThrow('Both arguments must be numbers');
    });

    it('should throw error for null arguments', () => {
      expect(() => add(null, 3)).toThrow('Both arguments must be numbers');
    });

    it('should throw error for undefined arguments', () => {
      expect(() => add(5, undefined)).toThrow('Both arguments must be numbers');
    });
  });

  describe('divide', () => {
    it('should throw error for division by zero', () => {
      expect(() => divide(5, 0)).toThrow('Division by zero is not allowed');
    });

    it('should throw error for division by very small numbers', () => {
      expect(() => divide(5, 0.0000001)).not.toThrow(); // Should not throw error
    });
  });
});

تست شرایط مرزی (Boundary Conditions)

شرایط مرزی جاییه که برنامه‌ها معمولاً مشکل پیدا می‌کنن. اول تابع زیر را می‌نویسیم، این تابع سن فعلی کاربر را برمی‌گرداند.

export const calculateAge = (birthYear) => {
  if (typeof birthYear !== 'number') {
    throw new Error('Birth year must be a number');
  }
  
  if (birthYear < 1900 || birthYear > new Date().getFullYear()) {
    throw new Error('Birth year is out of valid range');
  }
  
  return new Date().getFullYear() - birthYear;
};

می‌بینید که در کد تست چگونه شرایط مرزی مختلف داده شده‌اند تا سلامت تابع محاسبه سن رو به چالش بکشند.

describe('calculateAge', () => {
  it('should calculate age correctly', () => {
    const currentYear = new Date().getFullYear();
    expect(calculateAge(currentYear - 25)).toBe(25);
  });

  it('should throw error for too old birth year', () => {
    expect(() => calculateAge(1899)).toThrow('Birth year is out of valid range');
  });

  it('should throw error for future birth year', () => {
    expect(() => calculateAge(new Date().getFullYear() + 1)).toThrow('Birth year is out of valid range');
  });

  it('should handle minimum valid birth year', () => {
    expect(() => calculateAge(1900)).not.toThrow();
  });

  it('should handle maximum valid birth year', () => {
    expect(() => calculateAge(new Date().getFullYear())).not.toThrow();
  });
});

تست کد Async

تست کد async می‌تونه چالش برانگیز باشه، اما با async/await خیلی ساده می‌شه.

مثال ساده

const addAsync = (a, b) => Promise.resolve(a + b);

it('passes if use an `async/await`', async () => {
  const result = await addAsync(2, 3);
  expect(result).toBe(5);
});

Best Practice برای تست خطاهای برنامه

پوشش انواع ورودی‌ها

  • ورودی‌های null و undefined
  • رشته‌های خالی و فاصله‌دار
  • اعداد منفی و صفر
  • انواع داده‌های اشتباه

تست شرایط مرزی

  • کمترین و بیشترین مقادیر معتبر
  • مقادیر خارج از محدوده
  • تغییرات ناگهانی در ورودی

تست خطاهای async

  • شبیه‌سازی خطا در APIها
  • تست timeoutها
  • تست قطعی ارتباط

پیام‌های خطا

  • پیام‌ها باید واضح و مفید باشن
  • پیام‌ها باید برای دیباگ مفید باشن
  • پیام‌ها نباید اطلاعات حساس لو بدن

لاگ‌گیری خطاها

  • خطاهای مهم رو لاگ کنید
  • از لاگ‌های خطا برای مانیتورینگ استفاده کنید

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

تست نویسی یک سفر یادگیری مستمره و هرچقدر بیشتر تمرین کنید، بهتر می‌شید. موفق باشید!

خب دوستان، امیدوارم این مقاله براتون مفید بوده باشه و بتونید از این مفاهیم در پروژه‌های واقعی استفاده کنید. توی مقاله بعدی، قصد داریم وارد تکنیک‌های پیشرفته‌تر تست نویسی بشیم.