+6

Laravel testing controler, form request - How do you do?

Intro

Giả sử bạn có form request như thế này, bạn sẽ test nó như thế nào?

class ProductCreateRequest extends FormRequest
{
    public function rules()
    {
        return [
            'name' => ['required', 'max:255'],
            'sku' => ['required', Rule::unique(Product::getTableName(), 'sku')],
            'image' => ['nullable', 'mimes:jpg,png'],
            'quantity' => ['required', 'integer', 'min:1'],
            'description' => ['required'],
            'short_description' => ['nullable', 'max:255'],
        ];
    }
}
class ProductController extends Controller
{
    public function store(ProductCreateRequest $request)
    {
        $inputs = $request->validated();

        $product = $this->productService->create($inputs);

        return back()->with('product', $product);
    }
}

Nhắc lại một chút về Form Request:

  • Nhiệm vụ chính của class là dùng để khai báo validation rules cho form
  • Có thể kèm theo việc check authorization
  • (*1) Có thể kèm theo "after" validation hook
  • (*2) Có thể kèm theo prepareForValidation
  • Không dùng độc lập mà chỉ inject vào controller (type-hint biến $request)
  • Laravel service container khi thực hiện controller action sẽ tự động resolve Form Request dựa vào type hint và tự động thực hiện validate với các rules đã khai báo trong Form Request

Form Request có thể được test thông qua Unit Test hoặc Integration Test (Laravel thường gọi là Feature Test).

Unit Test

The first way

Cách tiếp cận đầu tiên đó là test để kiểm tra tất cả rules cần thiết đã được định nghĩa trong Form Request:

function test_it_should_contain_all_the_expected_validation_rules()
{
    $request = new ProductCreateRequest();

    $this->assertEquals([
        'name' => ['required', 'max:255'],
        'sku' => ['required', Rule::unique(Product::getTableName(), 'sku')],
        'image' => ['nullable', 'mimes:jpg,png'],
        'quantity' => ['required', 'integer', 'min:1'],
        'description' => ['required'],
        'short_description' => ['nullable', 'max:255'],
    ], $request->rules());
}

Rất đơn giản bạn đã có 100% code coverage cho class ProductCreateRequest!

Về lý thuyết thì không sai, function làm gì thì test đúng chức năng của function đấy. Nhưng tôi thì không thích cách này vì một số lý do:

  • Nó giống như đang duplicate code thành 2 nơi
  • Tạo thói quen không tốt, người viết chỉ việc copy code là đạt được coverage, mà chẳng cần để ý xem nó có thật sự chạy đúng không

The second approach

Cách tiếp cận thứ hai đó là dựa vào document của Laravel về Manually Creating Validators.

Chúng ta sẽ tự khởi tạo một Validator instance với các rules được khai báo trong Form Request:

function test_it_fails_when_name_is_missing()
{
    $request = new ProductCreateRequest();

    $validator = Validator::make([
        'sku' => 'a-product-sku',
        'quantity' => 1,
        'description' => 'I am the master key, buy me!',
    ], $request->rules());

    $this->assertFalse($validator->passes());
    $this->assertContains('title', $validator->errors()->keys());
}

Nếu viết theo test case trên, chúng ta sẽ cần rất nhiều test case nữa để test cho từng rule, của từng input:

  • Test name required
  • Test name max length
  • Test sku required
  • Test sku unique
  • Test image type
  • Test quantity required
  • Test quantity is number
  • ...

Nhưng có thể tóm gọn lại bằng cách sử dụng @dataProvider, ví dụ:

/**
 * @dataProvider provideInvalidData
 */
function test_invalid_data(array $data)
{
    $request = new ProductCreateRequest();

    $validator = Validator::make($data, $request->rules());

    $this->assertFalse($validator->passes());
}

function makeInvalidData($invalidInputs)
{
    $validInputs = [
        'sku' => 'a-product-sku',
        'quantity' => 1,
        'description' => 'I am the master key, buy me!',
    ];

    return array_merge($validInputs, $invalidInputs);
}

function provideInvalidData()
{
    return [
        [[]], // missing fields
        [$this->makeInvalidData(['name' => ''])],
        [$this->makeInvalidData(['name' => 'name exceed length ' . str_repeat('a', 256)])],
        // How to test file updload?
        [$this->makeInvalidData(['sku' => ''])],
        [$this->makeInvalidData(['sku' => 'existed-sku'])], // How to test Unique rule?
    ];
}

Vẫn còn một số câu hỏi bỏ ngõ ở trên 😄 nhưng chủ yếu là đưa ra ý tưởng test trước.

Nhược điểm lớn nhất của cách này đó là không thể test được hai ý (*1)(*2) nêu ở trên và còn nhiều trường hợp nữa đòi hỏi bạn đào sâu hơn vào cách hoạt động của framework (*3).

Integration Test

Trước khi đi vào chi tiết tôi nghĩ cần thống nhất một chút quan điểm về integration test.

Integration test ở đây tức là test tích hợp một nhóm các Unit với nhau. Laravel gọi chung nó là Feature Test, đã có pull request định tách ra làm 3 loại riêng biệt Unit Test, Integration Test và Feature Test ở đây nhưng cuối cùng vẫn keep như cũ là Unit Test và Feature Test.

Quan điểm ở bài viết này về Integration Test, cụ thể với Laravel đó là:

  • Sử dụng HTTP test để test route (tích hợp controller, form request và framework routing)
  • Có thể sử dụng DB, ví dụ khi test reposity integrate với DB
  • Có thể sử dụng mock để mock queue, storage, mail, repository, service...

Nó vẫn gần gần với Unit Test, chỉ là sử dụng thêm các tính năng, helper của framework để viết test dễ dàng hơn, chưa đến mức test full luồng như một end-user thực sự của hệ thống.

Bắt đầu Integration Test với tài liệu của Laravel: HTTP Tests.

function test_it_fails_when_name_is_missing()
{
    $url = action([ProductController::class, 'store']);

    $response = $this->post($url, [
        'sku' => 'a-product-sku',
        'quantity' => 1,
        'description' => 'I am the master key, buy me!',
    ]);

    $response->assertStatus(422);
    $response->assertSessionHasErrors(['name']);
}

Nó khá giống với Unit Test cách 2 ở trên, nhưng có một số điểm khác biệt dễ nhận thấy đó là:

  • Test trông giống thực tế hơn, vì ta thực hiện request trực tiếp đến route url sử dụng helper method $this->post() của Laravel. Bạn có thể xem cách Laravel giả lập request ở đây, bằng cách sử dụng HTTP Kernel, chứ không phải request đến server qua network!
  • Khắc phục được được vấn đề (*3) vì luồng xử lý request đã follow theo framework

Tất nhiên, ta có thể áp dụng @dataProvider như đã giới thiệu ở trên, nhưng liệu có cách nào tốt hơn không? - Sử dụng multiple data provider.

function makeInvalidData($invalidInputs)
{
    $validInputs = [
        'sku' => 'a-product-sku',
        'quantity' => 1,
        'description' => 'I am the master key, buy me!',
    ];

    return array_filter(array_merge($validInputs, $invalidInputs), function ($value) {
        return $value !== null;
    });
}

/**
 * @dataProvider provideInvalidName
 * @dataProvider provideInvalidQuantity
 * @dataProvider provideInvalidSku
 * @dataProvider provideInvalidImage
 * ...
 */
function test_it_show_error_when_input_invalid($inputKey, $inputValue)
{
    $url = action([ProductController::class, 'store']);
    $inputs = $this->makeInvalidData([
        $inputKey => $inputValue,
    ]);

    $response = $this->post($url, $inputs);

    $response->assertSessionHasErrors([$inputKey]);
}

function provideInvalidName()
{
    return [
        // Tên dataset => dataset value [$inputKey, $inputValue]
        'Name is required' => ['name', null],
        'Name is limit to 255 chars' => ['name' => str_repeat('a', 256)],
    ];
}

function provideInvalidQuantity()
{
    return [
        'Quantity is required' => ['quantity', null],
        'Quantity should be integer' => ['quantity', 1.1],
        'Quantity should be greater than 1' => ['quantity', 0],
    ];
}

PHPUnit hỗ trợ nhiều data provider cho một test case, nên ý tưởng ở đây là: mỗi input sẽ có một data provider và trong provider chúng ta sẽ mô tả dữ liệu cho case làm validation failed. Với cách này, chúng ta focus vào việc tạo dữ liệu test. Và với việc đặt tên cho mỗi dataset, khi đọc lại code sẽ rất dễ hiểu, cũng như khi một trường hợp fail, PHPUnit sẽ hiển thị message rõ ràng:

1) Tests\Feature\ProductControllerTest::test_it_show_error_when_input_invalid with dataset "Quantity should be integer" ('quantity', 1.1)

Nếu bạn follow theo convention: Controller chỉ xử lý điều hướng request, logic được xử lý ở Repository hay Service class. Thì việc test unit thuần cho controller là không cần thiết, thay vào đó chúng ta sẽ thực hiện test integration: routing, controller, form request. Việc assert cũng dễ dàng hơn do Laravel đã hỗ trợ sẵn nhiều method asserts cho HTTP Test.

Ở phần trên, chúng ta mới chỉ ví dụ về các input đơn giản, còn 2 vấn đề chưa được nhắc tới đó là test database rule unique và image upload.

Database rule

Cách đơn giản nhất theo hướng tiếp cận Integration Test đó là sử dụng database test để tạo dữ liệu mẫu theo test case.

Ví dụ ở ProductCreateRequest chúng ta có khai báo rule unique cho sku, vậy thì cách đơn giản nhất là chuẩn bị database test với 1 bản khi có sku = existed-sku, sau đó trong test case bạn sẽ truyền input cho sku là existed-sku.

Thông thường factory sẽ được sử dụng để tạo data test cho model.

factory không sử dụng được bên trong data provider của phpunit (data provider function của phpunit được chạy trước function setUp() vốn được Laravel dùng để bootstrap, binding service container...), nên chúng ta cần thay đổi chút về method test, chuyển về dạng callback:

/**
 * ...
 * @dataProvider provideInvalidSku
 * ...
 */
function test_it_show_error_when_input_invalid($inputKey, $inputValue)
{
    $url = action([ProductController::class, 'store']);
    $inputs = $this->makeInvalidData([
        $inputKey => is_callable($inputValue) ? $inputValue() : $inputValue,
    ]);

    $response = $this->post($url, $inputs);

    $response->assertSessionHasErrors([$inputKey]);
}

function provideInvalidSku()
{
    return [
        // Tên dataset => dataset value [$inputKey, $inputValue]
        'SKU is required' => ['sku', null],
        'SKU must be unique' => ['sku' => function () {
            Product::factory()->create(['sku' => 'existed-sku']);

            return 'existed-sku';
        }],
    ];
}

File upload

Laravel hỗ trợ tạo fake upload file, chúng ta có thể áp dụng nó như sau:

function provideInvalidImage()
{
    return [
        // Tên dataset => dataset value [$inputKey, $inputValue]
        'Image must be jpg or png' => ['image' => function () {
            Storage::fake();

            return UploadedFile::fake()->image('product.gif');
        }],
    ];
}

Rất đơn giản phải không.

Kết luận

Như các bạn thấy, có nhiều cách để test form controller và form request trong Laravel, vì vậy việc làm theo hướng nào phụ thuộc vào chính bạn hay dự án quyết định, tuy nhiên theo quan điểm cá nhân thì mình vẫn ưu tiên theo cách thực hiện integration test. Còn ý kiến của bạn thì sao?


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí