تمرین‌های تست‌نویسی دوره‌های «جامپ» کوئرا کالج را چگونه داوری می‌کنیم؟

949

دوره‌های «جامپ بک‌اند با جنگو» و «جامپ بک‌اند با لاراول» از محبوب‌ترین دوره‌های آموزش جنگو و لاراول هستند. این دوره‌های آموزشی شامل تمرین‌های زیادی هستند که داوری و امتیازدهی آن‌ها به‌صورت خودکار انجام می‌شود. این ویژگی، دوره‌های کوئرا کالج را از سایر دوره‌ها متمایز می‌کند؛ اما طراحی چنین دوره‌هایی به مراتب سخت‌تر از سایر دوره‌های آموزشی است. طراحان این دوره‌ها علاوه بر نوشتن درس‌نامه‌های متنی، ضبط ویدیوهای آموزشی و نوشتن صورت تمرین‌ها، تست‌هایی برای تمرین‌ها نوشته‌اند که بعضاً چالشی هستند. در این مقاله، قرار است نحوه‌ی داوری تمرین‌های فصل «تست‌نویسی» دوره‌ی «جامپ بک‌اند با لاراول» و چالش‌های آن را بررسی کنیم.

ایده‌ی اولیه‌ی سؤالات و بررسی چالش

اولین چیزی که برای شروع طراحی یک سؤال لازم است، ایده و سناریوی سؤال است که باید در ذهن طراح شکل بگیرد. فصل «تست‌نویسی» دوره‌ی لاراول شامل دو تمرین است که خلاصه‌ی سناریوی آن‌ها به شرح زیر است:

  • تعدادی تست به کاربر داده می‌شود و او باید برنامه را طوری پیاده‌سازی کند تا این تست‌ها pass شوند.
  • ساختار یک کلاس تست به کاربر داده می‌شود و او باید تست‌ها را طوری پیاده‌سازی کند تا برنامه به‌درستی تست شود.

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

از این سناریو در تمرین «بی‌باگ» استفاده شده است.

ساختار تست‌ها

کاربر قرار است تعدادی feature test یا اصطلاحاً black-box test برای بخش احراز هویت یک وب‌سایت بنویسد. بخشی از ساختار آن به‌صورت زیر است:

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AuthTest extends TestCase
{
    use RefreshDatabase;

    public function test_login_successful()
    {
        // TODO: Implement
    }

    public function test_login_no_username_provided()
    {
        // TODO: Implement
    }

    public function test_login_no_password_provided()
    {
        // TODO: Implement
    }

    public function test_login_invalid_credentials()
    {
        // TODO: Implement
    }
}

در اینجا، حتی اختیار محتویات دیتابیس هم دست ما نیست! کاربر می‌تواند هر کاربری را برای تست در دیتابیس درج کند و از آن برای تست برنامه استفاده کند. در صورت سؤال، توضیحات مربوط به هر یک از تست‌ها ذکر شده است که بخشی از آن به‌صورت زیر است:

  • تست test_login_successful: این تست اطلاعات معتبری را در فرم ورود وارد می‌کند.
  • تست test_login_no_username_provided: این تست فقط رمز عبور را در فرم ورود وارد می‌کند.
  • تست test_login_no_password_provided: این تست فقط نام کاربری یا رمز عبور را در فرم وارد می‌کند.
  • تست test_login_invalid_credentials: این تست یک نام کاربری یا رمز عبور نادرست را در فرم ورود وارد می‌کند.

کاربر باید بداند در هر یک از این تست‌ها باید دقیقاً چه assertionهایی بنویسد. بنابراین، رفتار برنامه در حالت‌های مختلف در صورت سؤال ذکر شده است که بخشی از آن به‌صورت زیر است:

  • اگر نام کاربری وارد نشده باشد، عبارت __('validation.required', ['attribute' => 'username']) در پاسخ درخواست ظاهر خواهد شد.
  • اگر رمز عبور وارد نشده باشد، عبارت __('validation.required', ['attribute' => 'password']) در پاسخ درخواست ظاهر خواهد شد.
  • اگر نام کاربری یا رمز عبور نادرست باشد، عبارت __('auth.failed') در پاسخ درخواست ظاهر خواهد شد.
  • اگر ورود موفقیت‌آمیز باشد، کاربر در سیستم وارد خواهد شد و عبارت __('auth.success') در پاسخ درخواست ظاهر خواهد شد.

