Một ví dụ TDD đơn giản trong Laravel

Chào các bạn! Hôm nay mình xin giới thiệu với các bạn về một ví dụ đơn giản sử dụng TDD trong Laravel. TDD là gì và tại sao cần sử dụng nó chắc hẳn mọi người đã ít nhiều được nghe đến cho nên mình sẽ không đề cấp tiếp ở đây mà mình sẽ chỉ chú trọng vào ví dụ sử dụng TDD đơn giản để mọi người có thể hiểu qua về cách viết TDD. Giờ thì BẮT ĐẦU THÔI !!!

Đây là một ví dụ TDD cho ứng dụng Laravel cung cấp các API quản lý các tác vụ, người dùng có thể thêm, xóa các tác vụ khị cần thiết.

Cấu hình testing database

Cập nhật file phpunit.xml như sau:

<php>
    <server name="APP_ENV" value="testing"/>
    <server name="BCRYPT_ROUNDS" value="4"/>
    <server name="CACHE_DRIVER" value="array"/>
    <server name="DB_CONNECTION" value="sqlite"/>
    <server name="DB_DATABASE" value=":memory:"/>
    <env name="API_DEBUG" value="false"/>
    <ini name="memory_limit" value="512M" />
    <server name="MAIL_MAILER" value="array"/>
    <server name="QUEUE_CONNECTION" value="sync"/>
    <server name="SESSION_DRIVER" value="array"/>
    <server name="TELESCOPE_ENABLED" value="false"/>
</php>

Chúng ta sẽ sử dụng testing database sqlite thay vì database thực tế để tesr và chạy test trong bộ nhớ. Bạn phải đặt debug thành false vì chúng ta chỉ cần các lỗi thực tế để xác nhận. Tăng giới hạn bộ nhớ có thể cần thiết trong tương lai khi thực hiện các test trở nên tốn bộ nhớ.

Giờ hãy chạy thử các Test bằng câu lệnh php artisan test. Sẽ được kết quả như sau:

ExampleTest có săn của Laravel

Ta sẽ thấy 2 file ExampleTest đó là 2 file Test có sẵn sau khi mình tạo project Laravel

Cấu hình file TestCase cơ bản như sau

<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Faker\Factory as Faker;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication, DatabaseMigrations, DatabaseTransactions;

    protected $faker;

    /**
     * Set up the test
     */
    public function setUp(): void
    {
        parent::setUp();
        $this->faker = Faker::create();
    }

    /**
     * Reset the migrations
     */
    public function tearDown(): void
    {
        $this->artisan('migrate:reset');
        parent::tearDown();
    }
}

Chúng ta cần thêm trait DatabaseMigations , vì vậy trong mỗi lần chạy thử nghiệm, các file migration cũng đang được chạy. Phương thức setUp () dùng để tạo ra data test còn phương thức tearDown() dùng để bỏ đi các dữ liệu test vừa được tạo ra khi test chạy xong.

Viết Test.

Như đã đề cập bên đây là ứng dụng Laravel cung cấp API quản lý các tác vụ, người dùng có thể thêm, xóa các tác vụ khị cần thiết.

Nó dự kiến bao gồm các API như sau:

/api/task/create chấp nhận một request POST để tạo task

/api/task {id}complete đánh dấu một task là đã hoàn thành

/api/task/{id}/delete xóa task khỏi database

Giờ thì viết Test để đảm bảo các API chạy đúng.

Tạo một file feature test ApiTest.php bằng câu lệnh `php artisan make:test ApiTest

Test API tạo Task.

Trong file ApiTest.php vừa tạo thay hàm test_example mặc định bằng hàm sau:

public function test_that_a_task_can_be_added()
    {
        $this->withoutExceptionHandling();
        $response = $this->post('/api/tasks/create', [
            'name' => 'Write Blog TDD',
            'description' => 'Write and publish an blog TDD'
        ]);
        $response->assertStatus(201);
        $this->assertTrue(count(Task::all()) > 0);
    }

Giải thích:

$ this->withoutExceptionHandling () yêu cầu PHPUnit không xử lý các ngoại lệ mà chúng ta có thể nhận được. Thao tác này là vô hiệu hóa xử lý ngoại lệ của Laravel để ngăn Laravel xử lý các ngoại lệ xảy ra thay vì ném nó đi, làm điều này để có thể nhận được báo cáo lỗi chi tiết hơn trong đầu ra thử nghiệm của mình.

$response->assertStatus(201)  xác nhận rằng mã trạng thái HTTP được trả về từ request post là tài nguyên được tạo.

$this->assertTrue() để xác nhận rằng tác vụ đã thực sự được lưu trong cơ sở dữ liệu.

Sau đó tiến hành chạy Test bằng câu lệnh php artisan test thu được kết quả:

Chúng ta thấy rằng test của chúng ta chạy chưa thành công vì một request POST tới đường dẫn http://localhost/api/tasks/create mà đường dẫn này chưa tồn tại => Nó gửi ra ngoại lệ

Để xử lý ta cần đăng ký các đường dẫn trong file api.php như sau:

Route::group(['prefix' => 'tasks'], function () {
    Route::get('/{task}', [TaskApiController::class, 'show']);
    Route::post('/create', [TaskApiController::class, 'store']);
    Route::patch('{task}/complete',[TaskApiController::class, 'mark_task_as_completed']);
    Route::delete('/{id}', [TaskApiController::class, 'destroy']);
});

Sau đó chạy lại test và tiếp tục thu được lỗi như sau:

Test tiếp tục fail là do ta chưa có class TaskApiController. Để sửa ta sẽ tạo một resourse controller như sau:

php artisan make:controller TaskApiController --resource

Tiếp tục fail test là do mình chưa có class Task. Cần tạo tiếp một model Task và một file migration create_tasks_table bằng câu lệnh: php artisan make:model Task -m

Trong file Task.php như sau:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Task extends Model
{
    use HasFactory;

    protected $fillable = ['name', 'description', 'completed'];

}

Trong file migration create_tasks_table như sau:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateTasksTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('tasks', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->text('description');
            $table->boolean('completed')->default(0);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('tasks');
    }
}

Chạy lai Test một lần nữa và ta thấy test đã chạy thành công

Test API chỉnh sửa Task (đánh dấu là đã hoàn thành)

Ta sẽ thêm một đoạn code test sau vào ApiTest.php

public function test_that_a_task_can_be_completed()
{
    $this->withoutExceptionHandling();
    $task_id = Task::create([
        'name' => 'Write Blog TDD',
        'description' => 'Is completed'
    ])->id; // create a task and store the id in the $task_id variable
    $response = $this->patch("/api/tasks/$task_id/complete"); //sends a patch request in order to complete the created task
    $this->assertTrue(Task::findOrFail($task_id)->is_completed() == 1); // assert that the task is now marked as completed in the database
    $response->assertJson([
        'message' => 'task successfully marked as updated'
    ], true); // ensure that the JSON response recieved contains the message specified
    $response->assertStatus(200); // furthe ensures that a 200 response code is recieved from the patch request
}

Sau đó chạy Test thu được:

Chắc chắn là test sẽ fail và tương tư như phần test API tạo task ta sẽ fix từng lỗi một. Tổng hợp lại ta sẽ cần sửa file sau:

Thứ nhất là TaskApiController.php

 public function mark_task_as_completed($id)
 {
     $task = (Task::findOrFail($id));
     $task->mark_task_as_completed(); //calls the mark_task_as_complete method we created in the App/Models/Task.php file
     return response()->json([
     	'message' => 'task successfully marked as updated'
     ], 200); //send a json response with the 200 status code
 }

Thứ hai là Task.php

public function mark_task_as_completed()
{
    $this->completed = 1;
    $this->save();
}

public function is_completed()
{
    return $this->completed;
}

Sau đó chạy lại Test và ta thấy test đã chạy thành công

Test API xóa task

Ta thêm đoạn code Test sau vào ApiTest để test chức năng xóa task

public function mark_task_as_completed()
{
    $this->completed = 1;
    $this->save();
}

public function is_completed()
{
    return $this->completed;
}

Tương tự như 2 phần trên ta sẽ cần chạy Test fail, sau đó báo lỗi ở đâu ta sửa dần ở đó

Các file cần chỉnh sửa, thêm mới là: TaskFactory, TaskApiController. cụ thể

Thêm mới file TaskFactory

<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class TaskFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        return [
            'name' => $this->faker->sentence(4),
            'description' => $this->faker->sentence(20),
            'completed' => random_int(0, 1)
        ];
    }
}

Thêm code trong file TaskApiController

public function destroy($id)
{
    $task_to_be_deleted = Task::findOrFail($id);
    $task_to_be_deleted->delete();
    return response()->json([
    	'message' => 'task deleted successfully'
    ], 410);
}

Cuối cùng là tất cả Test đã chạy thành công

Bài viết tới đây đã khá dài. Mình hi vọng với bài viết này sẽ giúp các bạn có thể hiểu hơn về tư tương viết TDD trong Laravel nói riêng và toàn bộ ngôn ngữ, framework khác nói chung.

Cảm ơn mọi người đã đọc!!!

THAM KHẢO:

Simple TDD in Laravel with 11 steps
Most web developers cringe when they hear of TDD (test-driven development). Well, I did when I was asked to program with TDD first.
Giới thiệu về Testing trong Laravel
Giới thiệu 1. Một số khái niệm cơ bản. Unit Test: Kiểm thử mức đơn vị. Trong Unit Test chúng ta sẽ kiểm thử các class, method, function, ... Mục tiêu của Unit Test là kiểm thử tính đúng đắn của mỗi đơn vị trong một dự án. PHP Unit: Là