۲ ژانویه ۲۰۲۳

تست خودکار با Mocha

تست خودکار در وظیفه های بعدی استفاده خواهد شد، و همچنین به طور گسترده در پروژه های واقعی استفاده می شود.

چرا به تست نیاز داریم؟

وقتی یک تابعی را می نویسیم، معمولاً می توانیم تصور کنیم که چه کاری باید انجام دهد: کدام پارامترها چه نتایجی را ارائه می دهند.

در طول توسعه، میتوانیم تابعی را اجرا کرده و خروجی آن را با چیزی که انتظار داریم تابع به ما بدهد بررسی کنیم. به عنوان مثال، ما می توانیم این کار را در کنسول انجام دهیم.

اگر چیزی اشتباه باشد – کد را تصحیح می کنیم، دوباره از اول اجرا می کنیم، نتیجه را بررسی می کنیم – و به همین ترتیب تا زمانی که کد ما کار کند، این کار ها را انجام می دهیم.

اما چنین “re-runs”(اجرای مجدد) به صورت دستی ناقص می باشد.

هنگام تست یک کد با اجرای مجدد(re-run) به صورت دستی، به راحتی می توانیم چیزی را از قلم بیاندازیم.

به عنوان مثال، ما یک تابع f ایجاد می کنیم. کدی مینویسیم و تست می کنیم: f(1) کار می کند، اما f(2) کار نمی کند. ما کد را اصلاح می کنیم و اکنون f(2) کار می کند. آیا الان تست ما کامل به نظر می رسد؟ اما فراموش کردیم f(1) را دوباره تست کنیم، که ممکن است به ارور برخورد کنیم.

این خیلی معمول(عادی) است. وقتی چیزی را توسعه می‌دهیم، کیس های احتمالی زیادی را در ذهن خود نگه میداریم، اما به سختی می توان انتظار داشت که یک برنامه نویس پس از هر تغییر، همه آنها را به صورت دستی بررسی کند. بنابراین اصلاح یک چیز و خراب کردن یک چیز دیگر آسان می شود.

تست خودکار به این معنی است که تست ها علاوه بر کد، به طور جداگانه نوشته می شوند. آنها تابع های ما را به روش های مختلف اجرا می کنند و نتایج به دست آمده را با آنچه انتظار می رود مقایسه می کنند.

توسعه رفتار محور (behavior driven development) (BDD)

بیایید با تکنیکی به نام Behavior Driven Development یا به طور خلاصه (BDD) شروع کنیم.

این BDD سه قسمت دارد: تست ها، مستندات(داکیومنت ها) و مثال ها.

برای درک بهتر BDD، یک مورد عملی از توسعه را بررسی خواهیم کرد.

توسعه ی “pow”: توضیح:

فرض کنید می‌خواهیم یک تابع pow(x, n) بسازیم که x را به توان یک عدد صحیح n برساند. ما فرض می کنیم که n≥0.

این تکلیف فقط یک مثال است: اپراتور ** در جاوا اسکریپت وجود دارد که می تواند این کار را انجام دهد، اما در اینجا ما روی جریان توسعه تمرکز می کنیم که می تواند برای کارهای پیچیده تر نیز اعمال شود.

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

چنین توصیفی یک specification(مشخصات) یا به طور خلاصه، یک spec نامیده می‌شود و حاوی توضیحاتی در مورد کیس(مورد) های همراه با تست هایی برای آنها است، مانند این:

describe("pow", function() {

  it("به توان n ام افزایش می یابد", function() {
    assert.equal(pow(2, 3), 8);
  });

});

یک spec دارای سه بلوک اصلی است که می توانید در بالا مشاهده کنید:

describe("موضوع", function() { ... })

چه عملکردی را توضیح می دهیم؟ در این کیس، ما تابع pow را توصیف می کنیم. برای گروه بندی “کارگران(workers)” – بلوک های it استفاده می شود.

it("توضیحات کیس مورد نظر", function() { ... })