همچنین، روت‌های برنامه نیز در اختیار کاربر قرار داده شده‌اند تا بداند به چه آدرس‌هایی درخواست ارسال کند:

<?php

use App\Http\Controllers\AuthController;

Route::prefix('panel')->group(function () {
    Route::get('/login', [AuthController::class, 'showLoginForm']);
    Route::post('/login', [AuthController::class, 'attemptLogin']);
    Route::get('/signup', [AuthController::class, 'showSignupForm']);
    Route::post('/signup', [AuthController::class, 'attemptSignup']);
});

با بیان این جزئیات، کاربر می‌تواند تست‌ها را به‌طور کامل پیاده‌سازی کند. اما چگونه قرار است متوجه شویم که این تست‌ها به‌درستی پیاده‌سازی شده‌اند؟

تست‌ها را تست کنیم!

برای داوری سؤالات، به هر حال باید مواردی که کاربر پیاده‌سازی می‌کند را تست کرد. در اینجا هم باید تست‌ها را تست کرد. شاید این راه‌حل یک bad practice محسوب شود، اما چاره‌ای نیست! هر روشی در برنامه‌نویسی که در حالت کلی یک روش خوب محسوب می‌شود، ممکن است در بعضی شرایط بدترین روش ممکن باشد. برعکس این قضیه برای روش‌هایی که برنامه‌نویس‌ها آن‌ها را روش‌هایی بد خطاب می‌کنند نیز صادق است. بگذریم؛ بیایید به بررسی این روش جذاب برای تست کردن برنامه بپردازیم.

ایجاد پیاده‌سازی‌های مختلف از برنامه

برای تست کامل تست‌های کاربر، باید حالت‌های مختلفی از برنامه را پیاده‌سازی کنیم که در آن‌ها بخشی از تست‌های کاربر باید pass شوند و بخشی از آن‌ها باید fail شوند.فایل‌هایی که در پیاده‌سازی‌های مختلف تفاوت دارند در قالب stub در‌آمده‌اند تا بتوان بخش‌هایی از آن‌ها را در حالت‌های مختلف تغییر داد. برای مثال، stub مربوط به کنترلر AuthController به‌صورت زیر است:

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Auth;

use App\Http\Requests\LoginRequest;
use App\Models\User;

class AuthController extends Controller
{
    public function attemptLogin(LoginRequest $request)
    {
        {{ attempt_login_body }}
    }
}

stub مربوط به کلاس LoginRequest نیز به‌صورت زیر است:

<?php

namespace App\Http\Requests;

use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;

class LoginRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        {{ rules }}
    }

    protected function failedValidation(Validator $validator)
    {
        $errors = $validator->errors();
    
        $response = response()->json($errors->messages(), 422);
    
        throw new HttpResponseException($response);
    }
}

حالت‌های مختلف کنترلر و FormRequest در تعدادی فایل متنی نوشته شده‌اند و همه‌ی ترکیب‌های مختلف این حالت‌ها توسط یک اسکریپت کوچک تولید می‌شوند. همان‌طور که می‌دانید، اگر یک فایل روی نتیجه‌ی n تست تأثیرگذار باشد، محتوای این فایل می‌تواند 2n حالت مختلف داشته باشد (هر تست دو حالت دارد؛ یا pass می‌شود یا fail). در واقع، متغیر attempt_login_body شامل این تعداد حالت خواهد بود. برای تولید آسان این حالت‌ها، attempt_login_body بر اساس منطقش به دو بخش تقسیم شده است که بخش اول مربوط به احراز هویت و بخش دوم مربوط به ارسال پاسخ است. نمونه‌ی صحیح پیاده‌سازی این دو بخش به‌صورت زیر است:

$success = Auth::attempt([
    'username' => $request->input('username_or_email'),
    'password' => $request->input('password')
]);
return response($success ? __('auth.success') : __('auth.failed'));

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

از آن‌جایی که تعداد این حالت‌ها بسیار زیاد است و اجرای تست‌ها را زمان‌بر می‌کند، تنها تعدادی از آن‌ها به‌صورت تصادفی انتخاب می‌شوند. با این کار، ممکن است تست‌های یک کاربر ایراد داشته باشند، اما امتیاز کامل این سؤال را دریافت کند (البته احتمال وقوع این حالت بسیار کم است). این مورد یک trade-off بین دقت تست‌ها و سرعت اجرای آن‌ها است.

البته نسبت دقت تست‌ها به تعداد پیاده‌سازی‌ها خطی نیست.

حال، باید بدانیم که هر یک از تست‌ها در هر یک از پیاده‌سازی‌ها pass می‌شود یا fail. برای این کار، پاسخ صحیح سؤال (که همان تست‌های موجود در کلاس AuthTest است) پیاده‌سازی شده است تا بتوان نتیجه‌ی هر تست را به ازای هر پیاده‌سازی دریافت کرد. یک اسکریپت ساده برای این کار نوشته شده که نتیجه‌ی اجرای تست‌ها را در قالب JSON به‌صورت زیر برمی‌گرداند:

{
    "test_login_successful": true,
    "test_login_no_username_provided": true,
    "test_login_no_password_provided": true,
    "test_login_invalid_credentials": true
}

این خروجی به ازای هر پیاده‌سازی تولید شده و در دایرکتوری مربوط به پیاده‌سازی در یک فایل ذخیره شده است. اکنون همه چیز برای تست کردن تست‌های کاربر آماده است.

اجرای تست‌های کاربر در PHPUnit

سیستم داوری لاراول در کوئرا به‌صورت پیش‌فرض تست‌هایی که مطابق با کانفیگ موجود در فایل phpunit.xml هستند را اجرا می‌کند. برای جلوگیری از اجرای تست‌های کاربر در شروع فرایند تست، باید آن را بیرون از دایرکتوری `tests` قرار داد. برای اجرای تست‌های کاربر، کافی است تا فایل تست پیاده‌سازی‌شده توسط کاربر در دایرکتوری تست‌ها قرار داده شود و همان دستور همیشگی php artisan test در تست‌ها اجرا شود. البته نوع خروجی تست‌ها مهم است. خروجی‌ای که همیشه در ترمینال با آن سروکار داریم، خروجی‌ای نیست که برنامه بتواند آن را به راحتی parse کند. متأسفانه PHPUnit امکان نمایش خروجی JSON را ندارد، اما خوشبختانه از فرمت‌هایی نظیر XML پشتیبانی می‌کند. همچنین، امکانی با نام TestDox در PHPUnit وجود دارد که با استفاده از آن می‌توان خروجی تست‌ها را به این شکل دریافت کرد:

$ phpunit --testdox BankAccountTest.php
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.

BankAccount
 ✔ Balance is initially zero
 ✔ Balance cannot become negative

اگرچه این خروجی همانند خروجی XML شامل تمام جزئیات اجرای تست‌ها نیست، اما به‌راحتی می‌توان آن را parse کرد.

برای اجرای تست‌ها، به‌جای shell_exec یا exec که ظاهر زیبایی ندارند، از کلاس Symfony\Component\Process\Process استفاده شده است. اجرای تست‌های کاربر توسط متد getTestResults انجام می‌شود:

private static function getTestResults(): array
{
    $process = new Process([
        'php',
        'artisan',
        'test',
        '--order-by', 'random',
        '--testdox-text', self::$testLogFilepath,
        self::$testFilepath
    ]);
    $process->run();
    preg_match_all('/ \[(.)] (.*)/', file_get_contents(self::$testLogFilepath), $matches);
    $result = [];
    for ($i = 0; $i < count($matches[0]); $i++) {
        $testName = 'test_'.Str::snake($matches[2][$i]);
        $result[$testName] = $matches[1][$i] == 'x';
    }

    return $result;
}

