Giới thiệu mô hình TDD trong lập trình, và áp dụng với framework Laravel

Để hiểu rõ hơn về TDD và sử dụng nó trong thực tế, chúng ta tiếp tục tìm hiểu TDD thông qua phần 2: Sử dụng TDD với framework Laravel nhé.

Các bạn cũng có thể xem lại phần 1 để hiểu rõ hơn về mô hình TDD: Phần 1: Giới thiệu về TDD, cách hoạt động, nhưng lợi ích và vấn đề gặp phải khi sử dụng TDD

I. Chu trình của TDD

Như phần 1 đã có đề cập tới, Test-Driven Development(TDD) là một cách phát triển phần mềm nhấn mạnh vào việc viết các Test-case trước để chỉ định những logic mà mã nguồn sẽ thực hiện.

Một chu trình của TDD sẽ bao gồm:

  1. Viết test-case
  2. Chạy test-case đã viết và sẽ nhận được kết quả fail do chưa có mã nguồn được viết
  3. Viết mã nguồn (code) để đáp ứng test-case
  4. Chạy lại test-case để kiểm tra mã nguồn được viết đã đáp ứng được chưa
  5. Chỉnh sửa mã nguồn để pass test-case (refactor)
  6. Lặp lại bước re-run (4) và refactor (5) cho đến khi test-case được pass

14lY4dxQLqqqrqDMloqj3S46eNZxX8_qC

II. Áp dụng TDD trong Laravel 7

Để hiểu rõ hơn việc áp dụng TDD trong Laravel như thế nào, chúng ta sẽ bắt đầu từng bước với chức năng thêm mới 1 người dùng nhé.

1. Chuẩn bị môi trường

Trước khi bắt đầu code, các bạn cần có một môi trường có thể chạy được ứng dụng web php

Trong bài viết này, mình sử dụng Cloud9 trên AWS để tạo IDE có thể code được luôn, các bạn có thể xem thêm tại Creating an environment in AWS Cloud9. Chi tiết môi trường sử dụng:

ec2-user:~/environment $ git --version
git version 2.32.0
ec2-user:~/environment $ php composer.phar -V
Composer version 2.3.7 2022-06-06 16:43:28
ec2-user:~/environment $ php -v
PHP 7.2.24 (cli) (built: Oct 31 2019 18:27:08) ( NTS )
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.2.0, Copyright (c) 1998-2018 Zend Technologies
    with Xdebug v3.1.5, Copyright (c) 2002-2022, by Derick Rethans

2. Khởi tạo project laravel

Sử dụng composer để tạo project laravel
php composer.phar create-project laravel/laravel example_tdd
cd example_tdd 

Output:

ec2-user:~/environment $ php composer.phar create-project laravel/laravel example_tdd
Creating a "laravel/laravel" project at "./example_tdd"
Info from https://repo.packagist.org: #StandWithUkraine
Installing laravel/laravel (v7.30.1)
........
Package manifest generated successfully.
69 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
> @php artisan key:generate --ansi
Application key set successfully.
Chỉnh sửa file config

Các test-case sử dụng các giá trị trong file phpunit.xml làm config khi chạy. Mỗi lần thực thi thì nên refresh database, nghĩa là nó sẽ xóa sạch database và chạy lại migrate mỗi lần thực thi. Vì vậy, chúng ta không nên sử dụng chung database của môi trường code (local, dev) và database của test.
Ở bước này, chúng ta sẽ cài đặt để database riêng để sử dụng cho việc test. Mở file phpunit.xml và bỏ comment 2 dòng sau:

 <server name="DB_CONNECTION" value="sqlite"/> 
 <server name="DB_DATABASE" value=":memory:"/> 

Với config này, thì khi chạy test, hệ thống sẽ sử dụng DB kiểu sqlite và DB này sẽ được lưu vào RAM, vì vậy, chúng ta không cần phải cài đặt hay tạo cơ sở dữ liệu riêng. Và đồng thời, việc lưu vào RAM sẽ giúp cho test-case chạy nhanh hơn