در عنوان it ما به روشی قابل خواندن برای انسان کیس مورد نظر را توصیف می کنیم، و آرگومان دوم تابعی است که آن را تست می کند.

assert.equal(value1, value2)

کد داخل بلوک it، در صورتی که پیاده سازی آن صحیح باشد، باید بدون خطا(ارور) اجرا شود.

توابع *.assert برای بررسی اینکه آیا pow همانطور که انتظار می رود کار می کند یا نه استفاده می شود. در اینجا ما از یکی از آنها استفاده می کنیم – assert.equal، آرگومان ها را با هم مقایسه می کند و در صورتی که برابر نباشند، خطا می دهد. در اینجا بررسی می‌کند که نتیجه pow(2, 3) برابر 8 باشد. انواع دیگری از مقایسه و بررسی وجود دارد که بعداً اضافه خواهیم کرد.

در ابنجا specification را می توان اجرا کرد و تست مشخص شده در بلوک it را اجرا می کند. بعداً خواهیم دید.

جریان توسعه(The development flow)

جریان توسعه معمولاً به این صورت است:

  1. یک spec اولیه با تست هایی برای بنیادی(اساسی) ترین عملکرد نوشته شده است.
  2. یک پیاده سازی اولیه ایجاد می شود.
  3. برای بررسی اینکه آیا کار می کند یا نه، فریم ورک تست [Mocha] (https://mochajs.org/) (جزئیات بیشتر به زودی) را اجرا می کنیم که spec را اجرا می کند. تا زمانی که عملکرد کامل نباشد، خطاها نمایش داده می شوند. ما اصلاحات را انجام می دهیم تا زمانی که همه چیز درست کار بکند
  4. اکنون ما یک پیاده سازی اولیه با تست داریم.
  5. کیس های بیشتری را به spec اضافه می کنیم که احتمالاً هنوز توسط پیاده سازی ها پشتیبانی نشده اند. تست ها به مشکل بر میخورند.
  6. به شماره 3 برگردید و پیاده سازی ها را آپدیت کنید تا وقتی که تست ها خطایی ندهند.
  7. مراحل 3-6 را تکرار کنید تا عملکرد ها آماده شود.

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

بیایید این جریان توسعه را در مثال عملی خود ببینیم.

مرحله اول در حال حاضر کامل شده است: ما یک spec اولیه برای pow داریم. اکنون، قبل از پیاده سازی، بیایید از چند کتابخانه جاوا اسکریپت برای اجرای تست ها استفاده کنیم تا ببینیم که آنها کار می کنند (همه تست ها رد شدند).

مشخصات(spec) در عمل

در دوره آموزشی ما، از کتابخانه های جاوا اسکریپت زیر برای تست(آزمایش) استفاده خواهیم کرد:

  • Mocha – فریم ورک اصلی: توابع تستی رایج از جمله spec و it و تابع اصلی که تست ها را اجرا می‌کند را ارائه می‌کند.
  • Chai – کتابخانه ای با توابع فراوان که این اجازه را می دهد تا از بسیاری از توابع مختلف استفاده کنیم، در حال حاضر فقط به assert.equal نیاز داریم.
  • Sinon – کتابخانه ای برای جاسوسی از توابع، شبیه سازی توابع(built-in) یا همان توابع داخلی و دیگر موارد، بعداً به آن نیاز خواهیم داشت.

این کتابخانه ها هم برای تست داخل مرورگر و هم برای تست سمت سرور مناسب هستند. در اینجا ما نوع مرورگر را در نظر خواهیم گرفت.

صفحه کامل HTML با این فریم ورک ها و pow spec:

<!DOCTYPE html>
<html>
<head>
  <!-- add mocha css, to show results -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/mocha/3.2.0/mocha.css">
  <!-- add mocha framework code -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/mocha/3.2.0/mocha.js"></script>
  <script>
    mocha.setup('bdd'); // minimal setup
  </script>
  <!-- add chai -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/chai/3.5.0/chai.js"></script>
  <script>
    // chai has a lot of stuff, let's make assert global
    let assert = chai.assert;
  </script>
</head>

<body>

  <script>
    function pow(x, n) {
      /* function code is to be written, empty now */
    }
  </script>

  <!-- the script with tests (describe, it...) -->
  <script src="test.js"></script>

  <!-- the element with id="mocha" will contain test results -->
  <div id="mocha"></div>

  <!-- run tests! -->
  <script>
    mocha.run();
  </script>
</body>

</html>

صفحه را می توان به پنج بخش تقسیم کرد:

  1. قسمت <head> – برای اضافه کردن کتاب خانه های خارجی و استایل های برای تست.
  2. قسمت <script> با توابعی برای تست, که در مثال ما --با کد pow.
  3. قسمت تست ها – در مثال ما, اسکریپ خارجی test.js که تابع describe("pow", ...) را دارد(مانند مثال بالایی).
  4. المان html مقابل: <div id="mocha"> برای نمایش نتیجه, Mocha از این تگ استفاده میکند.
  5. با کامند(دستور) mocha.run() تست ما شروع میشود.

نتیجه:

در حال حاضر، تست ناموفق است و یک خطا وجود دارد. و این خطا منطقی است زیرا: کدی داخل تابع pow ننوشته ایم و خالی می باشد، بنابراین pow(2, 3) به جای 8 undefined را برمی گرداند.

برای آینده، بیایید توجه داشته باشیم که تست ککنده های سطح بالای بیشتری مانند karma و دیگران وجود دارند که اجرای خودکار بسیاری از تست‌های مختلف را راحت کرده اند.

پیاده سازی اولیه(Initial implementation)

بیایید یک پیاده‌سازی ساده از pow برای گذراندن تست‌ها ایجاد کنیم:

function pow() {
  return 8; // :) we cheat!
}

بسیار عالی! الان کار میکند.

بهبود مشخصات(spec)

کاری که ما الان انجام دادیم قطعا یک تقلب است. این تابع کار نمی کند: تلاش برای محاسبه pow(3, 4) نتیجه نادرستی می دهد، اما تست ها با موفقیت انجام می شوند.

…اما این وضعیت کاملاً عادی است. در عمل اتفاق می افتد و تست ها قبول می شوند، اما عملکرد تابع کاملا اشتباه می باشد. spec(مشخصات) ما ناقص است و ما باید کیس های بیشتری را به آن اضافه کنیم.

بیایید یک تست دیگر اضافه کنیم تا pow(3, 4) = 81 را بررسی کند.

ما می توانیم یکی از دو روش را برای سازماندهی تست در اینجا انتخاب کنیم:

  1. نوع اول – یک assert دیگر به همان it اضافه کنید:

    describe("pow", function() {
    
      it("به توان n ام افزایش می یابد", function() {
        assert.equal(pow(2, 3), 8);
        assert.equal(pow(3, 4), 81);
      });
    
    });
  2. نوع دوم – دو تا تست درست کنیم:

    describe("pow", function() {
    
      it("دو به توان 3 میشود 8", function() {
        assert.equal(pow(2, 3), 8);
      });
    
      it("سه به توان 4 میشود 81", function() {
        assert.equal(pow(3, 4), 81);
      });
    
    });

تفاوت اصلی این است که وقتی assert باعث ایجاد یک خطا میشود، بلوک it بلافاصله پایان می‌یابد. بنابراین، در نوع اول، اگر assert اول ناموفق باشد، هرگز نتیجه assert دوم را نخواهیم دید.

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

و علاوه بر آن، یک قانون دیگری وجود دارد که رعایت کردن آن بهتر است.

یک تست تنها یک چیز را بررسی می کند.

اگر به تست نگاه کنیم و دو حالت چک کردن(بررسی) مستقل از هم در آن ببینیم، بهتر است آن را به دو دسته ساده تر تقسیم کنیم.

پس بیایید با نوع دوم ادامه دهیم.

و نتیجه:

همانطور که می‌توانستیم انتظار داشته باشیم، آزمایش دوم رد شد. مطمئناً، تابع ما همیشه 8 را برمی گرداند، در حالی که assert انتظار 81 را دارد.

بهبود پیاده سازی(Improving the implementation)

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

function pow(x, n) {
  let result = 1;

  for (let i = 0; i < n; i++) {
    result *= x;
  }

  return result;
}

برای اطمینان از اینکه تابع به خوبی کار می کند یا نه، بیایید آن را برای مقادیر بیشتری تست کنیم. به جای نوشتن بلوک‌های it به صورت دستی، می‌توانیم آن‌ها را در assert تولید کنیم:

describe("pow", function() {

  function makeTest(x) {
    let expected = x * x * x;
    it(`${x} به توان سه میشود ${expected}`, function() {
      assert.equal(pow(x, 3), expected);
    });
  }

  for (let x = 1; x <= 5; x++) {
    makeTest(x);
  }

});

نتیجه به صورت زیر می باشد:

توصیف تودرتو (Nested describe)

ما حتی تست های بیشتری را اضافه می کنیم. اما قبل از آن، اجازه دهید توجه داشته باشیم که تابع کمکی makeTest و for باید با هم در یک گروه باشند. ما در تست‌های دیگر به makeTest نیاز نداریم، بلکه فقط در for مورد نیاز است: وظیفه مشترک آنها این است که بررسی کنند که pow چگونه به توان داده شده افزایش می یابد.

گروه‌بندی با assert تودرتو انجام می‌شود:

describe("pow", function() {

  describe("x به توان سه", function() {

    function makeTest(x) {
      let expected = x * x * x;
      it(`${x} in the power 3 is ${expected}`, function() {
        assert.equal(pow(x, 3), expected);
      });
    }

    for (let x = 1; x <= 5; x++) {
      makeTest(x);
    }

  });

  // ... را می توان اضافه کرد it و هم descibe تست‌های بیشتری در اینجا نوشته میشوند، هم
});

describe تودرتو، “زیرگروه(subgroup)” جدیدی از تست‌ها را تعریف می‌کند. در خروجی نیز می توانیم تورفتگی عنوان تست (title) را ببینیم:

در آینده می‌توانیم it و describe بیشتری در سطح های بالا تری با توابع کمکی خود اضافه کنیم، آنها makeTest را نخواهند دید.

before/after و beforeEach/afterEach

ما می‌توانیم توابع before/after را بنویسیم که قبل/بعد از اجرای تست ها اجرا می‌شوند و همچنین توابع beforeEach/afterEach که قبل/بعد از هر it اجرا می‌شوند.

برای مثال:

describe("test", function() {

  before(() => alert("تست شروع میشود – قبل از همه تست ها"));
  after(() => alert("تست پایان می یابد – بعد از همه تست ها"));

  beforeEach(() => alert("قبل از تست - به تست وارد میشود"));
  afterEach(() => alert("بعد از تست - از تست خارج میشود"));

  it('test 1', () => alert(1));
  it('test 2', () => alert(2));

});

دنباله اجرا خواهد شد و:

تست شروع میشود – قبل از همه تست ها (before)
قبل از تست - به تست وارد میشود (beforeEach)
1
بعد از تست - از تست خارج میشود   (afterEach)
قبل از تست - به تست وارد میشود (beforeEach)
2
بعد از تست - از تست خارج میشود   (afterEach)
تست پایان می یابد – بعد از همه تست ها (after)
Open the example in the sandbox.

معمولاً beforeEach/afterEach و before/after برای انجام مقداردهی(initialize) اولیه، صفر کردن شمارنده ها یا انجام کار های دیگری بین تست ها (یا گروه های تست) استفاده می شود.

گسترش spec

عملکرد اصلی pow کامل است. اولین تکرار توسعه انجام شده است. وقت جشن گرفتن و نوشیدن شامپاین تمام شد – بیایید ادامه دهیم و آن را بهبود ببخشیم.

همانطور که گفته شد، تابع pow(x, n) برای کار با مقادیر صحیح مثبت n است.

برای نمایش یک خطای ریاضی، توابع جاوا اسکریپت معمولاً NaN را برمی‌گردانند. بیایید همین کار را برای مقادیر نامعتبر n انجام دهیم.

بیایید ابتدا رفتار را به spec(!) اضافه کنیم:

describe("pow", function() {

  // ...

  it("برای n های منفی عبارت NaN", function() {
    assert.isNaN(pow(2, -1));
  });

  it("برای مقادیر غیر عددی عبارت NaN", function() {
    assert.isNaN(pow(2, 1.5));
  });

});

و نتیجه با تست جدید به صورت زیر می باشد:

تست های جدید اضافه شده با شکست مواجه می شوند، زیرا پیاده سازی ما از آنها پشتیبانی نمی کند. BDD اینگونه انجام می شود: ابتدا تست های شکست خورده را می نویسیم و سپس برای آنها پیاده سازی می کنیم.

Other assertions

لطفاً به assert.isNaN توجه کنید: NaN بودن را بررسی میکند.

assert دیگری نیز در chai وجود دارد، به عنوان مثال:

  • assert.equal(value1, value2) – برابری را بررسی می کند value1 == value2.
  • assert.strictEqual(value1, value2) – برابری دقیق را بررسی می کند value1 === value2.
  • assert.notEqual, assert.notStrictEqual – بررسی معکوس موارد بالا.
  • assert.isTrue(value) – این را بررسی می کند value === true
  • assert.isFalse(value) – این را بررسی می کند value === false
  • … لیست کامل را می توانید در مستندات مشاهده کنید.

بنابراین باید چند خط به pow اضافه کنیم:

function pow(x, n) {
  if (n < 0) return NaN;
  if (Math.round(n) != n) return NaN;

  let result = 1;

  for (let i = 0; i < n; i++) {
    result *= x;
  }

  return result;
}

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

Open the full final example in the sandbox.

خلاصه

در BDD ابتدا spec(مشخصات) و سپس پیاده سازی انجام می شود. در پایان نیز ما هم spec و هم کد ها را داریم.

مشخصات(spec) را می توان به سه روش استفاده کرد:

  1. به صورت Tests – تست ها تضمین می کنند که کد به درستی کار می کند.
  2. به صورت Docs – عناوین describe و it نشان می دهد که تابع چه کاری انجام می دهد.
  3. به صورت Examples – تست ها در واقع نمونه های کاری هستند که نشان می دهند چگونه می توان از یک تابع استفاده کرد.

با این spec ها, می‌توانیم با خیال راحت عملکرد تابع را بهبود بخشیم، تغییر دهیم، حتی از ابتدا بازنویسی کنیم و مطمئن شویم که همچنان درست کار می‌کند.

این به ویژه در پروژه های بزرگ زمانی که یک تابع در بسیاری از مکان ها استفاده می شود مهم است. وقتی چنین تابعی را تغییر می‌دهیم، راهی برای بررسی دستی وجود ندارد که آیا هر مکانی که از آن استفاده می‌کند هنوز درست کار می‌کند یا خیر.

بدون تست, مردم دو راه دارند:

  1. برای انجام تغییر که مهم نیست که چه باشد, کاربران ما با اشکال مواجه می شوند، زیرا ما احتمالاً چیزی را به صورت دستی بررسی نمی کنیم.
  2. یا، اگر مجازات به وجود آمدن ارور یا خطا سخت باشد، همان طور که تست ها وجود ندارند، مردم از تغییر چنین تابع هایی می ترسند، در نتیجه کد ما قدیمی می شود(زیرا میترسند که کد را توسعه دهند و اروری رخ بدهد) و هیج کس مایل نیست که کد را توسعه دهد و این برای توسعه خوب نیست.

تست خودکار برای جلوگیری از این مشکلات کمک می کند!

اگر پروژه با تست ها پوشش داده شود، چنین مشکلی به وجود نمی آید, زیرا پس از هر تغییری، می‌توانیم تست‌هایی را اجرا کنیم و در عرض چند ثانیه تعداد زیادی بررسی را مشاهده کنیم.

علاوه بر این، یک کد خوب تست شده معماری بهتری دارد.

به طور طبیعی, اصلاح و بهبود کد های تست شده به صورت خودکار آسان تر است. اما دلیل دیگری نیز وجود دارد.

برای نوشتن تست‌ها، کد باید به گونه‌ای سازماندهی شود که هر تابع دارای یک وظیفه واضح، ورودی و خروجی کاملاً تعریف شده باشد. این یعنی یک معماری خوب از ابتدا.

در زندگی واقعی, گاهی اوقات آنقدرها هم آسان نیست. گاهی اوقات نوشتن یک مشخصات(spec) قبل از کد واقعی دشوار است، زیرا هنوز مشخص نیست که چگونه باید رفتار کند. اما به طور کلی تست, توسعه را سریعتر و پایدارتر می کند.

بعداً در این دوره آموزشی، با بسیاری از تمرین ها همراه با تست های فیکس شده(baked-in) روبرو خواهید شد. بنابراین نمونه های کاربردی بیشتری خواهید دید.

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

تمارین

اهمیت: 5

تست pow که در زیر آمده چه مشکلی دارد؟

it("Raises x to the power n", function() {
  let x = 5;

  let result = x;
  assert.equal(pow(x, 1), result);

  result *= x;
  assert.equal(pow(x, 2), result);

  result *= x;
  assert.equal(pow(x, 3), result);
});

از نظر سینتکس(نحوه) تست درست است و قبول می شود.

این تست، نمونه ای از وسوسه‌هایی را که یک توسعه دهنده(برنامه نویس) هنگام نوشتن تست‌ ها با آن رو به رو می‌شود را نشان می دهد.

آنچه که در اینجا داریم در واقع 3 تست است، اما به عنوان یک تابع با 3 assert نوشته شده است.

بعضی وقت ها نوشتن به این مدل ساده تر است، اما اگر خطایی رخ بدهد، خیلی کمتر مشخص میشود که مشکل از کجاست.

اگر خطایی در وسط اجرای یک جریان پیچیده رخ بدهد، در این مرحله باید داده ها را کشف کنیم. ما در واقع باید تست را دیباگ(اشکال زدایی) کنیم.

خیلی بهتر میشود که تست را به چندین بلوک it با ورودی ها و خروجی های کامل نوشته شده تقسیم کنیم.

به طور مثال:

describe("Raises x to power n", function() {
  it("5 in the power of 1 equals 5", function() {
    assert.equal(pow(5, 1), 5);
  });

  it("5 in the power of 2 equals 25", function() {
    assert.equal(pow(5, 2), 25);
  });

  it("5 in the power of 3 equals 125", function() {
    assert.equal(pow(5, 3), 125);
  });
});

ما یک it را با describe و گروهی از بلوک‌های it جایگزین میکنیم. حالا اگر مشکلی پیش بیاید، به وضوح می‌بینیم که داده‌ها چه بوده‌اند.

همچنین می‌توانیم با نوشتن it.only به جای it، یک تست را جدا کرده و آن را در حالت مستقل(به تنهایی) اجرا کنیم:

describe("Raises x to power n", function() {
  it("5 in the power of 1 equals 5", function() {
    assert.equal(pow(5, 1), 5);
  });

  // Mocha will run only this block
  it.only("5 in the power of 2 equals 25", function() {
    assert.equal(pow(5, 2), 25);
  });

  it("5 in the power of 3 equals 125", function() {
    assert.equal(pow(5, 3), 125);
  });
});
نقشه آموزش