از فلگ --order-by random به این دلیل استفاده شده تا کاربر تا حدی مجبور به رعایت اصل Independent از اصول F.I.R.S.T شود.

طبق قرارداد، اولین پیاده‌سازی همان پیاده‌سازی‌ای است که همه‌ی تست‌ها باید به ازای آن pass شوند. پیش از اجرای تست‌ها، یک mapping از نام تست‌ها به نتیجه‌ی اجرای آن‌ها ایجاد می‌شود:

public static function setUpBeforeClass(): void
{
    self::$tests = json_decode(file_get_contents(__DIR__.'/'.self::$codesDirname.'/1/expected_results.json'), true);
    if (!file_exists(self::$testFilepath)) {
        copy(self::$submissionFilepath, self::$testFilepath);
    }
    self::runTests();
}

متد runTests فایل‌های پیاده‌سازی را در برنامه کپی می‌کند، تست‌ها را اجرا می‌کند و نتیجه را با نتیجه‌ی موردانتظار مقایسه می‌کند. اگر نتیجه‌ی تست‌ها با نتیجه‌ی موردنظر متفاوت باشد، مقدار آن در map مربوط به تست‌ها به false تغییر می‌کند:

private static function runTests(): void
{
    $sampleDirs = glob(__DIR__.'/'.self::$codesDirname.'/*');
    shuffle($sampleDirs);
    foreach ($sampleDirs as $sampleDir) {
        foreach (self::$fileMappings as $srcFile => $destDir) {
            copy($sampleDir.'/'.$srcFile, self::BASE_PATH.'/'.$destDir.'/'.$srcFile);
        }
        $expectedResults = json_decode(file_get_contents($sampleDir.'/expected_results.json'), true);
        $actualResults = self::getTestResults();
        foreach ($expectedResults as $testName => $expectedResult) {
            if (!array_key_exists($testName, $actualResults)
                || $expectedResult != $actualResults[$testName]) {
                self::$tests[$testName] = false;
            }
        }
    }
}

پس از اجرای تست‌های کاربر به تعداد پیاده‌سازی‌ها، نوبت به اجرای تست‌های داوری می‌رسد. همه‌ی این تست‌ها بدنه‌ی یکسانی دارند و صرفاً بررسی می‌کنند که آیا تست موردنظر در همه‌ی حالت‌ها نتیجه‌ی درستی داشته یا نه:

public function test_login_successful()
{
    $this->assertTrue(self::$tests[__FUNCTION__]);
}

public function test_login_no_username_or_email_provided()
{
    $this->assertTrue(self::$tests[__FUNCTION__]);
}

public function test_login_no_password_provided()
{
    $this->assertTrue(self::$tests[__FUNCTION__]);
}

public function test_login_invalid_credentials()
{
    $this->assertTrue(self::$tests[__FUNCTION__]);
}

به این ترتیب، تست‌های کاربر تست می‌شوند و کاربر متوجه می‌شود که کدام تست‌ها را به‌درستی پیاده‌سازی کرده است.

برای بهبود تست‌ها، می‌توان نوع خروجی تست‌ها را به XML تغییر داد و پیام مربوط به assertionها را در تست‌ها ذخیره کرد. با این کار، می‌توان از این پیام در assertTrueهای مربوط به تست‌های داوری استفاده کرد تا بتوان تست‌ها را راحت‌تر دیباگ کرد.

جمع‌بندی

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

آموزش برنامه نویسی با کوئرا کالج
نیما حیدری‌نسب

اشتراک در
اطلاع از
guest

2 دیدگاه‌
قدیمی‌ترین
تازه‌ترین بیشترین واکنش
بازخورد (Feedback) های اینلاین
View all comments
negin
negin
1 سال قبل

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