Lưu ý: Trong dự án thực tế, thường sẽ sử dụng biến môi trường .env chứa các giá trị của môi trường mà bạn đang code, ví dụ local, dev, production. Thì với trường hợp test, chúng ta sẽ tạo 1 file .env.testing, khi chạy test, hệ thống sẽ dùng các giá trị trong file .env.testing thay cho .env.

Thử chạy test với các case demo có sẵn với cú pháp của phpunit:

./vendor/bin/phpunit

Hoặc sử dụng câu lệnh của Laravel Artisan để dễ phát triển và debug hơn với output được hiển thị dễ nhìn hơn:

php artisan test

Output:

ec2-user:~/environment/example_tdd $ php artisan test

PASS  Tests\Unit\ExampleTest
✓ basic test

PASS  Tests\Feature\ExampleTest
✓ basic test

Tests:  2 passed
Time:   0.15s

Như vậy là chúng ta đã hoàn thành việc chuẩn bị để viết test-case. Bây giờ bắt tay vào viết Unit-Test cho màn hình quản lý người dùng nhé.

3. Viết UnitTest theo mô hình TDD

Để có thể viết được test-case, chúng ta cần hiểu được chức năng này hoạt động như thế nào (phân tích bài toán), sau đó sẽ nghĩ tới giải pháp thực hiện chức năng (thiết kế luồng code). Ví dụ trong API thêm người dùng mới, chúng ta sẽ thiết kế các bước như sau:

  • beforeAddUser: Thực hiện validate, chuẩn hóa dữ liệu nhận được trước khi insert vào cơ sở dữ liệu
  • doAddUser: Thực thi việc insert dữ liệu người dùng đã được chuẩn hóa vào cơ sở dữ liệu.
  • afterAddUser: Xử lý kết quả insert, chuẩn hóa để đúng format trả về cho client.

UnitTest thường được sử dụng để test các function nhỏ phục vụ việc tính toán, hoặc convert dữ liệu. FeatureTest thường sử dụng để test chức năng hoàn chỉnh như chức năng thêm, sửa, xóa. Vì vậy, chúng ta sẽ thiết kế test-case như sau:

  • test_before_add_user: UnitTest cho method beforeAddUser
  • test_after_add_user: UnitTest cho method afterAddUser
  • admin_can_add_user: FeatureTest cho cả chức năng addUser.

Trong bài viết này, mình sẽ hướng dẫn viết test_before_add_user theo TDD, 2 case tests còn lại thì cũng sẽ làm tương tự nhé.

Code Base TestCase.php

Khi cần gọi đến các phương thức protect/private hoặc thay đổi property trong class, ta sẽ sử dụng \ReflectionClass để thực hiện được. Để tránh phải viết lại code xử lý logic này, chúng ta thêm đoạn code sau vào tests/TestCase.php:

 /**
 * Get private/protected property value
 */
public function getObjectProperty($object, $propertyName) {
    $reflector = new \ReflectionClass(get_class($object));
    $property = $reflector->getProperty($propertyName);
    $property->setAccessible(true);

    return $property->getValue($object);
}

/**
 * Call protected/private method of a class.
 */
public function invokeObjectMethod($object, $methodName, $parameters = [])
{
    $reflection = new \ReflectionClass(get_class($object));
    $method = $reflection->getMethod($methodName);
    $method->setAccessible(true);

    return $method->invokeArgs($object, $parameters);
}
UnitTest beforeAddUser

Bước 1: Viết TestCase mới
Sử dụng Laravel Artisan để tạo Tests:

php artisan make:test UserUnitTest --unit

Sau khi chạy lệnh, file UserUnitTest.php sẽ được tạo trong đường dẫn tests/Unit

