پروژهٔ ساخت ماشین حساب در زبان PHP و تست آن با استفاده از PHPUnit

در این آموزش قصد داریم یک پروژهٔ ماشین حساب ساده را با استفاده از یونیت تست تکمیل کنیم و برای آنکه تست‌های مرتبط با این پروژه از سایر تست‌ها مجزا باشند، داخل پوشهٔ tests پوشهٔ دیگری تحت عنوان calculator ساخته و داخل آن فایلی با نامی دلخواه همچون CalculatorTest.php به صورت زیر می‌سازیم:

<?php
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    public function testAddOperands()
    {
        $calculator = new \App\Calculator;
        $calculator->setOperands([5, 10]);
        $this->assertEquals(15, $calculator->add());
    }
}

همان‌طور که می‌بینیم، متدی نوشته‌ایم تحت عنوان ()testAddOperands که نام آن با کلمهٔ test شروع شده که این یک باید است و داخل این متد متغیری تعریف کرده‌ایم به نام calculator$ که مقدارش برابر با آبجکتی از روی کلاسی به نام Calculator است که هنوز ساخته نشده است.

در ادامه، متدی تحت عنوان ()setOperands را روی آبجکتی که ساخته‌ایم فراخوانی کرده و به عنوان آرگومان ورودی این متد هم آرایه‌ای حاوی مقادیر ۵ و ۱۰ در نظر گرفته‌ایم و در نهایت هم متد ()assertEquals از فریمورک PHPUnit را فراخوانی کرده و دو آرگومان ورودی برایش در نظر گرفته‌ایم که مورد اول عدد ۱۵ است که به نوعی نتیجه‌ای است که قصد داریم به آن برسیم و آرگومان دوم نیز فراخوانی متد ()add از کلاس Calculator است که هنوز نوشته نشده است.

در این مرحله از کار، با ران کردن تست خواهیم دید که اروری در معرض دیدمان قرار خواهد گرفت به طوری که حاوی پیام زیر است:

/var/www/phpunit$ ./vendor/bin/phpunit 
PHPUnit 8.1.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.3.4-1+ubuntu18.04.1+deb.sury.org+3
Configuration: /var/www/phpunit/phpunit.xml

.....E  6 / 6 (100%)

Time: 26 ms, Memory: 4.00 MB

There was 1 error:

1) CalculatorTest::testAddOperands
Error: Class 'App\Calculator' not found

/var/www/phpunit/tests/calculator/CalculatorTest.php:8

ERRORS!

می‌بینیم که در متن ارور آمده است که کلاس Calculator وجود ندارد که برای رفع این مشکل، داخل پوشهٔ app فایلی به نام Calculator.php به صورت زیر می‌سازیم:

<?php 
namespace App;

class Calculator
{
    
}

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

/var/www/phpunit$ ./vendor/bin/phpunit 
PHPUnit 8.1.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.3.4-1+ubuntu18.04.1+deb.sury.org+3
Configuration: /var/www/phpunit/phpunit.xml

.....E  6 / 6 (100%)

Time: 25 ms, Memory: 4.00 MB

There was 1 error:

1) CalculatorTest::testAddOperands
Error: Call to undefined method App\Calculator::setOperands()

/var/www/phpunit/tests/calculator/CalculatorTest.php:9

ERRORS!
Tests: 6, Assertions: 6, Errors: 1.

گرچه تست پاس نشد اما نوع پیام خطا تغییر کرد به طوری که گفته شده متدی با نام ()setOprands وجود ندارد که برای رفع این ارور، کلاس Calculator را به صورت زیر تکمیل می‌کنیم:

<?php 
namespace App;

class Calculator
{
    public $operands;

    public function setOperands(array $operands)
    {
        $this->operands = $operands;
    }
}

داخل این کلاس یک پراپرتی داریم به نام operands$ که این پراپرتی داخل متدی به نام ()setOperands مقداردهی خواهد شد. در واقع، این متد یک پارامتر ورودی تحت عنوان operands$ از جنس آرایه می‌گیرد که در نهایت این پارامتر به پراپرتی تعریف‌شده در بدنهٔ کلاس منتسب می‌گردد. به درج کلیدواژه‌ای همچون array پیش از نام پارامتر ورودی این متد اصطلاحاً Type Hinting گفته می‌شود که این تضمین را ایجاد می‌کند که پارامترهای ورودی این متد حتماً می‌باید از جنس آرایه باشند که در این ارتباط و برای کسب اطلاعات بیشتر، می‌توانید به مقالهٔ‌ آشنایی با مفهوم Type Hinting در زبان PHP مراجعه نمایید. حال اگر تست را ران کنیم خواهیم داشت:

/var/www/phpunit$ ./vendor/bin/phpunit 
PHPUnit 8.1.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.3.4-1+ubuntu18.04.1+deb.sury.org+3
Configuration: /var/www/phpunit/phpunit.xml

.....E  6 / 6 (100%)

Time: 25 ms, Memory: 4.00 MB

There was 1 error:

1) CalculatorTest::testAddOperands
Error: Call to undefined method App\Calculator::add()

/var/www/phpunit/tests/calculator/CalculatorTest.php:10

ERRORS!
Tests: 6, Assertions: 6, Errors: 1.

می‌بینیم که متن خطا مجدد تغییر کرد مبنی بر اینکه متدی تحت عنوان ()add وجود ندارد که این متد را نیز به صورت زیر پیاده‌سازی می‌کنیم:

public function add()
{
     return array_sum($this->operands);
}

متد ()array_sum با توجه به آرگومان ورودی‌اش که همان پراپرتی operands$ است مقداری را بازمی‌گرداند که آن مقدار توسط دستور return به عنوان خروجی این متد در نظر گرفته خواهد شد. در واقع، این متد جمع جبری اِلِمان‌های آرایهٔ ورودی را بازمی‌گرداند. حال اگر تست را ران کنیم، خواهیم دید که با موفقیت پاس خواهد شد:

/var/www/phpunit$ ./vendor/bin/phpunit 
PHPUnit 8.1.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.3.4-1+ubuntu18.04.1+deb.sury.org+3
Configuration: /var/www/phpunit/phpunit.xml

......  6 / 6 (100%)

Time: 24 ms, Memory: 4.00 MB

OK (6 tests, 7 assertions)

در ادامه، قصد داریم سه تست دیگر برای اَعمال ریاضیاتی تفریق، تقسیم و ضرب نوشته سپس کلاس Calculator را به منظور پاس شدن تست‌ها تکمیل نماییم.

به منظور تست کردن قابلیت تفریق این ماشین حساب، تستی تحت عنوان ()testSubtractOperands نوشته و آن را به صورت زیر تکمیل می‌‌کنیم:

public function testSubtractOperands()
{
    $calculator = new \App\Calculator;
    $calculator->setOperands([100, 10]);
    $this->assertEquals(90, $calculator->subtract());
}

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

/var/www/phpunit$ ./vendor/bin/phpunit 
PHPUnit 8.1.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.3.4-1+ubuntu18.04.1+deb.sury.org+3
Configuration: /var/www/phpunit/phpunit.xml

......E  7 / 7 (100%)

Time: 25 ms, Memory: 4.00 MB

There was 1 error:

1) CalculatorTest::testSubtractOperands
Error: Call to undefined method App\Calculator::subtract()

/var/www/phpunit/tests/calculator/CalculatorTest.php:17

ERRORS!
Tests: 7, Assertions: 7, Errors: 1.

می‌بینیم که در متن خطا آمده متدی تحت عنوان ()subtract وجود ندارد که برای رفع این مشکل، چنین متدی را به کلاس Calculator به صورت زیر می‌افزاییم:

public function subtract()
{
     return $this->operands[0] - $this->operands[1];
}

کاری که داخل این متد انجام داده‌ایم بدین صورت است که اِلِمان اول پراپرتی یا بهتر بگوییم آرایهٔ operands$ را از اِلِمان دوم این آرایه کسر کرده‌ایم به طوری که اگر تست را اجرا کنیم در خروجی خواهیم داشت:

/var/www/phpunit$ ./vendor/bin/phpunit 
PHPUnit 8.1.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.3.4-1+ubuntu18.04.1+deb.sury.org+3
Configuration: /var/www/phpunit/phpunit.xml

.......  7 / 7 (100%)

Time: 27 ms, Memory: 4.00 MB

OK (7 tests, 8 assertions)

در ادامهٔ تکمیل تست‌ها و به منظور سنجش عملیات تقسیم ماشین حساب،‌ تست دیگری تحت عنوان ()testDivideOperands به صورت زیر می‌نویسیم:

public function testDivideOperands()
{
    $calculator = new \App\Calculator;
    $calculator->setOperands([100, 2]);
    $this->assertEquals(50, $calculator->divide());
}

در این تست دو عملوند ۱۰۰ و ۲ را در نظر گرفته سپس در متد ()assertEquals گفته‌ایم که تقسیم عدد ۱۰۰ بر عدد ۲ می‌باید ۵۰ گردد؛ سپس به عنوان پارامتر دوم این متد، متدی تحت عنوان ()divide را به آبجکت calculator$ منتسب کرده‌ایم. در این مرحله از کار تست‌مان پاس نخواهد شد چرا که متد ()divide تعریف نشده است که برای رفع این مشکل چنین متدی داخل کلاس Calculator به صورت زیر تعریف می‌کنیم:

