اذهب إلى المحتوى

اختبار الوحدات والأداء في PHP


سارة محمد2

قواعد صنف الاختبار

بفرض لدينا الصنف LoginForm مع التابع rules()‎، والذي يستخدم في صفحة تسجيل الدخول مثل قالب لإطار العمل:

class LoginForm {
    public $email;
    public $rememberMe;
    public $password;

    // (1)
    public function rules() {
        return [
            // البريد الإلكتروني وكلمة السر مطلوبان
            [['email', 'password'], 'required'],
            // يجب أن يكون البريد الإلكتروني بصياغة بريد إلكتروني
            ['email', 'email'],
            // ‫يجب أن يكون الحقل rememberMe قيمة منطقية
            ['rememberMe', 'boolean'],
            // ‫يجب أن تطابق كلمة السر هذا النمط (أي تحوي أحرف وأرقام فقط)
            ['password', 'match', 'pattern' => '/^[a-z0-9]+$/i'],
        ];
    }

    // ‫تتحقق هذه الدالة من صحة القواعد الممررة
    public function validate($rule) {
        $success = true;
        list($var, $type) = $rule;
        foreach ((array) $var as $var) {
            switch ($type) {
                case "required":
                    $success = $success && $this->$var != "";
                    break;
                case "email":
                    $success = $success && filter_var($this->$var, FILTER_VALIDATE_EMAIL);
                    break;
                case "boolean":
                    $success = $success && filter_var($this->$var, FILTER_VALIDATE_BOOLEAN,
FILTER_NULL_ON_FAILURE) !== null;
                    break;
                case "match":
                    $success = $success && preg_match($rule["pattern"], $this->$var);
                    break;
                default:
                    throw new \InvalidArgumentException("Invalid filter type passed")
            }
        }
        return $success;
    }
}

في الموضع (1) يعيد التابع rules()‎ مصفوفة بمتطلبات كل حقل، يستخدم نموذج تسجيل الدخول البريد الإلكتروني وكلمة المرور لاستيثاق المستخدم.

نستخدم اختبار الوحدات لإجراء الاختبارات على هذا الصنف، أي للتحقق من الشيفرة المصدرية لمعرفة إذا كانت تناسب توقعاتنا:

class LoginFormTest extends TestCase {
    protected $loginForm;

    // تنفيذ الشيفرة في بداية الاختبار
    public function setUp() {
        $this->loginForm = new LoginForm;
    }

    // (1)
    public function testRuleValidation() {
        $rules = $this->loginForm->rules();

        // التهيئة للتحقق من صحة واختبار البيانات التالية
        $this->loginForm->email = "valid@email.com";
        $this->loginForm->password = "password";
        $this->loginForm->rememberMe = true;
        $this->assertTrue($this->loginForm->validate($rules), "Should be valid as nothing is invalid");

        // اختبار صحة البريد الإلكتروني
        // بما أننا حددنا أن يكون البريد الإلكتروني بصياغة بريد إلكتروني فلا يمكن أن يكون فارغًا­
        $this->loginForm->email = '';
        $this->assertFalse($this->loginForm->validate($rules), "Email should not be valid (empty)");

        // ‫لا يحتوي البريد الإلكتروني على العلامة "@" لذا فهو غير صحيح
        $this->loginForm->email = 'invalid.email.com';
        $this->assertFalse($this->loginForm->validate($rules), "Email should not be valid (invalid format)");

        // قيمة صحيحة للبريد الإلكتروني من أجل الاختبار التالي
        $this->loginForm->email = 'valid@email.com';

        // ‫اختبار صحة كلمة المرور والتي يجب ألا تكون فارغة (بما أنها مطلوبة)
        $this->loginForm->password = '';
        $this->assertFalse($this->loginForm->validate($rules), "Password should not be valid (empty)");

        // قيمة صحيحة لكلمة المرور من أجل الاختبار التالي
        $this->loginForm->password = 'ThisIsMyPassword';

        // ‫اختبار صحة الحقل rememberMe
        $this->loginForm->rememberMe = 999;
        $this->assertFalse($this->loginForm->validate($rules), "RememberMe should not be valid (integer type)");

        // ‫قيمة صحيحة للحقل rememberMe من أجل الاختبار التالي
        $this->loginForm->rememberMe = true;
    }
}

في الموضع (1) يجب أن نستخدم التابع validate()‎ للتحقق من صحة قواعدنا، يعود التابع testRuleValidation()‎ إلى اختبار الوحدة الخاص بالصنف LoginFormTest ويختبر القواعد المذكورة سابقًا.

كيف يمكن أن يساعد اختبار الوحدات هنا (باستثناء الأمثلة العامة)؟ يناسبنا عند الحصول على نتائج غير متوقعة مثلًا، بفرض لدينا هذه القاعدة:

['password', 'match', 'pattern' => '/^[a-z0-9]+$/i'],

إذا نسينا شيئًا واحدًا مهمًا وكتبنا:

['password', 'match', 'pattern' => '/^[a-z0-9]$/i'],

من الصعب اكتشاف الأخطاء مع وجود عشرات القواعد المختلفة، وبفرض أننا لا نستخدم البريد الإلكتروني وكلمة المرور فقط، إليك اختبار الوحدة هذا:

// التهيئة للتحقق من صحة واختبار البيانات التالية
$this->loginForm->email = "valid@email.com";
$this->loginForm->password = "password";
$this->loginForm->rememberMe = true;
$this->assertTrue($this->loginForm->validate($rules), "Should be valid as nothing is invalid");

سيمرر هذا الاختبار مثالنا الأول وليس الثاني لأنه لدينا خطأ مطبعي في المثال الثاني (لم نكتب علامة +)، مما يعني أنه سيقبل حرف/رقم واحد فقط.

يمكن تنفيذ اختبار الوحدات في الطرفية باستخدام الأمر phpunit [path_to_file]‎، إذا كان كل شيء صحيحًا، يجب أن نكون قادرين على رؤية الحالة OK لكل الاختبارات وإلا سنرى إما Error لخطأ في الصيغة، أو Fail على الأقل لسطر واحد لم يُمرَّر في ذلك التابع.

يمكننا أيضًا باستخدام معاملات إضافية مثل ‎--coverage أن نحصل على عدد الأسطر المُختبرة من الشيفرة في الواجهة الخلفية backend وأيها نجحَ/فشلَ، يُطبَّق هذا على أي إطار عمل ثبّتَ PHPUnit.

إليك مثال عن طريقة ظهور اختبار PHPUnit في الطرفية، وهو مثال عام وليس له صلة بمثالنا:

01.png

مقدمو بيانات PHPUnit

تحتاج توابع الاختبار عادةً إلى بيانات لتُختَبر بها، ولاختبار بعض التوابع كاملةً تحتاج لتوفير مجموعات بيانات مختلفة لكل حالة اختبار ممكنة، يمكنك القيام بذلك يدويًا باستخدام الحلقات، مثال:

...
public function testSomething()
{
    $data = [...];
    foreach($data as $dataSet) {
        $this->assertSomething($dataSet);
    }
}
...

يمكن أن تجد هذه الطريقة مريحة ولكن لها بعض العيوب، أولًا عليك أن تؤدي إجراءات إضافية لاستخراج البيانات إذا كانت دالة الاختبار تقبل عدة معاملات، ثانيًا سيكون من الصعب في حالة الفشل تمييز مجموعة البيانات الخاطئة بدون تنقيح أخطاء ورسائل إضافية، ثالثًا يوفر PHPUnit طريقة تلقائية للتعامل مع مجموعات بيانات الاختبار باستخدام مقدمي البيانات.

مقدم البيانات دالة يجب أن ترجع بيانات حالة الاختبار الخاصة بك، ويجب أن تكون هذه الدالة عامة وترجع إما مصفوفة من المصفوفات أو كائن ينفّذ الواجهة Iterator ويُرجع مصفوفة لكل خطوة تكرارية. كل مصفوفة جزء من المجموعة collection التي سيستدعيها تابع الاختبار مع محتويات المصفوفة كوسائط لها.

لاستخدام مقدم البيانات مع الاختبار نستخدم التوصيف ‎@dataProvider مع اسم دالة مقدم البيانات المحددة:

/**
* @dataProvider dataProviderForTest
*/
public function testEquals($a, $b)
{
    $this->assertEquals($a, $b);
}

public function dataProviderForTest()
{
    return [
        [1,1],
        [2,2],
        [3,2] //this will fail
    ];
}

مصفوفة مصفوفات

لاحظ أنّ الدالة dataProviderForTest()‎ ترجع مصفوفة من مصفوفات، كل مصفوفة متداخلة تحتوي عنصرين سيملآن المعاملات الضرورية للدالة testEquals()‎، إذا لم يكن هناك عناصر كافية سيُرمى خطأ مشابه للخطأ التالي:

 Missing argument 2 for Test::testEquals()‎ 

حيث سيمر PHPUnit تلقائيًا على كل البيانات وينفذ الاختبارات، كما هو ظاهر فيما يأتي:

public function dataProviderForTest()
{
    return [
        [1,1], // [0] testEquals($a = 1, $b = 1)
        [2,2], // [1] testEquals($a = 2, $b = 2)
        [3,2] // [2] There was 1 failure: 1) Test::testEquals with data set #2 (3, 4)
    ];
}

يمكن أن تُسمَّى كل مجموعة بيانات ليكون من الأسهل اكتشاف بيانات الفشل:

public function dataProviderForTest()
{
    return [
        'Test 1' => [1,1], // [0] testEquals($a = 1, $b = 1)
        'Test 2' => [2,2], // [1] testEquals($a = 2, $b = 2)
        'Test 3' => [3,2] // [2] There was 1 failure:
        // 1) Test::testEquals with data set "Test 3" (3, 4)
    ];
}

المكررات

class MyIterator implements Iterator {
    protected $array = [];

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

    function rewind() {
        return reset($this->array);
    }

    function current() {
        return current($this->array);
    }

    function key() {
        return key($this->array);
    }

    function next() {
        return next($this->array);
    }

    function valid() {
        return key($this->array) !== null;
    }
}
...
class Test extends TestCase
{
    /**
    * @dataProvider dataProviderForTest
    */
    public function testEquals($a)
    {
        $toCompare = 0;
        $this->assertEquals($a, $toCompare);
    }

    public function dataProviderForTest()
    {
        return new MyIterator([
            'Test 1' => [0],
            'Test 2' => [false],
            'Test 3' => [null]
        ]);
    }
}

كما تلاحظ، فإنَّ المكرِّر البسيط يعمل أيضًا، وأنّ مقدم البيانات يجب أن يرجع مصفوفة ‎$parameter حتى من أجل معامل واحد.

إذا غيّرنا التابع current()‎ الذي يعيد البيانات في كل تكرار، بالشكل التالي:

function current() {
    return current($this->array)[0];
}

أو غيّرنا البيانات الفعلية:

return new MyIterator([
    'Test 1' => 0,
    'Test 2' => false,
    'Test 3' => null
]);

سنحصل على خطأ:

There was 1 warning:
1) Warning
The data provider specified for Test::testEquals is invalid.

من غير المفيد طبعًا استخدام الكائن Iterator لتكرار محتويات مصفوفة بسيطة، يجب أن ينفّذ بعض المنطق المحدد لحالتك.

المولدات generators

لم يُشار إليها بشكلٍ صريح في التوثيق الرسمي لكن يمكنك استخدامها كمقدم بيانات أيضًا­، لاحظ أنّ الصنف Generator ينفّذ الواجهة Iterator فعليًا، إليك مثال عن استخدام DirectoryIterator مع مولِّد:

/**
* @param string $file
*
* @dataProvider fileDataProvider
*/
public function testSomethingWithFiles($fileName)
{
    // ‫‎$fileName متاح هنا
    // اختبر هنا
}

public function fileDataProvider()
{
    $directory = new DirectoryIterator('path-to-the-directory');
    foreach ($directory as $file) {
        if ($file->isFile() && $file->isReadable()) {
            // تنفيذ المولِّد هنا
            yield [$file->getPathname()]; 
        }
    }
}

لاحظ أنّ مقدم البيانات يُرجع مصفوفة، وستحصل على تحذير أنّ مقدم البيانات غير صحيح.

استثناءات الاختبار

لنفرض أنك تريد اختبار تابع يرمي استثناءً.

class Car
{
    /**
    * @throws \Exception
    */
    public function drive()
    {
        throw new \Exception('Useful message', 1);
    }
}

يمكنك القيام بذلك عن طريق تضمين استدعاء التابع في كتلة try/catch وإجراء توكيدات على خاصيات كائن الاستثناء، أو يمكنك استخدام توابع توكيد الاستثناء للمزيد من الملاءمة، يمكنك بدءًا من الإصدار PHPUnit 5.2 استخدام توابع expectX()‎ المتاحة لتأكيد نوع ورسالة وشيفرة الاستثناء.

class DriveTest extends PHPUnit_Framework_TestCase
{
    public function testDrive()
    {
        // التحضير
        $car = new \Car();
        $expectedClass = \Exception::class;
        $expectedMessage = 'Useful message';
        $expectedCode = 1;

        // الاختبار
        $this->expectException($expectedClass);
        $this->expectMessage($expectedMessage);
        $this->expectCode($expectedCode);

        // التنفيذ
        $car->drive();
    }
}

يمكنك أن تستخدم التابع setExpectedException بدلًا من expectX()‎ إذا كنت تستخدم إصدارًا قديمًا من PHPUnit لكن تذكر أنه تابع مُهمل وسيُحذف في الإصدار 6.

class DriveTest extends PHPUnit_Framework_TestCase
{
    public function testDrive()
    {
        // التحضير
        $car = new \Car();
        $expectedClass = \Exception::class;
        $expectedMessage = 'Useful message';
        $expectedCode = 1;

        // الاختبار
        $this->setExpectedException($expectedClass, $expectedMessage,             $expectedCode);

        // التنفيذ
        $car->drive();
    }
}

الأداء

التحليل مع Xdebug

تُعَدّ إضافة Xdebug متاحةً للمساعدة في تحليل تطبيقات PHP، بالإضافة إلى تنقيح الأخطاء وقت التنفيذ، عند تنفيذ المحلل يُكتب الخرج في ملف بصياغة ثنائية تدعى cachegrind. توجد تطبيقات متوفرة على كل منصة لتحليل هذه الملفات.

لتمكين التحليل نثبّت الإضافة ونعدّل إعدادات ملف php.ini. سننفذ في مثالنا المحلل اختياريًا بالاعتماد على معامل الطلب، يسمح لنا هذا بالحفاظ على الإعدادات ثابتة وتشغيل المحلل عندما نحتاج فقط.

// اضبطه إلى 1 لتشغيله عند كل طلب
xdebug.profiler_enable = 0

// ‫لنستخدم معامل GET/POST لتشغيل المحلِّل
xdebug.profiler_enable_trigger = 1

// ‫قيمة GET/POST التي سنمررها، فارغة من أجل أي قيمة
xdebug.profiler_enable_trigger_value = ""

// ‫عرض ملفات الذاكرة cachegrind في المسار ‎/tmp حتى ينظفها النظام لاحقًا
xdebug.profiler_output_dir = "/tmp"
xdebug.profiler_output_name = "cachegrind.out.%p"

ثم استخدم عميل ويب يرسل طلبًا إلى رابط التطبيق الذي ترغب بتحليله، مثل:

http://example.com/article/1?XDEBUG_PROFILE=1

أثناء معالجة الصفحة للطلب سيكتب في ملف له اسم مشابه للتالي:

/tmp/cachegrind.out.12345

لاحظ أنّه سيكتب ملف واحد لكل عملية/طلب PHP يُنفَّذ، لذا إذا أردت تحليل نموذج مُرسل بالطريقة POST سيُكتب تحليل واحد من أجل الطريقة GET لعرض نموذج HTML وستحتاج إلى تمرير المعامل XDEBUG_PROFILE ، من أجل الإقدام على طلب POST اللاحق لتحليل الطلب الثاني الذي يعالج النموذج، لذا فقد يكون من الأسهل تنفيذ مكتبة curl لإرسال نموذج بالطريقة POST مباشرةً عند التحليل.

بمجرد أن يُكتب التحليل، فيمكنك قراءة الذاكرة المخبئية cache بتطبيق مثل KCachegrind:

02 (1).png

سيعرض التطبيق معلومات التحليل متضمنةً:

  • الدوال المنفَّذة
  • وقت استدعاء الدالة بمفردها واستدعاءات الدالة اللاحقة.
  • عدد مرات استدعاء كل دالة.
  • رسوم بيانية للاستدعاء
  • روابط للشيفرة المصدرية