Chú ý:

  • Tên của class test phải được đặt theo quy tắc *Test, nếu muốn thay đổi, bạn có thể sửa trong file phpunit.xml
  • Để PHPUnit biết phương thức nào trong class cần chạy test, thì tên của phương thức đó cần có tiếp đầu ngữ test, ví dụ test_user_show, hoặc sử dụng annotation /** @test */
    UnitTest cho beforeAddUser:
/**
 * Test beforeAddUser 
 *
 * @test
 */
public function test_before_add_user()
{
    // input data 
    $dataInput = [
        'first_name' => $this->faker->firstName(),
        'last_name' => $this->faker->lastName(),
        'email' => $this->faker->email,
        'password' => $this->faker->password,
    ];

    // execute
    $output = $this->invokeObjectMethod(new \App\Http\Controllers\UserController(), 'beforeAddUser', [$dataInput]);

    // expect data
    $dataExpect = [
        'name' => $dataInput['first_name'] . ' ' . $dataInput['last_name'],
        'email' => $dataInput['email'],
        'password' => $dataInput['password'], 
    ];

    // assert
    $this->assertEquals($output, $dataExpect);
}

Bước 2: Chạy TestCase
Chạy test, chúng ta sẽ thấy có lỗi xảy ra vì chưa có code:

ec2-user:~/environment/example_tdd $ php artisan test

PASS  Tests\Unit\ExampleTest
✓ basic test

FAIL  Tests\Unit\UserUnitTest
✕ before add user

Tests:  1 failed, 1 passed, 1 pending

ReflectionException 

Class App\Http\Controllers\UserController does not exist

Bước 3: Viết Code để đáp ứng được TestCase vừa tạo
Tạo UserController:

php artisan make:controller UserController 
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class UserController extends Controller
{
    /**
     * Before add user
     */
    protected function beforeAddUser($data)
    {
        return $data;
    }
}

Bước 4: Chạy lại UnitTest

ec2-user:~/environment/example_tdd $ php artisan test

PASS  Tests\Unit\ExampleTest
✓ basic test

FAIL  Tests\Unit\UserUnitTest
✕ before add user

Tests:  1 failed, 1 passed, 1 pending

Failed asserting that two arrays are equal.

-> Lỗi do logic code đang trả về dữ liệu chưa đúng

Bước 5: Sửa code để pass UnitTest

/**
 * Before add user
 */
protected function beforeAddUser($data)
{
    return [
        'name' => $data['first_name'] . ' ' . $data['last_name'],
        'email' => $data['email'],
        'password' => $data['password'], 
    ];
}

Lặp lại bước 4 và bước 5 cho tới khi pass UnitTest

ec2-user:~/environment/example_tdd $ php artisan test

PASS  Tests\Unit\ExampleTest
✓ basic test

PASS  Tests\Unit\UserUnitTest
✓ before add user

PASS  Tests\Feature\ExampleTest
✓ basic test

Tests:  3 passed
Time:   0.32s

-> Đã pass UnitTest, và phương thức beforeAddUser đã hoạt động theo đúng logic mà chúng ta đã phân tích và thiết kế ban đầu.

Như vậy chúng ta đã hoàn thành xử lý logic cho beforeAddUser, các phần khác trong thiết kế chúng ta cũng sẽ thực hiện các bước tương tự.

III. Tổng kết

Việc áp dụng TDD sẽ giúp lập trình viên có cái nhìn tổng quát về chức năng mình đang chuẩn bị thực hiện, đồng thời cũng sẽ nâng cao được kỹ năng phân tích, thiết kế và đưa ra các giải pháp để xử lý bài toán.
Tuy sẽ mất thêm thời gian cho việc làm quen TDD, và thêm cả thời gian thiết kế, viết test-case, nhưng những lợi ích mà nó đem lại thì theo mình nghĩ là hoàn toàn xứng đáng.
Bài viết về TDD của mình xin phép dừng tại đây. Cảm ơn các bạn đã đọc và rất mong nhận được sự góp ý của các bạn.

IV. Tài liệu tham khảo

Test-Driven Development (TDD) in Laravel 8