public function divide()
{
     return $this->operands[0] / $this->operands[1];
}

همچون عملیات تفریق، این‌ بار با استفاده از علامت / اِلِمان اول آرایهٔ operands$ را بر اِلِمان دوم آن تقسیم کرده‌ایم به طوری که از این پس به عنوان خروجی تست خواهیم داشت:

/var/www/phpunit$ ./vendor/bin/phpunit 
PHPUnit 8.1.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.3.4-1+ubuntu18.04.1+deb.sury.org+3
Configuration: /var/www/phpunit/phpunit.xml

........  8 / 8 (100%)

Time: 25 ms, Memory: 4.00 MB

OK (8 tests, 9 assertions)

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

public function testMultiplyOperands()
{
    $calculator = new \App\Calculator;
    $calculator->setOperands([10, 2]);
    $this->assertEquals(20, $calculator->multiply());
}

همان‌طور که ملاحظه می‌شود، چنین تستی قرار است این اطمینان را به ما بدهد که اگر اعدادی همچون ۱۰ و ۲ به متدی تحت عنوان ()multiply پاس داده شوند، این متد آن‌ها را در یکدیگر ضرب کرده و حاصل‌ضرب آن‌ها برابر با ۲۰ خواهد شد اما این در حالی است که اگر اکنون تست را ران کنیم با اروری مواجه می‌شویم مبنی بر اینکه چنین متدی وجود ندارد که در همین راستا آن را داخل کلاس Calculator به صورت زیر تعریف می‌کنیم:

public function multiply()
{
     return $this->operands[0] * $this->operands[1];
}

همان‌طور که می‌بینیم از علامت * به منظور ضرب کردن اِلِمان‌های آرایه در یکدیگر استفاده نموده‌ایم به طوری که اگر اکنون تست را ران کنیم، خروجی زیر در معرض دیدمان قرار خواهد گرفت:

/var/www/phpunit$ ./vendor/bin/phpunit 
PHPUnit 8.1.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.3.4-1+ubuntu18.04.1+deb.sury.org+3
Configuration: /var/www/phpunit/phpunit.xml

.........  9 / 9 (100%)

Time: 34 ms, Memory: 4.00 MB

OK (9 tests, 10 assertions)

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

درآمدی بر نحوهٔ تست کردن اِکسپشن‌ها

گاهی پیش می‌آید که در عملیات تقسیم یک عدد همچون ۱۰۰ را بر ۰ تقسیم کنیم که در چنین شرایطی مفسر پی‌اچ‌پی اِکسپشنی را در معرض دیدمان قرار خواهد داد که برای رفع این مشکل، تست جدیدی به صورت زیر می‌نویسیم تا این موضوع را بیازماییم:

public function testDivisionByZeroThrowsException()
{
    $this->expectException('InvalidArgumentException');
    $calculator = new \App\Calculator;
    $calculator->setOperands([100, 0]);
    $calculator->divide();
}

در فریمورک PHPUnit به تابعی دسترسی داریم تحت عنوان ()expectException که این امکان را در اختیارمان می‌گذارد تا مشخص کنیم در صورت بروز مشکل، انتظار چه نوع اِکسپشنی را داشته باشیم. برای همین منظور، نام اِکسپشنی همچون InvalidArgumentException را به عنوان آرگومان ورودی این متد در نظر گرفته‌ایم سپس همچون تست ()testDivideOperands، اقدام به سِت کردن عملوندها سپس فراخوانی متد ()divide نموده‌ایم و اکنون اگر تست را ران کنیم، با خروجی زیر مواجه خواهیم شد:

/var/www/phpunit$ ./vendor/bin/phpunit 
PHPUnit 8.1.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.3.4-1+ubuntu18.04.1+deb.sury.org+3
Configuration: /var/www/phpunit/phpunit.xml

.........E  10 / 10 (100%)

Time: 25 ms, Memory: 4.00 MB

There was 1 error:

1) CalculatorTest::testDivisionByZeroThrowsException
Division by zero

/var/www/phpunit/app/Calculator.php:28
/var/www/phpunit/tests/calculator/CalculatorTest.php:47

ERRORS!
Tests: 10, Assertions: 10, Errors: 1.

می‌بینیم که پیام خطای «Division by zero» ارائه شده است. حال برای رفع این مشکل، به کلاس Calculator بازگشته و متد ()divide آن را به صورت زیر ریفکتور می‌کنیم:

public function divide()
{
    if ($this->operands[1] == 0) {
        throw new \InvalidArgumentException("Division by zero is not possible.");
    }
    return $this->operands[0] / $this->operands[1];
}

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