من الواضح أنّ ضبط الأداء خاص جدًا بحالات استخدام كل تطبيق، بشكل عام من الأفضل التركيز على النقاط التالية:

  • يجب ألا ترى استدعاءات متكررة لنفس الدالة، بالنسبة للدوال التي تعالج البيانات وتستعلم عنها قد يكون هناك فرص للتخزين المؤقت.
  • وجود دوال بطيئة التنفيذ، أين يستهلك التطبيق معظم وقته؟ أفضل عائد لضبط الأداء هو التركيز على أجزاء التطبيق التي تستهلك معظم وقته.

ملاحظة: إنّ إضافة Xdebug وخاصةً ميزاتها التحليلية مكثّفة للموارد وتبطئ تنفيذ PHP، لذا يُنصح بعدم تنفيذها في بيئة خادم الإنتاج.

استخدام الذاكرة

يُضبط حد ذاكرة زمن تنفيذ PHP باستخدام موجّه INI الـ  ‏memory_limit، حيث يمنع هذا الضبط أي تنفيذ PHP مفرد من استخدام الكثير من الذاكرة مما يؤدي إلى استنزافها من أجل السكربتات الأخرى وبرنامج النظام. حد الذاكرة الافتراضي هو 128MB ويمكن أن يتغير في ملف php.ini أو في وقت التنفيذ. يمكن أن يُضبط ليكون غير محدود لكن يعدّ هذا عمومًا ممارسةً سيئة.

يمكن أن يُحدَّد الاستخدام الدقيق للذاكرة أثناء وقت التنفيذ ᠎᠎عن طريق استدعاء الدالة memory_get_usage()‎ التي تعيد عدد بايتات الذاكرة المحجوزة للسكربت الحالي المُنفَّذ. بدءًا من الإصدار PHP 5.2 يوجد لهذه الدالة معامل منطقي اختياري للحصول على ذاكرة النظام المحجوزة الكلية على عكس الذاكرة الفعالة التي تستخدمها PHP.

<?php
echo memory_get_usage() . "\n";
// ‫الخرج 350688 (أو شيء ما مشابه وهذا يعتمد على النظام وإصدار PHP)

// ‫لنستهلك جزءًا من الذاكرة RAM
$array = array_fill(0, 1000, 'abc');

echo memory_get_usage() . "\n";
// 387704

// حذف المصفوفة من الذاكرة
unset($array);

echo memory_get_usage() . "\n";
// 350784

تعطيك الآن الدالة memory_get_usage استخدام الذاكرة في الوقت الذي نُفِّذت فيه، قد تخصص وتلغي تخصيص الكثير من الذاكرة بين استدعاءات الدالة المتلاحقة، يمكنك استخدام الدالة memory_get_peak_usage()‎ للحصول على الحجم الأعظمي من الذاكرة المستخدمة حتى نقطة معينة.

<?php
echo memory_get_peak_usage() . "\n";
// 385688
$array = array_fill(0, 1000, 'abc');
echo memory_get_peak_usage() . "\n";
// 422736
unset($array);
echo memory_get_peak_usage() . "\n";
// 422776

لاحظ أنّ القيمة إما سترتفع أو تبقى ثابتة.

التحليل باستخدام XHProf

XHProf محلل PHP مكتوب من قِبل شركة فيسبوك لتوفير بديل أخف للإضافة XDebug، يمكن تمكين/تعطيل التحليل بعد تثبيت الوحدة xhprof من شيفرة PHP:

xhprof_enable();
doSlowOperation();
$profile_data = xhprof_disable();

ستحتوي المصفوفة المُرجعة على بيانات حول عدد الاستدعاءات ووقت المعالج واستخدام الذاكرة لكل دالة تم الوصول إليها من داخل doSlowOperation()‎.

يمكن استخدام الدالة الموالية على أساس خيار أخف لتسجيل معلومات التحليل لجزء من الطلبات فقط (وبصياغة مختلفة).⁩

xhprof_sample_enable()/xhprof_sample_disable()

ولهذا يستخدم المحلل بعض الدوال المساعدة (معظمها غير موثَّق) لعرض البيانات (اطلع على هذا المثال)، أو يمكنك أاستعمال أدوات أخرى لتصورها (يمكنك الاطلاع على هذا المثال من مدونة platform.sh).

ترجمة -وبتصرف- للفصول Unit Testing - Performance من كتاب  PHP Notes for Professionals book


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...