/var/www/phpunit$ ./vendor/bin/phpunit 
PHPUnit 8.1.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.3.4-1+ubuntu18.04.1+deb.sury.org+3
Configuration: /var/www/phpunit/phpunit.xml

..........  10 / 10 (100%)

Time: 26 ms, Memory: 4.00 MB

OK (10 tests, 11 assertions)

می‌بینیم که تست‌مان با موفقیت پاس شد. در تفسیر تغییراتی که داخل متد ()divide انجام داده‌ایم می‌توان گفت که با استفاده از یک دستور شرطی چک کرده‌ایم ببینیم که آیا اِلِمان دوم آرایهٔ operands$ برابر با ۰ است یا خیر که اگر این‌گونه بود، کلاس InvalidArgumentException زبان پی‌اچ‌پی را به عنوان اِکسپشن فراخوانی می‌کنیم و داخل متد ()testDivisionByZeroThrowsException نیز گفتیم که اگر اِکسپشن ایجادشده از جنسِ InvalidArgumentException بود، تست پاس گردد.

ریفکتور کردن تست‌ها به منظو بهبود سورس‌کد

حال که توانستیم تست‌ها را با موفقیت بنویسیم، به منظور بهبود کلاس CalculatorTest، در ادامه یکسری تغییرات در این کلاس خواهیم داد به طوری که داریم:

<?php
use App\Calculator;
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    private $calculator;

    protected function setUp(): void
    {
        $this->calculator = new Calculator;
    }

    public function testAddOperands()
    {
        // $calculator = new \App\Calculator;
        $this->calculator->setOperands([5, 10]);
        $this->assertEquals(15, $this->calculator->add());
    }

    public function testSubtractOperands()
    {
        // $calculator = new \App\Calculator;
        $this->calculator->setOperands([100, 10]);
        $this->assertEquals(90, $this->calculator->subtract());
    }

    public function testDivideOperands()
    {
        // $calculator = new \App\Calculator;
        $this->calculator->setOperands([100, 2]);
        $this->assertEquals(50, $this->calculator->divide());
    }

    public function testMultiplyOperands()
    {
        // $calculator = new \App\Calculator;
        $this->calculator->setOperands([10, 2]);
        $this->assertEquals(20, $this->calculator->multiply());
    }

    public function testDivisionByZeroThrowsException()
    {
        $this->expectException('InvalidArgumentException');
        // $calculator = new \App\Calculator;
        $this->calculator->setOperands([100, 0]);
        $this->calculator->divide();
    }
}

همان‌طور که پیش از این دیدیم، به منظور دستیابی به کلاس Calculator، داخل تک‌تک تست‌ها یک آبجکت جدید از روی این کلاس ساختیم و این در حالی است که به شکل بهینه‌تری می‌توان این کار را انجام داد بدین صورت که یک پراپرتی در بدنهٔ کلاس از جنس private تحت عنوان calculator$ ساخته سپس متدی تحت عنوان ()setUp نوشته‌ایم که داخل فریمورک PHPUnit تعبیه شده است و همان‌طور که از علائم void : مشخص‌ است، این متد قرار نیست چیزی را بازگرداند. داخل این متد هم دستور داده‌ایم تا یک آبجکت جدید از روی کلاس Calculator ساخته شده و به پراپرتی calculator$ منتسب گردد و بر خلاف روال گذشته، این بار به منظور ساخت آبجکت به جای درج نام App\Calculator\ صرفاً نام کلاس را نوشته اما در خط دوم فایل آن را use کرده‌ایم. از این پس، به جای آنکه داخل تک‌تک تست‌ها یک آبجکت جدید از روی کلاس Calculator بسازیم، به سادگی می‌توانیم با استفاده از دستور <-this$ به پراپرتی calculator$ دست پیدا کنیم.

به خاطر داشته باشید
زمانی که می‌خواهیم به یک پراپرتی در داخل متدهای یک کلاس دست پیدا کنیم، هرگز نباید پس از دستور <-this$ علامت $ مرتبط را پراپرتی را بنویسیم. 

همچنین نکتهٔ بسیار مهمی که در ارتباط با متد ()setUp وجود دارد اینکه متد مذکور به هر تعداد که تست داخل کلاس‌مان داشته باشیم، به همان تعداد فراخوانی می‌شود. به عبارت دیگر، در حال حاضر که چهار تست داخل این کلاس نوشته‌ایم، چهار بار این متد فراخوانی شده و چهار آبجکت مختلف از روی کلاس Calculator ساخته می‌شود و همین مسئله‌ این تضمین را ایجاد می‌کند که هر تست با یک آبجکت دست‌نخوره سروکار داشته باشد.

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

نظرات
اگر login نکردی برامون ایمیلت رو بنویس: